Files
skyview.astronomiemuseum.de/public/jupitersystem.php
T
2026-04-12 11:55:31 +02:00

1782 lines
67 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
ini_set('display_errors', '1');
error_reporting(E_ALL);
session_start();
require_once __DIR__ . '/auth_helpers.php';
$loggedIn = isset($_SESSION['user_id']);
$isAdmin = $loggedIn && currentUserIsAdminLike();
// AJAX-Handler: GRS-Kalibrierung lesen/speichern
if (isset($_GET['grs_api'])) {
$dataFile = __DIR__ . '/../config/jupiter_grs.json';
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
echo file_exists($dataFile)
? file_get_contents($dataFile)
: json_encode(['refDate' => null, 'refAngle' => 0, 'calibratedAt' => null, 'calibratedBy' => null]);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$isAdmin) { http_response_code(403); echo json_encode(['error' => 'Nicht autorisiert']); exit; }
$body = json_decode(file_get_contents('php://input'), true);
if (!$body || !isset($body['refDate'], $body['refAngle'])) { http_response_code(400); echo json_encode(['error' => 'Ungültige Daten']); exit; }
$existing = [];
if (file_exists($dataFile)) {
$existing = json_decode(file_get_contents($dataFile), true) ?? [];
}
$history = $existing['history'] ?? [];
$history[] = [
'refDate' => (string) $body['refDate'],
'refAngle' => (float) $body['refAngle'],
'calibratedAt' => date('c'),
'calibratedBy' => $_SESSION['username'] ?? 'admin',
];
usort($history, fn($a, $b) => strcmp((string) $a['refDate'], (string) $b['refDate']));
$latest = end($history);
$data = [
'refDate' => $latest['refDate'],
'refAngle' => $latest['refAngle'],
'calibratedAt' => $latest['calibratedAt'],
'calibratedBy' => $latest['calibratedBy'],
'history' => $history,
];
$written = file_put_contents($dataFile, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
echo $written !== false ? json_encode(['ok' => true]) : json_encode(['error' => 'Schreibfehler – Dateiberechtigung prüfen']);
exit;
}
http_response_code(405); echo json_encode(['error' => 'Methode nicht erlaubt']); exit;
}
$pageTitle = 'Jupitersystem - AstroTools';
$bodyClass = 'solarsystem-page jupitersystem-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>Jupitersystem</h2>
</div>
<div class="solarsystem-sidebar-block">
<div class="solarsystem-label">Datum</div>
<div class="solarsystem-input-row">
<input type="date" id="jupiterDatePicker">
<button type="button" class="btn btn-secondary" id="jupiterBtnToday">Heute</button>
</div>
<div class="solarsystem-current-date" id="jupiterCurrentDate">--</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="jupiterBtnPlay">Play</button>
<button type="button" class="btn btn-secondary" id="jupiterBtnReset">Jetzt</button>
<button type="button" class="btn btn-secondary" id="jupiterBtnMinus5">−5 min</button>
<button type="button" class="btn btn-secondary" id="jupiterBtnPlus5">+5 min</button>
</div>
</div>
<div class="solarsystem-sidebar-block">
<div class="solarsystem-label">Geschwindigkeit</div>
<input type="range" id="jupiterSpeedSlider" min="-12" max="12" step="0.01" value="1" list="jupiterSpeedTicks">
<datalist id="jupiterSpeedTicks">
<option value="-12"></option>
<option value="-6"></option>
<option value="0"></option>
<option value="6"></option>
<option value="12"></option>
</datalist>
<div class="jupiter-speed-scale">
<span>−12h</span><span>−6h</span><span>0</span><span>+6h</span><span>+12h</span>
</div>
<div class="solarsystem-speed-value" id="jupiterSpeedLabel">1.0 h/s</div>
<button type="button" class="btn btn-sm btn-secondary mt-md" id="jupiterBtnRealtime">Echtzeit</button>
</div>
<div class="solarsystem-sidebar-block">
<div class="solarsystem-label">Monatsübersicht</div>
<button type="button" class="btn btn-secondary btn-full" id="jupiterBtnEphemeris">Mondgrafik erzeugen</button>
</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="jupiterBtnTop">Top</button>
<button type="button" class="btn btn-secondary" id="jupiterBtnSide">Seite</button>
<button type="button" class="btn btn-secondary" id="jupiterBtnEarth">Erde</button>
<button type="button" class="btn btn-secondary is-active" id="jupiterBtnOrbits">Orbits</button>
<button type="button" class="btn btn-secondary" id="jupiterBtnLabels">Namen</button>
<button type="button" class="btn btn-secondary" id="jupiterBtnInnerMoons">Zusatzmonde</button>
</div>
<label class="form-checkbox mt-sm" title="Vergrößert nur die Darstellung der Monde. Der Schatten auf Jupiter bleibt in der realen Größe.">
<input type="checkbox" id="jupiterChkMoonScale">
Monde 4× vergrößert
</label>
</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 Jupiter oder Mond: Fokus setzen</li>
<li>Erneuter Klick ins Leere: Fokus loesen</li>
</ul>
</div>
<?php if ($isAdmin): ?>
<div class="solarsystem-sidebar-block">
<details id="grsAdminPanel">
<summary class="solarsystem-label summary-buttonless">&#9660; GRS Kalibrierung</summary>
<div class="grs-admin-body">
<div class="solarsystem-label mt-sm">GRS Transit-Zeitpunkt (UTC)</div>
<p class="hint mt-2xs"><a href="https://skyandtelescope.org/stargazing-and-observing/interactive-stargazing-tools/transit-times-of-jupiters-great-red-spot/" target="_blank" rel="noopener">↗ Sky&amp;Telescope: GRS-Transitzeiten</a></p>
<input type="text" id="grsTransitInput" class="w-full mt-2xs" placeholder="03/27/2026 @ 05:00 UT">
<button class="btn btn-secondary btn-full mt-xs" id="grsBtnSetTime">Auf diese Zeit setzen</button>
<div id="grsParseError" class="msg-error mt-2xs is-hidden"></div>
<div class="solarsystem-label mt-md">Jupiter drehen</div>
<div class="solarsystem-button-grid mt-2xs">
<button class="btn btn-secondary" id="grsBtnRot5L">&#9664;&#9664; 5°</button>
<button class="btn btn-secondary" id="grsBtnRot1L">&#9664; 1°</button>
<button class="btn btn-secondary" id="grsBtnRot1R">1° &#9654;</button>
<button class="btn btn-secondary" id="grsBtnRot5R">5° &#9654;&#9654;</button>
</div>
<button class="btn btn-secondary btn-full mt-md" id="grsBtnShadowRays">Schattenstrahlen anzeigen</button>
<button class="btn btn-primary btn-full mt-xs" id="grsBtnSave">Kalibrierung speichern</button>
<div id="grsCalibStatus" class="hint inline-fade mt-sm"></div>
<div id="grsDriftSection" style="display:none">
<div class="solarsystem-label mt-md">GRS Drift-Historie</div>
<p class="hint mt-2xs">Abweichung vom System-II-Rhythmus zwischen je zwei Abgleichen.</p>
<div id="grsDriftAvg" class="hint mt-2xs" style="color:var(--gold-pale)"></div>
<div style="overflow-x:auto;margin-top:0.5rem">
<table id="grsDriftTable" class="data-table" style="font-size:0.8rem;white-space:nowrap"></table>
</div>
</div>
</div>
</details>
</div>
<?php endif; ?>
</aside>
<section class="card solarsystem-stage-card">
<div class="solarsystem-stage-wrap" id="jupiterStageWrap">
<canvas id="jupiterCanvas"></canvas>
<div id="jupiterLabels" class="solarsystem-label-layer"></div>
<div class="solarsystem-info-panel" id="jupiterInfoPanel" hidden>
<h3 id="jupiterInfoName">--</h3>
<div class="solarsystem-info-row"><span>Abstand Jupiter</span><strong id="jupiterInfoDistanceRj">--</strong></div>
<div class="solarsystem-info-row"><span>Abstand km</span><strong id="jupiterInfoDistanceKm">--</strong></div>
<div class="solarsystem-info-row"><span>Umlaufzeit</span><strong id="jupiterInfoPeriod">--</strong></div>
<div class="solarsystem-info-row"><span>Helligkeit</span><strong id="jupiterInfoMagnitude">--</strong></div>
<div class="solarsystem-info-row"><span>Typ</span><strong id="jupiterInfoType">--</strong></div>
</div>
<div class="solarsystem-overlay" id="jupiterOverlay">Initialisiere Visualisierung...</div>
</div>
</section>
</div>
</div>
<script type="module">
const AstronomyEngine = window.Astronomy;
const AU_KM = 149597870.7;
const JUPITER_RADIUS_KM = AstronomyEngine.JUPITER_EQUATORIAL_RADIUS_KM;
const JUPITER_DISPLAY_RADIUS = 5.8;
// Positions from JupiterMoons() are in AU → convert to display units
// so that 1 Jupiter radius = JUPITER_DISPLAY_RADIUS display units
const MOON_DISTANCE_SCALE = (JUPITER_DISPLAY_RADIUS / JUPITER_RADIUS_KM) * AU_KM;
const KM_TO_DISPLAY = JUPITER_DISPLAY_RADIUS / JUPITER_RADIUS_KM;
// Jupiter System III (Referenz): 9h 55m 29.7s — System II (GRS): 9h 55m 40.632s
const GRS_SYSTEM_II_PERIOD_DAYS = 35740.632 / 86400;
// GRS-Kalibrierung: Referenzzeitpunkt und Rotationswinkel bei Transit
let grsRefDate = new Date(0);
let grsRefAngle = 0;
const MOONS = [
{ key: 'io', label: 'Io', color: '#f4d16b', orbitalPeriodDays: 1.769, radiusKm: AstronomyEngine.IO_RADIUS_KM, texture: 'images/moons/io.jpg', type: 'Galileischer Mond' },
{ key: 'europa', label: 'Europa', color: '#d8c7a2', orbitalPeriodDays: 3.551, radiusKm: AstronomyEngine.EUROPA_RADIUS_KM, texture: 'images/moons/europa.jpg', type: 'Galileischer Mond' },
{ key: 'ganymede', label: 'Ganymed', color: '#9f8f7c', orbitalPeriodDays: 7.155, radiusKm: AstronomyEngine.GANYMEDE_RADIUS_KM, texture: 'images/moons/ganymede.jpg', type: 'Galileischer Mond' },
{ key: 'callisto', label: 'Kallisto', color: '#7f7164', orbitalPeriodDays: 16.689, radiusKm: AstronomyEngine.CALLISTO_RADIUS_KM, texture: 'images/moons/callisto.jpg', type: 'Galileischer Mond' }
].map(moon => ({ ...moon, size: moon.radiusKm * KM_TO_DISPLAY }));
// Innere Monde — Keplerbahnen, IAU-Elemente (Epoche J2000)
const INNER_MOON_EPOCH = new Date('2000-01-01T12:00:00Z');
const INNER_MOON_MIN_RADIUS = 0.08; // Mindest-Anzeigeradius für Sichtbarkeit
const INNER_MOONS = [
{ key: 'metis', label: 'Metis', color: '#9a9a9a', semiMajorAxisKm: 127690, eccentricity: 0.0002, inclinationDeg: 0.06, orbitalPeriodDays: 0.29478, radiusKm: 21.5, M0deg: 0, type: 'Innerer Mond' },
{ key: 'amalthea', label: 'Amalthea', color: '#b03010', semiMajorAxisKm: 181366, eccentricity: 0.0032, inclinationDeg: 0.374, orbitalPeriodDays: 0.49818, radiusKm: 83.5, M0deg: 120, type: 'Innerer Mond' },
{ key: 'thebe', label: 'Thebe', color: '#787878', semiMajorAxisKm: 221900, eccentricity: 0.0177, inclinationDeg: 1.076, orbitalPeriodDays: 0.6745, radiusKm: 49.3, M0deg: 0, type: 'Innerer Mond' },
].map(m => ({ ...m, size: Math.max(m.radiusKm * KM_TO_DISPLAY, INNER_MOON_MIN_RADIUS) }));
const stageWrap = document.getElementById('jupiterStageWrap');
const canvas = document.getElementById('jupiterCanvas');
const labelsLayer = document.getElementById('jupiterLabels');
const overlay = document.getElementById('jupiterOverlay');
const datePicker = document.getElementById('jupiterDatePicker');
const currentDateLabel = document.getElementById('jupiterCurrentDate');
const btnToday = document.getElementById('jupiterBtnToday');
const btnPlay = document.getElementById('jupiterBtnPlay');
const btnReset = document.getElementById('jupiterBtnReset');
const btnTop = document.getElementById('jupiterBtnTop');
const btnSide = document.getElementById('jupiterBtnSide');
const btnEarth = document.getElementById('jupiterBtnEarth');
const btnOrbits = document.getElementById('jupiterBtnOrbits');
const btnLabels = document.getElementById('jupiterBtnLabels');
const btnInnerMoons = document.getElementById('jupiterBtnInnerMoons');
const chkMoonScale = document.getElementById('jupiterChkMoonScale');
const speedSlider = document.getElementById('jupiterSpeedSlider');
const speedLabel = document.getElementById('jupiterSpeedLabel');
const infoPanel = document.getElementById('jupiterInfoPanel');
let THREE;
let rotationEqjToEcl;
let renderer;
let scene;
let camera;
let textureLoader;
let moonGroups;
let moonShadowCasters;
let moonOrbitLines;
let innerMoonGroups;
let innerMoonShadowCasters;
let innerMoonOrbitLines;
let labelElements;
let jupiterGroup;
let sunLight;
let meridianLine;
let shadowRays = [];
let showShadowRays = false;
let raycaster;
let pointer;
let showOrbits = true;
let showLabels = false;
let moonScaleEnlarged = false;
let showInnerMoons = false;
let playing = true;
let speedDaysPerSecond = 1 / 24;
let focusedObjectKey = null;
let simDate = new Date();
let lastAnimationTime = performance.now();
let cameraTarget;
let camTheta = 0.45;
let camPhi = 1.0;
let camRadius = 95;
let camRadiusTarget = 95;
let isDragging = false;
let pointerDownMoved = false;
let lastPointerX = 0;
let lastPointerY = 0;
if (!AstronomyEngine) {
overlay.textContent = 'Astronomy Engine konnte nicht geladen werden.';
throw new Error('Astronomy Engine unavailable');
}
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) {
const totalMinutes = daysPerSecond * 24 * 60;
if (Math.abs(totalMinutes) < 0.5) {
return '0 min/s';
}
const sign = totalMinutes < 0 ? '−' : '';
const abs = Math.abs(totalMinutes);
if (abs < 60) {
return `${sign}${Math.round(abs)} min/s`;
}
const h = Math.floor(abs / 60);
const m = Math.round(abs % 60);
return m > 0 ? `${sign}${h} h ${m} min/s` : `${sign}${h} h/s`;
}
function updateSpeedFromSlider() {
speedDaysPerSecond = Number(speedSlider.value) / 24;
speedLabel.textContent = normalizedSpeedText(speedDaysPerSecond);
}
function refreshCurrentDateUi() {
currentDateLabel.textContent = formatDisplayDate(simDate);
datePicker.value = formatDateInput(simDate);
}
function resizeRenderer() {
const width = stageWrap.clientWidth;
const height = Math.max(520, Math.min(Math.round(width * 0.62), Math.round(window.innerHeight * 0.78)));
stageWrap.style.height = `${height}px`;
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 getMoonMeta(key) {
return MOONS.find((moon) => moon.key === key) || null;
}
function getJupiterMoonStates(date) {
return AstronomyEngine.JupiterMoons(date);
}
function toDisplayPosition(state) {
const ecl = rotationEqjToEcl
? AstronomyEngine.RotateVector(rotationEqjToEcl, state)
: state;
return new THREE.Vector3(
ecl.x * MOON_DISTANCE_SCALE,
ecl.z * MOON_DISTANCE_SCALE,
-ecl.y * MOON_DISTANCE_SCALE
);
}
function getMoonDistanceFromJupiter(state) {
const distAu = Math.hypot(state.x, state.y, state.z);
const distKm = distAu * AU_KM;
return {
rj: distKm / JUPITER_RADIUS_KM,
km: distKm,
};
}
function getJupiterMagnitude(date) {
try {
const illumination = AstronomyEngine.Illumination(AstronomyEngine.Body.Jupiter, date);
return illumination && Number.isFinite(illumination.mag) ? illumination.mag : null;
} catch (error) {
return null;
}
}
function loadTexture(path, onLoaded, onError) {
return textureLoader.load(
path,
(loadedTexture) => {
loadedTexture.colorSpace = THREE.SRGBColorSpace;
loadedTexture.anisotropy = Math.min(renderer.capabilities.getMaxAnisotropy(), 8);
loadedTexture.needsUpdate = true;
if (typeof onLoaded === 'function') {
onLoaded(loadedTexture);
}
},
undefined,
(error) => {
console.warn(`Textur konnte nicht geladen werden: ${path}`);
if (typeof onError === 'function') {
onError(error);
}
}
);
}
function updateInfoPanel(key) {
if (key === 'jupiter') {
const magnitude = getJupiterMagnitude(simDate);
infoPanel.hidden = false;
document.getElementById('jupiterInfoName').textContent = 'Jupiter';
document.getElementById('jupiterInfoName').style.color = '#f0cf8d';
document.getElementById('jupiterInfoDistanceRj').textContent = '0.0 Rj';
document.getElementById('jupiterInfoDistanceKm').textContent = '0 km';
document.getElementById('jupiterInfoPeriod').textContent = `${(AstronomyEngine.PlanetOrbitalPeriod(AstronomyEngine.Body.Jupiter) / 365.25).toFixed(2)} Jahre`;
document.getElementById('jupiterInfoMagnitude').textContent = magnitude === null ? '--' : `${magnitude.toFixed(1)} mag`;
document.getElementById('jupiterInfoType').textContent = 'Gasriese';
return;
}
const moon = getMoonMeta(key);
if (!moon) {
infoPanel.hidden = true;
return;
}
const state = getJupiterMoonStates(simDate)[moon.key];
const distance = getMoonDistanceFromJupiter(state);
infoPanel.hidden = false;
document.getElementById('jupiterInfoName').textContent = moon.label;
document.getElementById('jupiterInfoName').style.color = moon.color;
document.getElementById('jupiterInfoDistanceRj').textContent = `${distance.rj.toFixed(2)} Rj`;
document.getElementById('jupiterInfoDistanceKm').textContent = `${distance.km.toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, '.')} km`;
document.getElementById('jupiterInfoPeriod').textContent = `${moon.orbitalPeriodDays.toFixed(3)} Tage`;
document.getElementById('jupiterInfoMagnitude').textContent = '--';
document.getElementById('jupiterInfoType').textContent = moon.type;
}
function clearFocus() {
focusedObjectKey = null;
cameraTarget.set(0, 0, 0);
infoPanel.hidden = true;
camRadiusTarget = camRadius;
updateCamera();
}
function buildLabels() {
const objects = [{ key: 'jupiter', label: 'Jupiter', color: '#f0cf8d' }, ...MOONS, ...INNER_MOONS];
objects.forEach((entry) => {
const label = document.createElement('div');
label.className = 'solarsystem-planet-label';
label.textContent = entry.label;
label.style.color = entry.color;
label.hidden = true;
labelsLayer.appendChild(label);
labelElements.set(entry.key, label);
});
}
function createMoonOrbitLine(moon) {
const steps = 180;
const positions = [];
const referenceDate = new Date(simDate);
for (let step = 0; step <= steps; step += 1) {
const sampleDate = new Date(referenceDate.getTime() + ((moon.orbitalPeriodDays * step) / steps) * 86400000);
const moonState = getJupiterMoonStates(sampleDate)[moon.key];
const display = toDisplayPosition(moonState);
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(moon.color).multiplyScalar(0.9),
transparent: true,
opacity: 0.55,
fog: false,
});
const line = new THREE.Line(geometry, material);
line.visible = showOrbits;
moonOrbitLines.set(moon.key, line);
scene.add(line);
}
function rebuildMoonOrbitLine(moon) {
const line = moonOrbitLines.get(moon.key);
if (!line) {
return;
}
const steps = 180;
const positions = [];
const referenceDate = new Date(simDate);
for (let step = 0; step <= steps; step += 1) {
const sampleDate = new Date(referenceDate.getTime() + ((moon.orbitalPeriodDays * step) / steps) * 86400000);
const moonState = getJupiterMoonStates(sampleDate)[moon.key];
const display = toDisplayPosition(moonState);
positions.push(display.x, display.y, display.z);
}
line.geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
line.geometry.attributes.position.needsUpdate = true;
}
// ── Kepler-Solver für innere Monde ──────────────────────────────────────────
function solveKepler(M, e) {
let E = M % (2 * Math.PI);
for (let i = 0; i < 10; i++) {
E -= (E - e * Math.sin(E) - M) / (1 - e * Math.cos(E));
}
return E;
}
function innerMoonPosition(moon, date) {
const tDays = (date - INNER_MOON_EPOCH) / 86400000;
const M = (moon.M0deg * Math.PI / 180) + (2 * Math.PI / moon.orbitalPeriodDays) * tDays;
const E = solveKepler(M, moon.eccentricity);
const nu = 2 * Math.atan2(
Math.sqrt(1 + moon.eccentricity) * Math.sin(E / 2),
Math.sqrt(1 - moon.eccentricity) * Math.cos(E / 2)
);
const r = moon.semiMajorAxisKm * (1 - moon.eccentricity * Math.cos(E));
const i = moon.inclinationDeg * Math.PI / 180;
const x = r * Math.cos(nu);
const yOrb = r * Math.sin(nu);
return new THREE.Vector3(
x * KM_TO_DISPLAY,
yOrb * Math.sin(i) * KM_TO_DISPLAY,
-yOrb * Math.cos(i) * KM_TO_DISPLAY
);
}
function createInnerMoonOrbitLine(moon) {
const steps = 120;
const positions = [];
for (let s = 0; s <= steps; s++) {
const sampleDate = new Date(simDate.getTime() + (moon.orbitalPeriodDays * s / steps) * 86400000);
const p = innerMoonPosition(moon, sampleDate);
positions.push(p.x, p.y, p.z);
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
const line = new THREE.Line(geo, new THREE.LineBasicMaterial({
color: new THREE.Color(moon.color).multiplyScalar(0.85),
transparent: true, opacity: 0.45, fog: false,
}));
line.visible = showInnerMoons && showOrbits;
innerMoonOrbitLines.set(moon.key, line);
scene.add(line);
}
function rebuildInnerMoonOrbitLine(moon) {
const line = innerMoonOrbitLines.get(moon.key);
if (!line) return;
const steps = 120;
const positions = [];
for (let s = 0; s <= steps; s++) {
const sampleDate = new Date(simDate.getTime() + (moon.orbitalPeriodDays * s / steps) * 86400000);
const p = innerMoonPosition(moon, sampleDate);
positions.push(p.x, p.y, p.z);
}
line.geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
line.geometry.attributes.position.needsUpdate = true;
}
function createInnerMoons() {
INNER_MOONS.forEach((moon) => {
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(moon.size, 24, 24),
new THREE.MeshStandardMaterial({ color: new THREE.Color(moon.color), roughness: 0.9, metalness: 0.0, fog: false })
);
mesh.userData.objectKey = moon.key;
mesh.castShadow = false; // Shadow-Caster übernimmt immer
mesh.receiveShadow = true;
mesh.visible = showInnerMoons;
scene.add(mesh);
const shadowCaster = new THREE.Mesh(
new THREE.SphereGeometry(moon.size, 12, 12),
new THREE.MeshStandardMaterial()
);
shadowCaster.castShadow = false;
shadowCaster.visible = false;
scene.add(shadowCaster);
innerMoonGroups.set(moon.key, mesh);
innerMoonShadowCasters.set(moon.key, shadowCaster);
createInnerMoonOrbitLine(moon);
});
}
function updateInnerMoonPositions() {
if (!showInnerMoons) return;
INNER_MOONS.forEach((moon) => {
const mesh = innerMoonGroups.get(moon.key);
const shadowCaster = innerMoonShadowCasters.get(moon.key);
const pos = innerMoonPosition(moon, simDate);
if (mesh) mesh.position.copy(pos);
if (shadowCaster) shadowCaster.position.copy(pos);
});
}
function createJupiterSystemObjects() {
const jupiterMaterial = new THREE.MeshStandardMaterial({
color: 0xffffff,
emissive: new THREE.Color(0x2b1908),
emissiveIntensity: 0.10,
roughness: 0.92,
metalness: 0.00,
});
loadTexture('./images/planets/jupiter_2048.jpg', (texture) => {
jupiterMaterial.map = texture;
jupiterMaterial.needsUpdate = true;
});
jupiterGroup = new THREE.Mesh(
new THREE.SphereGeometry(5.8, 64, 64),
jupiterMaterial
);
jupiterGroup.userData.objectKey = 'jupiter';
jupiterGroup.scale.set(1, 66854 / 71492, 1); // Abplattung: Polarradius/Äquatorialradius
jupiterGroup.receiveShadow = true;
jupiterGroup.castShadow = true;
scene.add(jupiterGroup);
MOONS.forEach((moon) => {
const moonMaterial = new THREE.MeshStandardMaterial({
color: new THREE.Color(moon.color),
emissive: new THREE.Color(0x0a0a0a),
emissiveIntensity: 0.10,
roughness: 0.92,
metalness: 0.00,
fog: false,
});
loadTexture(`./${moon.texture}`, (texture) => {
moonMaterial.map = texture;
moonMaterial.color.set(0xffffff);
moonMaterial.needsUpdate = true;
});
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(moon.size, 32, 32),
moonMaterial
);
mesh.userData.objectKey = moon.key;
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
// Shadow-Caster in Realgröße — nur aktiv wenn Monde visuell vergrößert sind
const shadowCaster = new THREE.Mesh(
new THREE.SphereGeometry(moon.size, 16, 16),
new THREE.MeshStandardMaterial()
);
shadowCaster.castShadow = false;
shadowCaster.visible = false;
scene.add(shadowCaster);
moonShadowCasters.set(moon.key, shadowCaster);
moonGroups.set(moon.key, mesh);
createMoonOrbitLine(moon);
});
}
function updateSunDirection() {
// Jupiterposition heliozentrisch in EQJ → negiert = Richtung Jupiter→Sonne
const jupVec = AstronomyEngine.HelioVector(AstronomyEngine.Body.Jupiter, simDate);
const toSun = { x: -jupVec.x, y: -jupVec.y, z: -jupVec.z };
const ecl = AstronomyEngine.RotateVector(rotationEqjToEcl, toSun);
// Nur die Richtung verwenden (normieren), Licht auf feste Distanz von 800 setzen
const len = Math.hypot(ecl.x, ecl.z, ecl.y);
sunLight.position.set(
(ecl.x / len) * 800,
(ecl.z / len) * 800,
(-ecl.y / len) * 800
);
}
function updateMoonPositions(deltaDays) {
const states = getJupiterMoonStates(simDate);
MOONS.forEach((moon) => {
const mesh = moonGroups.get(moon.key);
if (!mesh) {
return;
}
const display = toDisplayPosition(states[moon.key]);
mesh.position.copy(display);
const shadowCaster = moonShadowCasters.get(moon.key);
if (shadowCaster) shadowCaster.position.copy(display);
// Monde sind gezeitengebunden → keine unabhängige Eigenrotation
});
if (jupiterGroup) {
const elapsedDays = (simDate - grsRefDate) / 86400000;
jupiterGroup.rotation.y = grsRefAngle + (elapsedDays / GRS_SYSTEM_II_PERIOD_DAYS) * Math.PI * 2;
}
}
function updateLabels() {
labelElements.forEach((label, key) => {
label.hidden = !showLabels;
if (!showLabels) {
return;
}
const object = key === 'jupiter' ? jupiterGroup
: (moonGroups.get(key) || innerMoonGroups.get(key));
if (!object || !object.visible) {
label.hidden = true;
return;
}
const projected = object.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`;
});
}
function pickObject(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 pickables = [jupiterGroup, ...Array.from(moonGroups.values())].filter(Boolean);
const hits = raycaster.intersectObjects(pickables, false);
if (hits.length === 0) {
clearFocus();
return;
}
const objectKey = hits[0].object.userData.objectKey || null;
if (!objectKey) {
clearFocus();
return;
}
focusedObjectKey = objectKey;
updateInfoPanel(objectKey);
}
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.18, Math.min(Math.PI - 0.18, camPhi + dy * 0.005));
lastPointerX = event.clientX;
lastPointerY = event.clientY;
updateCamera();
});
canvas.addEventListener('pointerup', (event) => {
if (!pointerDownMoved) {
pickObject(event.clientX, event.clientY);
}
isDragging = false;
canvas.releasePointerCapture(event.pointerId);
});
canvas.addEventListener('wheel', (event) => {
event.preventDefault();
camRadiusTarget = Math.max(8, Math.min(600, camRadiusTarget * (1 + event.deltaY * 0.001)));
camRadius = camRadiusTarget;
updateCamera();
}, { passive: false });
}
function parseGrsTransitInput(str) {
// Erwartet: "MM/DD/YYYY @ HH:MM UT" oder "MM/DD/YYYY @ HH:MM"
const match = str.trim().match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})\s*@\s*(\d{1,2}):(\d{2})(\s*UT)?$/i);
if (!match) return null;
const [, mm, dd, yyyy, hh, min] = match;
const iso = `${yyyy}-${mm.padStart(2,'0')}-${dd.padStart(2,'0')}T${hh.padStart(2,'0')}:${min}:00Z`;
const d = new Date(iso);
return isNaN(d.getTime()) ? null : d;
}
function renderGrsDrift(history) {
const section = document.getElementById('grsDriftSection');
const table = document.getElementById('grsDriftTable');
const avgEl = document.getElementById('grsDriftAvg');
if (!section || !table || !avgEl || !Array.isArray(history) || history.length < 2) {
if (section) section.style.display = 'none';
return;
}
const RAD_TO_DEG = 180 / Math.PI;
let rows = '';
const driftRates = [];
for (let i = 1; i < history.length; i++) {
const prev = history[i - 1];
const curr = history[i];
const t1 = new Date(prev.refDate).getTime();
const t2 = new Date(curr.refDate).getTime();
const elapsedDays = (t2 - t1) / 86400000;
if (elapsedDays <= 0) continue;
const expectedDeg = (elapsedDays / GRS_SYSTEM_II_PERIOD_DAYS) * 360;
const actualDeg = (curr.refAngle - prev.refAngle) * RAD_TO_DEG;
// Drift auf den Bereich [-180, 180] normieren
let drift = ((actualDeg - expectedDeg) % 360 + 540) % 360 - 180;
const driftPerDay = drift / elapsedDays;
driftRates.push(driftPerDay);
const sign = driftPerDay >= 0 ? '+' : '';
const color = Math.abs(driftPerDay) < 0.05 ? 'var(--text-dim)' : (driftPerDay > 0 ? 'var(--gold-pale)' : 'var(--cyan)');
const dateStr = new Date(curr.refDate).toLocaleString('de-DE', { timeZone: 'UTC', dateStyle: 'short', timeStyle: 'short' });
rows += `<tr>
<td>${dateStr} UT</td>
<td>${elapsedDays.toFixed(1)} d</td>
<td style="color:${color};font-variant-numeric:tabular-nums">${sign}${drift.toFixed(2)}°</td>
<td style="color:${color};font-variant-numeric:tabular-nums">${sign}${driftPerDay.toFixed(3)}°/d</td>
</tr>`;
}
table.innerHTML = `<thead><tr>
<th>Abgleich</th><th>Abstand</th><th>Drift ges.</th><th>Drift/Tag</th>
</tr></thead><tbody>${rows}</tbody>`;
if (driftRates.length > 0) {
const avg = driftRates.reduce((s, v) => s + v, 0) / driftRates.length;
const sign = avg >= 0 ? '+' : '';
avgEl.textContent = `Ø Drift: ${sign}${avg.toFixed(3)}°/Tag`;
}
section.style.display = '';
}
function formatGrsStatus(data) {
if (!data.calibratedAt) return 'Noch keine Kalibrierung gespeichert.';
const at = new Date(data.calibratedAt).toLocaleString('de-DE', { timeZone: 'UTC', dateStyle: 'short', timeStyle: 'short' });
const ref = data.refDate ? new Date(data.refDate).toLocaleString('de-DE', { timeZone: 'UTC', dateStyle: 'short', timeStyle: 'short' }) : '–';
return `Letzter Abgleich: ${at} UT\nTransit-Ref.: ${ref} UT\nGespeichert von: ${data.calibratedBy || '–'}`;
}
async function loadGrsCalibration() {
try {
const res = await fetch('jupitersystem.php?grs_api');
if (!res.ok) return;
const data = await res.json();
if (data.refDate) {
grsRefDate = new Date(data.refDate);
grsRefAngle = Number(data.refAngle) || 0;
}
const statusEl = document.getElementById('grsCalibStatus');
if (statusEl) statusEl.textContent = formatGrsStatus(data);
renderGrsDrift(data.history);
} catch (e) {}
}
async function saveGrsCalibration() {
const res = await fetch('jupitersystem.php?grs_api', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refDate: grsRefDate.toISOString(), refAngle: grsRefAngle }),
});
const json = await res.json().catch(() => ({}));
return res.ok ? true : (json.error || 'Unbekannter Fehler');
}
function shiftGrsRotation(angleDeg) {
// Verschiebt den GRS-Winkel relativ zur aktuellen Simulationszeit
const elapsedDays = (simDate - grsRefDate) / 86400000;
const currentAngle = grsRefAngle + (elapsedDays / GRS_SYSTEM_II_PERIOD_DAYS) * Math.PI * 2;
grsRefDate = new Date(simDate);
grsRefAngle = currentAngle + (angleDeg * Math.PI / 180);
}
function applyEarthView(targetRadius = 200) {
const geoVec = AstronomyEngine.GeoVector(AstronomyEngine.Body.Jupiter, simDate, true);
const toEarth = { x: -geoVec.x, y: -geoVec.y, z: -geoVec.z };
const ecl = AstronomyEngine.RotateVector(rotationEqjToEcl, toEarth);
const dx = ecl.x, dy = ecl.z, dz = -ecl.y;
camPhi = Math.acos(Math.max(-1, Math.min(1, dy / Math.hypot(dx, dy, dz))));
camTheta = Math.atan2(dx, dz);
camRadius = targetRadius;
camRadiusTarget = targetRadius;
clearFocus();
updateCamera();
}
function createMeridianLine() {
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(65 * 3), 3));
meridianLine = new THREE.Line(geo, new THREE.LineBasicMaterial({
color: 0xffffff, transparent: true, opacity: 0.75, depthTest: false,
}));
meridianLine.visible = false;
meridianLine.renderOrder = 1;
scene.add(meridianLine);
}
function updateMeridianLine() {
if (!meridianLine || !meridianLine.visible) return;
const R = 5.85; // leicht über Jupiters Oberfläche
const camDir = camera.position.clone().sub(cameraTarget).normalize();
const up = new THREE.Vector3(0, 1, 0);
// Projektion der Kamerarichtung auf die Äquatorialebene
const dPerp = camDir.clone().sub(up.clone().multiplyScalar(camDir.dot(up)));
const steps = 64;
const positions = [];
if (dPerp.length() < 0.001) {
// Blick von oben: Kreis auf Äquator
for (let i = 0; i <= steps; i++) {
const t = (i / steps) * Math.PI * 2;
positions.push(R * Math.cos(t), 0, R * Math.sin(t));
}
} else {
dPerp.normalize();
// Meridian: Südpol → Suberdpunkt → Nordpol
for (let i = 0; i <= steps; i++) {
const t = -Math.PI / 2 + (i / steps) * Math.PI;
positions.push(R * dPerp.x * Math.cos(t), R * Math.sin(t), R * dPerp.z * Math.cos(t));
}
}
meridianLine.geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
meridianLine.geometry.attributes.position.needsUpdate = true;
}
function createShadowRays() {
MOONS.forEach((moon, i) => {
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(6), 3));
const colors = ['#f4d16b', '#d8c7a2', '#9f8f7c', '#7f7164'];
const mat = new THREE.LineBasicMaterial({ color: new THREE.Color(colors[i] || '#ffffff'), transparent: true, opacity: 0.7, depthTest: false });
const line = new THREE.Line(geo, mat);
line.visible = false;
line.renderOrder = 2;
scene.add(line);
shadowRays.push({ line, key: moon.key });
});
}
function updateShadowRays() {
if (!showShadowRays) return;
const sunDir = sunLight.position.clone().normalize(); // Richtung Licht→Jupiter
shadowRays.forEach(({ line, key }) => {
const mesh = moonGroups.get(key);
if (!mesh) return;
const moonPos = mesh.position;
// Strahl: vom Mond entgegen der Sonnenrichtung bis hinter Jupiter
const start = moonPos.clone().addScaledVector(sunDir, 200); // Seite der Sonne
const end = moonPos.clone().addScaledVector(sunDir, -200); // Seite Jupiter/dahinter
const pos = [start.x, start.y, start.z, end.x, end.y, end.z];
line.geometry.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
line.geometry.attributes.position.needsUpdate = true;
});
}
function attachAdminHandlers() {
const grsTransitInput = document.getElementById('grsTransitInput');
if (!grsTransitInput) return;
const grsBtnSetTime = document.getElementById('grsBtnSetTime');
const grsBtnRot5L = document.getElementById('grsBtnRot5L');
const grsBtnRot1L = document.getElementById('grsBtnRot1L');
const grsBtnRot1R = document.getElementById('grsBtnRot1R');
const grsBtnRot5R = document.getElementById('grsBtnRot5R');
const grsBtnSave = document.getElementById('grsBtnSave');
const grsCalibStatus = document.getElementById('grsCalibStatus');
const grsParseError = document.getElementById('grsParseError');
grsBtnSetTime.addEventListener('click', () => {
const parsed = parseGrsTransitInput(grsTransitInput.value);
if (!parsed) {
grsParseError.textContent = 'Format ungültig. Erwartet: MM/DD/YYYY @ HH:MM UT';
grsParseError.classList.remove('is-hidden');
return;
}
grsParseError.classList.add('is-hidden');
simDate = parsed;
refreshCurrentDateUi();
MOONS.forEach(rebuildMoonOrbitLine);
// Animation anhalten
playing = false;
btnPlay.textContent = 'Play';
btnPlay.classList.remove('is-active');
// Blick von der Erde, Jupiter füllt die Fläche (~22 = knapp über Oberfläche mit FOV 45°)
applyEarthView(22);
// Zentralmeridian einblenden
if (meridianLine) meridianLine.visible = true;
});
const grsBtnShadowRays = document.getElementById('grsBtnShadowRays');
grsBtnShadowRays.addEventListener('click', () => {
showShadowRays = !showShadowRays;
grsBtnShadowRays.textContent = showShadowRays ? 'Schattenstrahlen ausblenden' : 'Schattenstrahlen anzeigen';
grsBtnShadowRays.classList.toggle('is-active', showShadowRays);
shadowRays.forEach(({ line }) => { line.visible = showShadowRays; });
});
grsBtnRot5L.addEventListener('click', () => shiftGrsRotation(-5));
grsBtnRot1L.addEventListener('click', () => shiftGrsRotation(-1));
grsBtnRot1R.addEventListener('click', () => shiftGrsRotation(1));
grsBtnRot5R.addEventListener('click', () => shiftGrsRotation(5));
grsBtnSave.addEventListener('click', async () => {
if (grsTransitInput.value.trim() === '') {
grsParseError.textContent = 'Bitte zuerst einen GRS-Transitzeitpunkt eingeben und auf „Auf diese Zeit setzen" klicken.';
grsParseError.classList.remove('is-hidden');
return;
}
const elapsedDays = (simDate - grsRefDate) / 86400000;
grsRefAngle = grsRefAngle + (elapsedDays / GRS_SYSTEM_II_PERIOD_DAYS) * Math.PI * 2;
grsRefDate = new Date(simDate);
const ok = await saveGrsCalibration();
if (ok === true && meridianLine) meridianLine.visible = false;
grsCalibStatus.textContent = ok === true
? `Gespeichert: ${simDate.toISOString().slice(0, 16).replace('T', ' ')} UT\nFür alle Nutzer aktiv.`
: `Fehler: ${ok}`;
if (ok === true) {
const res = await fetch('jupitersystem.php?grs_api').catch(() => null);
if (res && res.ok) { const d = await res.json(); renderGrsDrift(d.history); }
}
});
}
function attachUiHandlers() {
btnToday.addEventListener('click', () => {
simDate = new Date();
refreshCurrentDateUi();
});
btnReset.addEventListener('click', () => {
simDate = new Date();
clearFocus();
refreshCurrentDateUi();
});
function togglePlay() {
playing = !playing;
btnPlay.textContent = playing ? 'Pause' : 'Play';
btnPlay.classList.toggle('is-active', playing);
}
btnPlay.addEventListener('click', togglePlay);
document.getElementById('jupiterBtnMinus5').addEventListener('click', () => {
simDate = new Date(simDate.getTime() - 5 * 60000);
refreshCurrentDateUi();
});
document.getElementById('jupiterBtnPlus5').addEventListener('click', () => {
simDate = new Date(simDate.getTime() + 5 * 60000);
refreshCurrentDateUi();
});
document.addEventListener('keydown', (e) => {
if (e.code === 'Space' && e.target === document.body) {
e.preventDefault();
togglePlay();
}
});
btnTop.addEventListener('click', () => {
camPhi = 0.22;
camTheta = 0;
camRadius = 120;
camRadiusTarget = 120;
clearFocus();
updateCamera();
});
btnSide.addEventListener('click', () => {
camPhi = Math.PI / 2;
camTheta = 0;
camRadius = 95;
camRadiusTarget = 95;
clearFocus();
updateCamera();
});
btnEarth.addEventListener('click', () => applyEarthView());
btnOrbits.addEventListener('click', () => {
showOrbits = !showOrbits;
btnOrbits.classList.toggle('is-active', showOrbits);
moonOrbitLines.forEach((line) => {
line.visible = showOrbits;
});
innerMoonOrbitLines.forEach((line) => {
line.visible = showInnerMoons && showOrbits;
});
});
btnLabels.addEventListener('click', () => {
showLabels = !showLabels;
btnLabels.classList.toggle('is-active', showLabels);
labelElements.forEach((label) => {
label.hidden = !showLabels;
});
});
btnInnerMoons.addEventListener('click', () => {
showInnerMoons = !showInnerMoons;
btnInnerMoons.classList.toggle('is-active', showInnerMoons);
INNER_MOONS.forEach((moon) => {
const mesh = innerMoonGroups.get(moon.key);
const line = innerMoonOrbitLines.get(moon.key);
if (mesh) mesh.visible = showInnerMoons;
if (line) line.visible = showInnerMoons && showOrbits;
});
});
chkMoonScale.addEventListener('change', () => {
moonScaleEnlarged = chkMoonScale.checked;
const factor = moonScaleEnlarged ? 4 : 1;
MOONS.forEach((moon) => {
const mesh = moonGroups.get(moon.key);
const shadowCaster = moonShadowCasters.get(moon.key);
if (mesh) {
mesh.scale.setScalar(factor);
mesh.castShadow = !moonScaleEnlarged;
if (shadowCaster) {
shadowCaster.castShadow = moonScaleEnlarged;
shadowCaster.visible = moonScaleEnlarged;
}
}
});
INNER_MOONS.forEach((moon) => {
const mesh = innerMoonGroups.get(moon.key);
if (mesh) mesh.scale.setScalar(factor);
// Shadow-Caster bleibt immer bei moon.size — keine Änderung nötig
});
});
speedSlider.addEventListener('input', updateSpeedFromSlider);
speedSlider.addEventListener('wheel', (e) => {
e.preventDefault();
const step = parseFloat(speedSlider.step) || 0.01;
speedSlider.value = Math.min(speedSlider.max, Math.max(speedSlider.min,
parseFloat(speedSlider.value) + (e.deltaY < 0 ? step : -step)
));
updateSpeedFromSlider();
}, { passive: false });
document.getElementById('jupiterBtnRealtime').addEventListener('click', () => {
speedDaysPerSecond = 1 / 86400; // 1 Sekunde real = 1 Sekunde Simulation
speedSlider.value = 0;
speedLabel.textContent = 'Echtzeit';
if (!playing) {
playing = true;
btnPlay.textContent = 'Pause';
btnPlay.classList.add('is-active');
}
});
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();
MOONS.forEach(rebuildMoonOrbitLine);
});
}
function animate() {
requestAnimationFrame(animate);
const now = performance.now();
const deltaSeconds = (now - lastAnimationTime) / 1000;
lastAnimationTime = now;
const deltaDays = playing ? speedDaysPerSecond * deltaSeconds : 0;
if (playing) {
simDate = new Date(simDate.getTime() + deltaDays * 86400000);
}
updateMoonPositions(deltaDays);
updateInnerMoonPositions();
updateSunDirection();
updateMeridianLine();
updateShadowRays();
refreshCurrentDateUi();
if (focusedObjectKey) {
const object = focusedObjectKey === 'jupiter' ? jupiterGroup : moonGroups.get(focusedObjectKey);
if (object) {
cameraTarget.lerp(object.position, 0.06);
updateInfoPanel(focusedObjectKey);
}
}
camRadius += (camRadiusTarget - camRadius) * 0.18;
updateCamera();
updateLabels();
renderer.render(scene, camera);
}
async function initJupiterSystem() {
try {
THREE = await import('./js/three.module.js');
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);
textureLoader = new THREE.TextureLoader();
moonGroups = new Map();
moonShadowCasters = new Map();
moonOrbitLines = new Map();
innerMoonGroups = new Map();
innerMoonShadowCasters = new Map();
innerMoonOrbitLines = 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();
const ambientLight = new THREE.AmbientLight(0x334466, 0.2);
scene.add(ambientLight);
const hemiLight = new THREE.HemisphereLight(0x445577, 0x111a2e, 0.6);
scene.add(hemiLight);
sunLight = new THREE.DirectionalLight(0xfff5d0, 3.5);
sunLight.position.set(550, 0, 0); // wird jede Frame aktualisiert
sunLight.castShadow = true;
sunLight.shadow.mapSize.width = 4096;
sunLight.shadow.mapSize.height = 4096;
sunLight.shadow.camera.near = 500; // Licht bei 800 → Nahebene bei 300 Einh. von Jupiter (Kallisto max. 153)
sunLight.shadow.camera.far = 1050; // Licht bei 800 + Kallisto hinter Jupiter (153) + Puffer
sunLight.shadow.camera.left = -40;
sunLight.shadow.camera.right = 40;
sunLight.shadow.camera.top = 40;
sunLight.shadow.camera.bottom = -40;
sunLight.shadow.camera.updateProjectionMatrix();
sunLight.shadow.radius = 0.5;
sunLight.shadow.bias = 0.0008;
sunLight.shadow.normalBias = 0.02;
scene.add(sunLight);
scene.add(sunLight.target); // Pflicht: sonst ignoriert Three.js die Shadow-Camera-Ausrichtung
// Starfield
(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 = 700 + 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,
})));
})();
await loadGrsCalibration();
buildLabels();
createJupiterSystemObjects();
createInnerMoons();
createMeridianLine();
createShadowRays();
attachPointerControls();
attachUiHandlers();
attachAdminHandlers();
btnPlay.textContent = 'Pause';
btnPlay.classList.add('is-active');
updateSpeedFromSlider();
refreshCurrentDateUi();
resizeRenderer();
updateMoonPositions(0);
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('Jupitersystem konnte nicht initialisiert werden.', error);
overlay.hidden = false;
overlay.textContent = 'Fehler beim Laden der Visualisierung. Bitte Browser-Konsole pruefen.';
}
}
initJupiterSystem();
/* ═══════════════════════════════════════════════════
MONATSÜBERSICHT — Galileische Monde (DIN A4)
═══════════════════════════════════════════════════ */
function generateEphemerisCanvas() {
const darkMode = false;
// Monat aus simDate
const ref = new Date(simDate);
const year = ref.getFullYear();
const month = ref.getMonth(); // 0-based
const daysInMonth = new Date(year, month + 1, 0).getDate();
const monthName = ref.toLocaleString('de-DE', { month: 'long', year: 'numeric' });
// DIN A4 @ 150 dpi
const W = 1240;
const H = 1754;
const MARGIN_LEFT = 160; // Platz für Tageszahlen + GRS-Zeiten
const MARGIN_RIGHT = 50;
const MARGIN_TOP = 80;
const MARGIN_BOT = 160;
const chartW = W - MARGIN_LEFT - MARGIN_RIGHT;
const chartH = H - MARGIN_TOP - MARGIN_BOT;
const rowH = chartH / daysInMonth;
// Jupiter-Radius in AU → wir skalieren so dass Kallisto (~0.01257 AU) auf halbe Chartbreite passt
const MAX_AU = 0.014; // ±0.014 AU ~ knapp über Kallistos max. Elongation
const scaleX = (chartW / 2) / MAX_AU; // px pro AU
const centerX = MARGIN_LEFT + chartW / 2;
const jupiterPxRadius = 7;
const MOON_COLORS = {
io: '#e07000', // kräftiges Orange
europa: '#1a6abf', // kräftiges Blau
ganymede: '#2a9d40', // kräftiges Grün
callisto: '#9030a0', // kräftiges Violett
};
const MOON_KEYS = ['io', 'europa', 'ganymede', 'callisto'];
const MOON_LABELS = { io: 'Io', europa: 'Europa', ganymede: 'Ganymed', callisto: 'Kallisto' };
// Positionen berechnen: alle 2 Stunden (12 Punkte/Tag) für glatte Kurven
const STEPS_PER_DAY = 12;
const STEP_HOURS = 24 / STEPS_PER_DAY;
const JUPITER_RADIUS_AU = AstronomyEngine.JUPITER_EQUATORIAL_RADIUS_KM / AU_KM;
// Schattenberechnung: Mond wirft Schatten auf Jupiter
// → Mond steht zwischen Sonne und Jupiter (auf der Sonnenseite)
// → Schatten des Mondes (entlang Sonne→Jupiter-Achse projiziert) trifft Jupiterscheibe
function getMoonsNearDisk(date, states) {
const jupHelio = AstronomyEngine.HelioVector(AstronomyEngine.Body.Jupiter, date);
const jupHelioDist = Math.hypot(jupHelio.x, jupHelio.y, jupHelio.z);
// Einheitsvektor Sonne → Jupiter
const sx = jupHelio.x / jupHelioDist;
const sy = jupHelio.y / jupHelioDist;
const sz = jupHelio.z / jupHelioDist;
const result = {};
MOON_KEYS.forEach(key => {
const m = states[key];
// Komponente entlang Sonne→Jupiter: negativ = Mond auf Sonnenseite (zwischen Sonne und Jupiter)
const dot = m.x * sx + m.y * sy + m.z * sz;
if (dot >= 0) return; // Mond auf Schattenseite → kein Schatten auf Jupiter
// Senkrechter Abstand des Mondes von der Sonne-Jupiter-Achse
const pxS = m.x - dot * sx, pyS = m.y - dot * sy, pzS = m.z - dot * sz;
if (Math.hypot(pxS, pyS, pzS) < JUPITER_RADIUS_AU) {
result[key] = 'shadow'; // Mondschatten trifft Jupiterscheibe
}
});
return result;
}
const points = [];
for (let d = 1; d <= daysInMonth; d++) {
for (let s = 0; s < STEPS_PER_DAY; s++) {
const date = new Date(Date.UTC(year, month, d, s * STEP_HOURS, 0, 0));
const states = getJupiterMoonStates(date);
const moonX = {};
MOON_KEYS.forEach(key => { moonX[key] = states[key].x; });
const nearDisk = getMoonsNearDisk(date, states);
points.push({ d, t: (d - 1) + s / STEPS_PER_DAY, date, moonX, nearDisk });
}
}
// Doppel-/Dreifach-Ereignisse erkennen + Beginn auf 5 Min verfeinern
function refineMultiStart(dateA, dateB) {
// Binäre Suche: finde den frühesten Moment wo >= 2 Monde im Schatten sind
let lo = dateA.getTime(), hi = dateB.getTime();
for (let i = 0; i < 8; i++) { // 8 Schritte → ~1 Min Genauigkeit aus 2h
const mid = (lo + hi) / 2;
const midDate = new Date(mid);
const st = getJupiterMoonStates(midDate);
const n = Object.keys(getMoonsNearDisk(midDate, st)).length;
if (n >= 2) hi = mid; else lo = mid;
}
// Auf 5-Minuten-Raster runden
const rounded = Math.round(hi / 300000) * 300000;
return new Date(rounded);
}
const multiEvents = [];
let lastEventT = -999;
points.forEach((pt, i) => {
const inDisk = Object.keys(pt.nearDisk);
if (inDisk.length >= 2 && pt.t - lastEventT > 0.1) {
const prev = i > 0 ? points[i - 1] : pt;
const refinedDate = refineMultiStart(prev.date, pt.date);
const refinedT = pt.t - (pt.date - refinedDate) / 86400000;
multiEvents.push({ t: refinedT, date: refinedDate, moons: inDisk });
lastEventT = pt.t;
}
});
// GRS-Transitzeiten: alle Transits im Monat berechnen
function getGrsTransitsForDay(d) {
if (!grsRefDate || grsRefDate.getTime() === 0) return [];
const dayStart = new Date(Date.UTC(year, month, d, 0, 0, 0)).getTime();
const dayEnd = dayStart + 86400000;
// Erster Transit nach Epoch: finde nächsten Transit ab dayStart
const refMs = grsRefDate.getTime();
const periodMs = GRS_SYSTEM_II_PERIOD_DAYS * 86400000;
// Wie viele Perioden seit refDate bis dayStart?
const n = Math.ceil((dayStart - refMs) / periodMs);
const transits = [];
for (let i = n - 1; i <= n + 3; i++) {
const t = refMs + i * periodMs;
if (t >= dayStart && t < dayEnd) transits.push(new Date(t));
}
return transits;
}
// rows für Jupiter-Kreise und Tageszahlen (einmal pro Tag, Mittag)
const rows = [];
for (let d = 1; d <= daysInMonth; d++) {
const date = new Date(Date.UTC(year, month, d, 12, 0, 0));
const states = getJupiterMoonStates(date);
const moonX = {};
MOON_KEYS.forEach(key => { moonX[key] = states[key].x; });
const grsTransits = getGrsTransitsForDay(d);
rows.push({ d, moonX, grsTransits });
}
// Canvas aufbauen
const cvs = document.createElement('canvas');
cvs.width = W;
cvs.height = H;
const c = cvs.getContext('2d');
// ── Farben je nach Modus ──
const COL = darkMode ? {
bg0: '#06080f', bg1: '#03030d',
text: '#eef4ff', textDim: 'rgba(200,215,240,0.35)',
grid: 'rgba(255,255,255,0.03)',
rowAlt: 'rgba(255,255,255,0.018)',
sep: 'rgba(201,168,76,0.08)',
gold: '#c9a84c', goldLine: 'rgba(201,168,76,0.5)',
axisGlow: 'rgba(232,160,32,0.12)', axisCore: 'rgba(232,160,32,0.35)',
jupRim: 'rgba(255,200,100,0.6)',
moonRim: 'rgba(255,255,255,0.3)',
legBg: 'rgba(6,10,26,0.7)', legBorder: 'rgba(201,168,76,0.3)',
legText: 'rgba(200,215,240,0.65)',
grsCol: '#ff9930',
footText: 'rgba(200,215,240,0.35)',
titleLine: 'rgba(201,168,76,0.5)',
} : {
bg0: '#ffffff', bg1: '#f5f5f8',
text: '#1a1a2e', textDim: 'rgba(30,30,60,0.5)',
grid: 'rgba(0,0,0,0.04)',
rowAlt: 'rgba(0,0,0,0.03)',
sep: 'rgba(100,80,20,0.15)',
gold: '#8a6010', goldLine: 'rgba(120,90,20,0.4)',
axisGlow: 'rgba(180,100,0,0.08)', axisCore: 'rgba(180,100,0,0.5)',
jupRim: 'rgba(180,100,0,0.5)',
moonRim: 'rgba(0,0,0,0.2)',
legBg: 'rgba(240,240,245,0.9)', legBorder: 'rgba(100,80,20,0.3)',
legText: 'rgba(40,40,80,0.8)',
grsCol: '#c05000',
footText: 'rgba(60,60,100,0.5)',
titleLine: 'rgba(120,90,20,0.4)',
};
// ── Hintergrund mit leichtem Verlauf ──
const bgGrad = c.createLinearGradient(0, 0, 0, H);
bgGrad.addColorStop(0, COL.bg0);
bgGrad.addColorStop(1, COL.bg1);
c.fillStyle = bgGrad;
c.fillRect(0, 0, W, H);
// ── Subtile Rasterlinien im Chartbereich ──
c.strokeStyle = COL.grid;
c.lineWidth = 1;
for (let x = centerX - Math.round(MAX_AU / 0.002) * 0.002 * scaleX;
x < MARGIN_LEFT + chartW; x += 0.002 * scaleX) {
c.beginPath(); c.moveTo(x, MARGIN_TOP); c.lineTo(x, MARGIN_TOP + chartH); c.stroke();
}
// ── Titel ──
c.textBaseline = 'alphabetic';
c.fillStyle = COL.gold;
c.font = 'bold 42px monospace';
c.textAlign = 'center';
c.fillText(`Jupitermonde — ${monthName}`, W / 2, 56);
// Goldene Trennlinie unter Titel
const tl = c.createLinearGradient(MARGIN_LEFT, 0, MARGIN_LEFT + chartW, 0);
tl.addColorStop(0, 'transparent');
tl.addColorStop(0.2, COL.titleLine);
tl.addColorStop(0.8, COL.titleLine);
tl.addColorStop(1, 'transparent');
c.strokeStyle = tl; c.lineWidth = 1;
c.beginPath(); c.moveTo(MARGIN_LEFT, 66); c.lineTo(MARGIN_LEFT + chartW, 66); c.stroke();
// ── Mittellinie (Jupiter-Achse) mit Glühen ──
c.strokeStyle = COL.axisGlow;
c.lineWidth = 6;
c.beginPath(); c.moveTo(centerX, MARGIN_TOP); c.lineTo(centerX, MARGIN_TOP + chartH); c.stroke();
c.strokeStyle = COL.axisCore;
c.lineWidth = 1;
c.beginPath(); c.moveTo(centerX, MARGIN_TOP); c.lineTo(centerX, MARGIN_TOP + chartH); c.stroke();
// ── Mondlinien (hochauflösend) ──
MOON_KEYS.forEach(key => {
// Glow-Pass
c.beginPath();
c.strokeStyle = MOON_COLORS[key] + '30'; // hex alpha ~19%
c.lineWidth = 6;
c.lineJoin = 'round';
points.forEach((pt, i) => {
const px = centerX + pt.moonX[key] * scaleX;
const py = MARGIN_TOP + pt.t * rowH;
if (i === 0) c.moveTo(px, py); else c.lineTo(px, py);
});
c.stroke();
// Hauptlinie
c.beginPath();
c.strokeStyle = MOON_COLORS[key];
c.lineWidth = 1.8;
points.forEach((pt, i) => {
const px = centerX + pt.moonX[key] * scaleX;
const py = MARGIN_TOP + pt.t * rowH;
if (i === 0) c.moveTo(px, py); else c.lineTo(px, py);
});
c.stroke();
});
// ── Tageszeilen ──
const fontSize = Math.round(rowH * 0.48);
rows.forEach((row, ri) => {
const py = MARGIN_TOP + (ri + 0.5) * rowH;
// Alternierende Zeilentönung
if (ri % 2 === 0) {
c.fillStyle = COL.rowAlt;
c.fillRect(MARGIN_LEFT, MARGIN_TOP + ri * rowH, chartW, rowH);
}
// Trennlinie
if (ri > 0) {
c.strokeStyle = COL.sep;
c.lineWidth = 0.5;
c.beginPath();
c.moveTo(MARGIN_LEFT, MARGIN_TOP + ri * rowH);
c.lineTo(W - MARGIN_RIGHT, MARGIN_TOP + ri * rowH);
c.stroke();
}
// Tageszahl
c.font = `bold ${fontSize}px monospace`;
c.fillStyle = COL.text;
c.textAlign = 'left';
c.textBaseline = 'middle';
c.fillText(String(row.d).padStart(2, ' '), 4, py);
// GRS-Transitzeiten
if (row.grsTransits.length > 0) {
const grsSize = Math.round(rowH * 0.33);
c.font = `${grsSize}px monospace`;
c.fillStyle = COL.grsCol;
const timeStr = row.grsTransits.map(t => t.toISOString().slice(11, 16)).join(' ');
c.fillText(timeStr, 4 + fontSize * 1.5, py);
}
// Jupiter-Kreis mit Glühen
const jg = c.createRadialGradient(centerX, py, 0, centerX, py, jupiterPxRadius * 2.5);
jg.addColorStop(0, 'rgba(232,160,32,0.25)');
jg.addColorStop(1, 'transparent');
c.beginPath(); c.arc(centerX, py, jupiterPxRadius * 2.5, 0, Math.PI * 2);
c.fillStyle = jg; c.fill();
c.beginPath(); c.arc(centerX, py, jupiterPxRadius, 0, Math.PI * 2);
c.fillStyle = '#e8a020'; c.fill();
c.strokeStyle = COL.jupRim; c.lineWidth = 0.8;
c.stroke();
// Mondpunkte
MOON_KEYS.forEach(key => {
const px = centerX + row.moonX[key] * scaleX;
c.beginPath(); c.arc(px, py, 4, 0, Math.PI * 2);
c.fillStyle = MOON_COLORS[key]; c.fill();
c.strokeStyle = COL.moonRim; c.lineWidth = 0.5; c.stroke();
});
});
// ── Schattenstriche rechts: nur Beginn jeder Finsternis ──
const TICK_X = W - MARGIN_RIGHT + 10;
const TICK_W = 22;
const TICK_H = 3;
const TICK_GAP = 3;
points.forEach((pt, i) => {
const prev = i > 0 ? points[i - 1].nearDisk : {};
MOON_KEYS.forEach((key, ki) => {
// Strich nur beim Übergang: vorher nicht im Schatten, jetzt schon
if (pt.nearDisk[key] && !prev[key]) {
const py = MARGIN_TOP + pt.t * rowH;
c.fillStyle = MOON_COLORS[key];
c.fillRect(TICK_X, py - TICK_H / 2, TICK_W, TICK_H);
}
});
});
// ── Doppel-/Dreifach-Ereignisse ──
multiEvents.forEach(ev => {
const py = MARGIN_TOP + ev.t * rowH;
const isTriple = ev.moons.length >= 3;
c.save();
c.strokeStyle = isTriple ? 'rgba(255,80,80,0.5)' : 'rgba(255,220,80,0.35)';
c.lineWidth = isTriple ? 2 : 1;
c.setLineDash([3, 5]);
c.beginPath(); c.moveTo(MARGIN_LEFT, py); c.lineTo(W - MARGIN_RIGHT - 36, py); c.stroke();
c.setLineDash([]);
c.restore();
const evLabel = (isTriple ? '◆ 3× ' : '◆ 2× ') +
ev.moons.map(k => MOON_LABELS[k]).join('+') +
' ' + ev.date.toISOString().slice(11, 16) + ' UT';
c.font = `bold ${Math.round(rowH * 0.34)}px monospace`;
c.fillStyle = isTriple ? 'rgba(255,110,110,0.95)' : 'rgba(255,220,80,0.9)';
c.textAlign = 'right';
c.textBaseline = 'bottom';
c.fillText(evLabel, W - MARGIN_RIGHT - 38, py - 2);
});
// ── Legende + Fußzeile ──
// Layout: Legendenbox links, Logo rechts daneben, Fußtext ganz unten
const logoH = 64;
const logoW_est = 64; // wird beim Zeichnen korrigiert
const LEG_PAD = 20;
const LEG_X = MARGIN_LEFT;
const LEG_W = chartW - logoW_est - 16;
const LEG_H = 120;
const LEG_Y = H - MARGIN_BOT + 10;
const fontSize2 = 19;
const lineH = 30;
// Rahmen
c.save();
c.strokeStyle = COL.legBorder;
c.lineWidth = 1;
c.beginPath();
c.roundRect(LEG_X, LEG_Y, LEG_W, LEG_H, 8);
c.fillStyle = COL.legBg;
c.fill(); c.stroke();
c.restore();
c.textBaseline = 'middle';
c.textAlign = 'left';
// Zeile 1: Monde (4 Einträge gleichmäßig verteilt)
const row1Y = LEG_Y + LEG_PAD + lineH * 0;
const moonColW = (LEG_W - LEG_PAD * 2) / 4;
MOON_KEYS.forEach((key, i) => {
const x = LEG_X + LEG_PAD + i * moonColW;
c.beginPath(); c.arc(x + 8, row1Y, 6, 0, Math.PI * 2);
c.fillStyle = MOON_COLORS[key]; c.fill();
c.font = `bold ${fontSize2}px monospace`;
c.fillStyle = MOON_COLORS[key];
c.fillText(MOON_LABELS[key], x + 22, row1Y);
});
// Zeile 2: Schattenstriche + GRS + Mehrfach
const row2Y = LEG_Y + LEG_PAD + lineH * 1.6;
const col2W = (LEG_W - LEG_PAD * 2) / 3;
c.font = `${fontSize2}px monospace`;
c.fillStyle = COL.legText;
// Schatten
let cx2 = LEG_X + LEG_PAD;
c.fillStyle = MOON_COLORS['io'];
c.fillRect(cx2, row2Y - 2, 28, 4);
c.fillStyle = COL.legText;
c.fillText('Mondschatten auf Jupiter', cx2 + 36, row2Y);
// GRS
cx2 = LEG_X + LEG_PAD + col2W;
c.font = `${fontSize2}px monospace`;
c.fillStyle = COL.grsCol;
c.fillText('03:42', cx2, row2Y);
c.fillStyle = COL.legText;
c.fillText(' GRS-Transit', cx2 + c.measureText('03:42').width, row2Y);
// Mehrfach
cx2 = LEG_X + LEG_PAD + col2W * 2;
c.font = `bold ${fontSize2}px monospace`;
c.fillStyle = '#c08000';
c.fillText('◆ Mehrfach-Schatten', cx2, row2Y);
// Fußlinie + Text
const footY = H - 14;
const footLineY = H - 34;
const footGrad = c.createLinearGradient(MARGIN_LEFT, 0, MARGIN_LEFT + chartW, 0);
footGrad.addColorStop(0, 'transparent');
footGrad.addColorStop(0.15, COL.goldLine);
footGrad.addColorStop(0.85, COL.goldLine);
footGrad.addColorStop(1, 'transparent');
c.strokeStyle = footGrad; c.lineWidth = 1;
c.beginPath(); c.moveTo(MARGIN_LEFT, footLineY); c.lineTo(MARGIN_LEFT + chartW, footLineY); c.stroke();
c.fillStyle = COL.footText;
c.font = '17px monospace';
c.textAlign = 'center';
c.textBaseline = 'alphabetic';
c.fillText('AstroTools · Astronomiemuseum der Sternwarte Sonneberg · IAU 031 · Alle Zeiten in UT', W / 2, footY);
return new Promise(resolve => {
const logo = new Image();
logo.onload = () => {
const logoH = 64;
const logoW = Math.round(logo.naturalWidth * (logoH / logo.naturalHeight));
const lx = W - MARGIN_RIGHT - logoW;
const ly = LEG_Y + (LEG_H - logoH) / 2;
c.drawImage(logo, lx, ly, logoW, logoH);
resolve({ cvs, year, month });
};
logo.onerror = () => resolve({ cvs, year, month }); // kein Logo → trotzdem fortfahren
logo.src = 'images/logoAM.png';
});
}
document.getElementById('jupiterBtnEphemeris').addEventListener('click', async () => {
const { cvs, year, month } = await generateEphemerisCanvas();
cvs.toBlob(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `jupitermonde_${year}_${String(month + 1).padStart(2, '0')}.png`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
}, 'image/png');
});
</script>
<?php require __DIR__ . '/footer.php'; ?>