Files

1821 lines
66 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
session_start();
/*
* Wichtige Projektvorgabe:
* Diese Visualisierung soll immer so astronomisch korrekt wie sinnvoll moeglich
* bleiben. Positionen, Richtungen, scheinbare Groessen, Schattenachsen und
* verwandte Geometrien muessen deshalb nach Moeglichkeit aus echten Daten und
* Vektoren der AstronomyEngine berechnet werden.
*
* Frei angenaeherte Bewegungen, optische Platzhalter oder "gut aussehende"
* Vereinfachungen sind nur dann akzeptabel, wenn sie klar begruendet sind und
* keine astronomisch falschen Beziehungen zwischen Sonne, Mond, Erde,
* Erdachse, Standort und Schatten erzeugen.
*/
$loggedIn = isset($_SESSION['user_id']);
$pageTitle = 'Sonnenfinsternis – AstroTools';
$bodyClass = 'solarsystem-page';
if (!isset($_SESSION['sonnenfinsternis_export_token']) || !is_string($_SESSION['sonnenfinsternis_export_token'])) {
$_SESSION['sonnenfinsternis_export_token'] = bin2hex(random_bytes(16));
}
$sonnenfinsternisExportToken = $_SESSION['sonnenfinsternis_export_token'];
?>
<?php require __DIR__ . '/header.php'; ?>
<?php
$userLat = isset($currentLocation['latitude']) ? (float)$currentLocation['latitude'] : null;
$userLon = isset($currentLocation['longitude']) ? (float)$currentLocation['longitude'] : null;
$userAlt = isset($currentLocation['elevation']) ? (float)$currentLocation['elevation'] : 0.0;
?>
<script src="js/astronomy.browser.min.js"></script>
<div class="solarsystem-tool">
<div class="solarsystem-layout">
<aside class="card solarsystem-sidebar" id="eclipseSidebar">
<div class="solarsystem-sidebar-block">
<h2>Sonnenfinsternis</h2>
</div>
<div class="solarsystem-sidebar-block">
<div class="solarsystem-label">Zeitraum</div>
<div class="solarsystem-input-row">
<select id="eclipseYearFrom" class="eclipse-year-select"></select>
<span class="eclipse-range-separator">–</span>
<select id="eclipseYearTo" class="eclipse-year-select"></select>
</div>
<button class="btn btn-secondary btn-full" id="eclipseSearchBtn">Finsternisse suchen</button>
</div>
<div class="solarsystem-sidebar-block">
<div class="solarsystem-label">Finsternisliste</div>
<div id="eclipseList" class="eclipse-list">
<p class="hint">Suche läuft…</p>
</div>
</div>
<div class="solarsystem-sidebar-block">
<div class="solarsystem-label">Steuerung</div>
<div class="eclipse-sidebar-controls">
<div class="eclipse-sidebar-buttons">
<button class="btn btn-sm btn-secondary" id="eclipseBtnRewind" title="Zum Anfang">⏮</button>
<button class="btn btn-sm btn-primary" id="eclipseBtnPlay" title="Play/Pause">▶ Play</button>
<button class="btn btn-sm btn-secondary" id="eclipseBtnEnd" title="Zum Ende">⏭</button>
</div>
<div class="eclipse-time-display" id="eclipseTimeDisplay">– – : – – UTC</div>
<label class="eclipse-speed-label eclipse-speed-label-sidebar">
<span class="solarsystem-label">Geschw.</span>
<select id="eclipseSpeedSel" class="eclipse-speed-sel">
<option value="1">×1</option>
<option value="3">×3</option>
<option value="10" selected>×10</option>
<option value="30">×30</option>
<option value="60">×60</option>
</select>
</label>
</div>
</div>
<div class="solarsystem-sidebar-block">
<div class="solarsystem-label">Zeitformat</div>
<label class="form-checkbox" for="eclipseUseLocalTime">
<input type="checkbox" id="eclipseUseLocalTime">
<span>Lokale Zeit statt UTC anzeigen</span>
</label>
<label class="form-checkbox" for="eclipseShowStageTime">
<input type="checkbox" id="eclipseShowStageTime">
<span>Uhrzeit mittig in der Grafik anzeigen</span>
</label>
</div>
<div class="solarsystem-sidebar-block">
<div class="solarsystem-label">Schatten-Skalierung</div>
<label class="solarsystem-label eclipse-control-label" for="umbraScaleRange">
Kernschatten: <span id="umbraScaleValue">0.50</span>
</label>
<input id="umbraScaleRange" class="eclipse-control-range" type="range" min="0.01" max="3.00" step="0.01" value="0.50">
<label class="solarsystem-label eclipse-control-label" for="penumbraScaleRange">
Halbschatten: <span id="penumbraScaleValue">1.00</span>
</label>
<input id="penumbraScaleRange" class="eclipse-control-range" type="range" min="0.01" max="5.00" step="0.01" value="1.00">
</div>
<div class="solarsystem-sidebar-block is-hidden" id="eclipseDataBlock">
<div class="solarsystem-label">Kontaktzeiten (<span id="eclipseTimeZoneLabel">UTC</span>)</div>
<table class="data-table" id="eclipseContactTable"></table>
</div>
<div class="solarsystem-sidebar-block">
<div class="solarsystem-label">Export</div>
<label class="solarsystem-label eclipse-control-label" for="exportFromTime">Von (UTC)</label>
<div class="solarsystem-input-row">
<input type="time" step="1" id="exportFromTime">
</div>
<label class="solarsystem-label eclipse-control-label" for="exportToTime">Bis (UTC)</label>
<div class="solarsystem-input-row">
<input type="time" step="1" id="exportToTime">
</div>
<button class="btn btn-secondary btn-full" id="btnExportFrames" disabled>Als Einzelbilder exportieren (24 fps)</button>
<p class="hint" id="exportStatus"></p>
</div>
</aside>
<div class="solarsystem-stage-wrap" id="eclipseStageWrap">
<div class="eclipse-view-toggle">
<button class="btn btn-sm btn-secondary is-active" id="btnSpaceView">Weltraum</button>
<button class="btn btn-sm btn-secondary" id="btnLocalView">Standort</button>
</div>
<canvas id="eclipseCanvas" class="eclipse-canvas"></canvas>
<canvas id="eclipseLocalCanvas" class="eclipse-canvas is-hidden"></canvas>
<div id="eclipseLocalHint" class="eclipse-local-hint"></div>
<div class="solarsystem-info-panel eclipse-info-panel" id="eclipseInfoPanel">
<button type="button" class="eclipse-info-toggle" id="eclipseInfoToggle" aria-expanded="true" aria-controls="eclipseInfoBody" title="Ein-/Ausklappen">
<span class="eclipse-info-toggle-icon">▾</span>
<span class="sr-only">Infokasten ein- oder ausklappen</span>
</button>
<div class="eclipse-info-body" id="eclipseInfoBody">
<div class="eclipse-info-empty">Wähle eine Finsternis aus der Liste</div>
<div class="eclipse-info-scale-note">Distanzen stark komprimiert, Größenverhältnisse für die Anschauung angepasst</div>
</div>
</div>
<div class="eclipse-stage-time eclipse-stage-time-solar is-hidden" id="eclipseStageTime">– – : – – UTC</div>
<div class="eclipse-timeline" id="eclipseTimeline">
<div class="eclipse-timeline-bar" id="eclipseTimelineBar">
<div class="eclipse-tl-cursor" id="eclipseTimelineCursor"></div>
</div>
<div class="eclipse-timeline-controls is-hidden" aria-hidden="true">
<button class="btn btn-sm btn-secondary" id="eclipseBtnRewindLegacy" title="Zum Anfang">⏮</button>
<button class="btn btn-sm btn-primary" id="eclipseBtnPlayLegacy" title="Play/Pause">▶ Play</button>
<button class="btn btn-sm btn-secondary" id="eclipseBtnEndLegacy" title="Zum Ende">⏭</button>
<span class="eclipse-time-display" id="eclipseTimeDisplayLegacy">– – : – – UTC</span>
<label class="eclipse-speed-label">
<span class="solarsystem-label">Geschw.</span>
<select id="eclipseSpeedSelLegacy" class="eclipse-speed-sel">
<option value="1">×1</option>
<option value="3">×3</option>
<option value="10" selected>×10</option>
<option value="30">×30</option>
<option value="60">×60</option>
</select>
</label>
</div>
</div>
</div>
</div>
</div>
<script type="module">
const USER_LAT = <?= $userLat !== null ? $userLat : 'null' ?>;
const USER_LON = <?= $userLon !== null ? $userLon : 'null' ?>;
const EXPORT_TOKEN = <?= json_encode($sonnenfinsternisExportToken, JSON_UNESCAPED_SLASHES) ?>;
const EXPORT_ENDPOINT = 'ajax/sonnenfinsternis_export.php';
const USER_ALT = <?= $userAlt !== null ? $userAlt : '0' ?>;
const AE = window.Astronomy;
const R_SUN_VIS = 2.7;
const R_MOON_VIS = 0.42;
const R_EARTH_VIS = 1.0;
const D_SM_VIS = 7.5;
const D_ME_VIS = 8.0;
const EARTH_TILT = 23.44 * Math.PI / 180;
const DEG2RAD = Math.PI / 180;
const RAD2DEG = 180 / Math.PI;
const EXPORT_WIDTH = 1280;
const EXPORT_HEIGHT = Math.round(EXPORT_WIDTH * 0.58);
const LOCAL_MATCH_HOURS = 18;
const D_EM_VIS = 3.1;
const SUN_RADIUS_KM = 695700;
const MOON_RADIUS_KM = 1737.4;
const EARTH_RADIUS_KM = 6378.1366;
const MOON_RADIUS_ER = MOON_RADIUS_KM / EARTH_RADIUS_KM;
let umbraProxyScale = 0.50;
let penumbraProxyScale = 1.00;
const MEAN_MOON_DIST_AU = 384400 / AE.KM_PER_AU;
const canvas3d = document.getElementById('eclipseCanvas');
const canvasLocal = document.getElementById('eclipseLocalCanvas');
const localHint = document.getElementById('eclipseLocalHint');
const btnSpaceView = document.getElementById('btnSpaceView');
const btnLocalView = document.getElementById('btnLocalView');
const btnExportFrames = document.getElementById('btnExportFrames');
const exportStatus = document.getElementById('exportStatus');
const exportFromTimeInput = document.getElementById('exportFromTime');
const exportToTimeInput = document.getElementById('exportToTime');
const stageWrap = document.getElementById('eclipseStageWrap');
const infoPanel = document.getElementById('eclipseInfoPanel');
const infoBody = document.getElementById('eclipseInfoBody');
const infoToggleBtn = document.getElementById('eclipseInfoToggle');
const timelineBar = document.getElementById('eclipseTimelineBar');
const tlCursor = document.getElementById('eclipseTimelineCursor');
const tlDisplay = document.getElementById('eclipseTimeDisplay');
const useLocalTimeEl = document.getElementById('eclipseUseLocalTime');
const showStageTimeEl = document.getElementById('eclipseShowStageTime');
const timeZoneLabelEl = document.getElementById('eclipseTimeZoneLabel');
const stageTimeEl = document.getElementById('eclipseStageTime');
const eclipseListEl = document.getElementById('eclipseList');
const dataBlock = document.getElementById('eclipseDataBlock');
const contactTable = document.getElementById('eclipseContactTable');
const btnPlay = document.getElementById('eclipseBtnPlay');
const speedSel = document.getElementById('eclipseSpeedSel');
const umbraScaleRange = document.getElementById('umbraScaleRange');
const penumbraScaleRange = document.getElementById('penumbraScaleRange');
const umbraScaleValue = document.getElementById('umbraScaleValue');
const penumbraScaleValue = document.getElementById('penumbraScaleValue');
let currentEclipse = null;
let eclipseListData = [];
let currentLocal = null;
let localObserver = null;
let geometryContacts = null;
let viewMode = 'space';
let exportInProgress = false;
let exportCancelRequested = false;
let exportFromAnchor = null;
let exportToAnchor = null;
let simTime = 0;
let playing = false;
let playSpeed = 10;
let lastRAF = 0;
let infoPanelCollapsed = false;
let useLocalTime = false;
let showStageTime = false;
let simPartialHalf = 95;
let simCentralHalf = 0;
let simTrackB = 0;
let simTrackSpeed = 0.02;
let moonToSunRadiusRatio = 1.0;
let moonRadiusWorld = R_MOON_VIS;
let earthShadowRadius = 0;
let earthPenumbraRadius = 0.68;
let THREE, renderer, scene, camera;
let sunMesh, moonMesh, moonShadowCaster, moonPenumbraCaster, earthMesh, earthTiltGroup;
let moonTrailLine;
let sunLight, umbraLight, penumbraLight, ambientLight, eclipseUmbraSpot, eclipsePenumbraSpot;
let camTheta = -0.14, camPhi = 1.08, camRadius = 18;
const camTarget = { x: 0.6, y: 0, z: 0 };
let isDragging = false, dragX = 0, dragY = 0, dragMode = 'rotate';
function eclipseTypeClass(kind) {
return {
partial: 'eclipse-type-par',
total: 'eclipse-type-tot',
annular: 'eclipse-type-ann'
}[kind] || '';
}
function eclipseTypeLabel(kind) {
return {
partial: 'Partielle Sonnenfinsternis',
total: 'Totale Sonnenfinsternis',
annular: 'Ringförmige Sonnenfinsternis'
}[kind] || kind;
}
async function init() {
if (USER_LAT !== null && USER_LON !== null) {
localObserver = new AE.Observer(USER_LAT, USER_LON, USER_ALT || 0);
}
THREE = await import('./js/three.module.js');
renderer = new THREE.WebGLRenderer({ canvas: canvas3d, antialias: true, alpha: false, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
scene = new THREE.Scene();
scene.background = new THREE.Color(0x010309);
camera = new THREE.PerspectiveCamera(48, 1, 0.05, 400);
updateCamera();
ambientLight = new THREE.AmbientLight(0x0a0d14, 0.03);
scene.add(ambientLight);
sunLight = new THREE.DirectionalLight(0xfff1c2, 0.28);
sunLight.position.set(-D_SM_VIS, 0, 0);
scene.add(sunLight);
penumbraLight = new THREE.DirectionalLight(0xfff5d8, 1.1);
scene.add(penumbraLight);
umbraLight = new THREE.DirectionalLight(0xfff8e8, 3.2);
scene.add(umbraLight);
buildStarfield();
buildBodies();
buildMoonTrail();
resizeAll();
window.addEventListener('resize', resizeAll);
setupInteraction();
buildYearSelectors();
requestAnimationFrame(renderLoop);
searchEclipses();
}
function buildStarfield() {
const pos = [];
for (let i = 0; i < 3200; i++) {
const t = Math.random() * Math.PI * 2;
const p = Math.acos(2 * Math.random() - 1);
const r = 150 + Math.random() * 110;
pos.push(r * Math.sin(p) * Math.cos(t), r * Math.sin(p) * Math.sin(t), r * Math.cos(p));
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
scene.add(new THREE.Points(
geo,
new THREE.PointsMaterial({ color: 0xffffff, size: 0.42, sizeAttenuation: true, transparent: true, opacity: 0.92 })
));
}
function buildBodies() {
const tl = new THREE.TextureLoader();
const sunTex = tl.load('images/planets/sun_1024.jpg');
sunMesh = new THREE.Mesh(
new THREE.SphereGeometry(R_SUN_VIS, 64, 64),
new THREE.MeshBasicMaterial({ map: sunTex, color: 0xfff2bd })
);
sunMesh.position.set(-D_SM_VIS, 0, 0);
scene.add(sunMesh);
const moonTex = tl.load('images/moons/moon_1024.jpg');
const moonBump = tl.load('images/moons/moon_displacement_16bit.jpg');
moonMesh = new THREE.Mesh(
new THREE.SphereGeometry(R_MOON_VIS, 64, 64),
new THREE.MeshStandardMaterial({
map: moonTex,
bumpMap: moonBump,
bumpScale: 0.04,
roughness: 0.96,
metalness: 0.0
})
);
moonMesh.position.set(0, 0, 0);
moonMesh.rotation.y = Math.PI;
moonMesh.castShadow = false;
moonMesh.receiveShadow = false;
scene.add(moonMesh);
// Physikalischer Proxy-Mond nur fuer den Kernschatten.
moonShadowCaster = new THREE.Mesh(
new THREE.SphereGeometry(1, 48, 48),
new THREE.MeshBasicMaterial({ color: 0x000000, colorWrite: false })
);
moonShadowCaster.castShadow = false;
moonShadowCaster.receiveShadow = false;
moonShadowCaster.visible = true;
scene.add(moonShadowCaster);
moonPenumbraCaster = new THREE.Mesh(
new THREE.SphereGeometry(1, 48, 48),
new THREE.MeshBasicMaterial({ color: 0x000000, colorWrite: false })
);
moonPenumbraCaster.castShadow = false;
moonPenumbraCaster.receiveShadow = false;
moonPenumbraCaster.visible = true;
scene.add(moonPenumbraCaster);
const earthTex = tl.load('images/planets/earth_day_1024.jpg');
earthMesh = new THREE.Mesh(
new THREE.SphereGeometry(R_EARTH_VIS, 64, 64),
new THREE.MeshStandardMaterial({ map: earthTex, roughness: 0.9, metalness: 0.0 })
);
earthMesh.receiveShadow = false;
earthMesh.castShadow = false;
earthTiltGroup = new THREE.Group();
earthTiltGroup.add(earthMesh);
earthTiltGroup.position.set(D_ME_VIS, 0, 0);
scene.add(earthTiltGroup);
if (USER_LAT !== null && USER_LON !== null) {
const dot = new THREE.Mesh(
new THREE.SphereGeometry(0.03, 12, 12),
new THREE.MeshBasicMaterial({ color: 0xff2222 })
);
const marker = latLonToEarthMesh(USER_LAT, USER_LON, R_EARTH_VIS + 0.012);
dot.position.set(marker.x, marker.y, marker.z);
earthMesh.add(dot);
}
eclipsePenumbraSpot = new THREE.Mesh(
new THREE.CircleGeometry(0.48, 64),
new THREE.MeshBasicMaterial({ color: 0x120f10, transparent: true, opacity: 0.22, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2 })
);
eclipsePenumbraSpot.renderOrder = 3;
eclipsePenumbraSpot.visible = false;
earthMesh.add(eclipsePenumbraSpot);
eclipseUmbraSpot = new THREE.Mesh(
new THREE.CircleGeometry(0.18, 48),
new THREE.MeshBasicMaterial({ color: 0x040404, transparent: true, opacity: 0.6, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -3, polygonOffsetUnits: -3 })
);
eclipseUmbraSpot.renderOrder = 4;
eclipseUmbraSpot.visible = false;
earthMesh.add(eclipseUmbraSpot);
}
function buildMoonTrail() {
if (moonTrailLine) {
scene.remove(moonTrailLine);
moonTrailLine.geometry.dispose();
}
const pts = [];
for (let t = -150; t <= 150; t += 4) {
const g = computeGeometryAtTime(simDateAt(t));
pts.push(new THREE.Vector3(g.moonPos.x, g.moonPos.y, g.moonPos.z));
}
const geo = new THREE.BufferGeometry().setFromPoints(pts);
const mat = new THREE.LineBasicMaterial({ color: 0x43506c, transparent: true, opacity: 0.5 });
moonTrailLine = new THREE.Line(geo, mat);
scene.add(moonTrailLine);
}
function updateCamera() {
const sp = Math.sin(camPhi), cp = Math.cos(camPhi);
const st = Math.sin(camTheta), ct = Math.cos(camTheta);
camera.position.set(
camTarget.x + camRadius * sp * st,
camTarget.y + camRadius * cp,
camTarget.z + camRadius * sp * ct
);
camera.lookAt(camTarget.x, camTarget.y, camTarget.z);
}
function panCamera(dx, dy) {
const forward = new THREE.Vector3(
camTarget.x - camera.position.x,
camTarget.y - camera.position.y,
camTarget.z - camera.position.z
).normalize();
const worldUp = new THREE.Vector3(0, 1, 0);
let right = new THREE.Vector3().crossVectors(forward, worldUp);
if (right.lengthSq() < 1e-8) right = new THREE.Vector3(1, 0, 0);
right.normalize();
const up = new THREE.Vector3().crossVectors(right, forward).normalize();
const scale = camRadius * 0.0022;
camTarget.x += (-dx * right.x + dy * up.x) * scale;
camTarget.y += (-dx * right.y + dy * up.y) * scale;
camTarget.z += (-dx * right.z + dy * up.z) * scale;
}
function resizeAll() {
const w = stageWrap.clientWidth;
const h = Math.max(400, Math.round(w * 0.58));
stageWrap.style.minHeight = h + 'px';
renderer.setSize(w, h);
camera.aspect = w / h;
camera.updateProjectionMatrix();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvasLocal.width = Math.round(w * dpr);
canvasLocal.height = Math.round(h * dpr);
if (viewMode === 'local') drawLocalView();
}
function buildYearSelectors() {
const yf = document.getElementById('eclipseYearFrom');
const yt = document.getElementById('eclipseYearTo');
const now = new Date().getFullYear();
const from = now;
const to = now + 10;
for (let y = 1950; y <= 2100; y++) {
yf.add(new Option(y, y, y === from, y === from));
yt.add(new Option(y, y, y === to, y === to));
}
}
function searchEclipses() {
const yf = parseInt(document.getElementById('eclipseYearFrom').value, 10);
const yt = parseInt(document.getElementById('eclipseYearTo').value, 10);
eclipseListData = [];
let date = new Date(Date.UTC(yf, 0, 1));
const limit = new Date(Date.UTC(yt + 1, 0, 1));
while (date < limit) {
let ecl;
try {
ecl = AE.SearchGlobalSolarEclipse(date);
} catch (err) {
break;
}
if (!ecl || ecl.peak.date >= limit) break;
eclipseListData.push(ecl);
date = new Date(ecl.peak.date.getTime() + 20 * 86400000);
}
renderEclipseList();
const now = new Date();
const idx = eclipseListData.findIndex(e => e.peak.date >= now);
selectEclipse(idx >= 0 ? idx : 0);
}
function renderEclipseList() {
if (!eclipseListData.length) {
eclipseListEl.innerHTML = '<p class="hint">Keine Finsternisse gefunden.</p>';
return;
}
eclipseListEl.innerHTML = eclipseListData.map((ecl, i) => {
const d = ecl.peak.date;
const ds = d.toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: 'numeric' });
const tc = eclipseTypeClass(ecl.kind);
const tl = { partial: 'Partial', total: 'Total', annular: 'Ring' }[ecl.kind] || ecl.kind;
return `<div class="eclipse-list-item" data-i="${i}">
<span class="eclipse-list-date">${ds}</span>
<span class="eclipse-list-type ${tc}">${tl}</span>
</div>`;
}).join('');
eclipseListEl.querySelectorAll('.eclipse-list-item').forEach(el =>
el.addEventListener('click', () => selectEclipse(parseInt(el.dataset.i, 10)))
);
}
function selectEclipse(idx) {
if (!eclipseListData[idx]) return;
currentEclipse = eclipseListData[idx];
currentLocal = findLocalCircumstances(currentEclipse);
updateLocalViewAvailability();
eclipseListEl.querySelectorAll('.eclipse-list-item').forEach((el, i) =>
el.classList.toggle('is-selected', i === idx)
);
const sel = eclipseListEl.querySelector('.is-selected');
if (sel) sel.scrollIntoView({ block: 'nearest' });
configureSimulation();
orientEarthAxis(currentEclipse.peak.date);
buildMoonTrail();
renderInfo();
renderContactTable();
renderTimeline();
updateCamera();
simTime = timeRange().min;
updateSim();
dataBlock.classList.remove('is-hidden');
playing = false;
btnPlay.textContent = '▶ Play';
lastRAF = performance.now();
btnExportFrames.disabled = false;
populateExportRange();
}
function findLocalCircumstances(globalEclipse) {
if (!localObserver) return null;
try {
const candidate = AE.SearchLocalSolarEclipse(new Date(globalEclipse.peak.date.getTime() - 86400000), localObserver);
if (!candidate || !candidate.peak || !candidate.peak.time) return null;
const diffHours = Math.abs(candidate.peak.time.date.getTime() - globalEclipse.peak.date.getTime()) / 3600000;
return diffHours <= LOCAL_MATCH_HOURS ? candidate : null;
} catch (err) {
console.warn('Lokale Finsternis nicht bestimmbar', err);
return null;
}
}
function updateLocalViewAvailability() {
if (!localObserver) {
btnLocalView.disabled = true;
btnLocalView.title = 'Kein Standort hinterlegt (siehe Einstellungen)';
} else if (!currentLocal) {
btnLocalView.disabled = true;
btnLocalView.title = 'Von diesem Standort aus nicht sichtbar';
} else {
btnLocalView.disabled = false;
btnLocalView.title = '';
}
if (btnLocalView.disabled && viewMode === 'local') setView('space');
}
// Topozentrische Sonne/Mond-Geometrie am Favoritenstandort, Zenit oben (parallaktischer
// Winkel angewendet), siehe Plan "Standort-Ansicht" fuer Herleitung der Formeln.
function computeLocalSkyGeometry(date) {
if (!localObserver) return null;
const astroTime = AE.MakeTime(date);
const sunEq = AE.Equator('Sun', astroTime, localObserver, true, true);
const moonEq = AE.Equator('Moon', astroTime, localObserver, true, true);
const sunDistKm = sunEq.dist * AE.KM_PER_AU;
const moonDistKm = moonEq.dist * AE.KM_PER_AU;
const sunAngR = Math.asin(Math.min(1, SUN_RADIUS_KM / sunDistKm));
const moonAngR = Math.asin(Math.min(1, MOON_RADIUS_KM / moonDistKm));
const sunRaR = sunEq.ra * 15 * DEG2RAD;
const sunDecR = sunEq.dec * DEG2RAD;
const moonRaR = moonEq.ra * 15 * DEG2RAD;
const moonDecR = moonEq.dec * DEG2RAD;
const dRa = moonRaR - sunRaR;
const cosSep = Math.sin(sunDecR) * Math.sin(moonDecR) +
Math.cos(sunDecR) * Math.cos(moonDecR) * Math.cos(dRa);
const sep = Math.acos(Math.max(-1, Math.min(1, cosSep)));
const pa = Math.atan2(
Math.sin(dRa),
Math.cos(sunDecR) * Math.tan(moonDecR) - Math.sin(sunDecR) * Math.cos(dRa)
);
const gastHours = AE.SiderealTime(astroTime);
const lstHours = ((gastHours + localObserver.longitude / 15) % 24 + 24) % 24;
const hourAngleR = (lstHours - sunEq.ra) * 15 * DEG2RAD;
const latR = localObserver.latitude * DEG2RAD;
const parallacticAngle = Math.atan2(
Math.sin(hourAngleR),
Math.tan(latR) * Math.cos(sunDecR) - Math.sin(sunDecR) * Math.cos(hourAngleR)
);
const paZenith = pa - parallacticAngle;
const dx = sep * Math.sin(paZenith);
const dy = -sep * Math.cos(paZenith);
const sunHor = AE.Horizon(astroTime, localObserver, sunEq.ra, sunEq.dec, 'normal');
return {
visible: sunHor.altitude > -sunAngR * RAD2DEG,
sunAngR,
moonAngR,
sep,
dx,
dy,
sunAltitudeDeg: sunHor.altitude
};
}
function drawLocalView() {
const ctx = canvasLocal.getContext('2d');
const W = canvasLocal.width;
const H = canvasLocal.height;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#010309';
ctx.fillRect(0, 0, W, H);
if (!localObserver) {
localHint.textContent = 'Kein Standort hinterlegt.';
localHint.classList.add('is-visible');
return;
}
if (!currentEclipse) {
localHint.textContent = 'Wähle zunächst eine Finsternis aus der Liste.';
localHint.classList.add('is-visible');
return;
}
const geo = computeLocalSkyGeometry(simDateAt());
if (!geo || !geo.visible) {
localHint.textContent = geo
? `Sonne unter dem Horizont am Favoritenstandort (Höhe ${geo.sunAltitudeDeg.toFixed(1)}°).`
: 'Standortgeometrie nicht bestimmbar.';
localHint.classList.add('is-visible');
return;
}
localHint.classList.remove('is-visible');
const cx = W / 2, cy = H / 2;
const R = Math.min(W, H) * 0.32;
const sc = R / geo.sunAngR;
const sunR = R;
const moonR = geo.moonAngR * sc;
// Sky-view perspective is mirrored horizontally relative to the
// east-positive topocentric offset used in the geometry calculation.
const mx = cx - geo.dx * sc;
const my = cy + geo.dy * sc;
const glow = ctx.createRadialGradient(cx, cy, sunR * 0.9, cx, cy, sunR * 2.2);
glow.addColorStop(0, 'rgba(255,241,180,0.35)');
glow.addColorStop(1, 'rgba(255,241,180,0)');
ctx.fillStyle = glow;
ctx.fillRect(0, 0, W, H);
const sunGrad = ctx.createRadialGradient(cx - sunR * 0.2, cy - sunR * 0.2, sunR * 0.05, cx, cy, sunR);
sunGrad.addColorStop(0, '#fff8dd');
sunGrad.addColorStop(0.75, '#ffe9a8');
sunGrad.addColorStop(1, '#f2b64c');
ctx.fillStyle = sunGrad;
ctx.beginPath();
ctx.arc(cx, cy, sunR, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#03040a';
ctx.beginPath();
ctx.arc(mx, my, moonR, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(10,8,4,0.35)';
ctx.lineWidth = Math.max(1, sunR * 0.01);
ctx.beginPath();
ctx.arc(mx, my, moonR, 0, Math.PI * 2);
ctx.stroke();
// Dezenter Korona-Schimmer bei Totalitaet/Ringphase
if (geo.sep < Math.max(geo.sunAngR, geo.moonAngR) * 0.15) {
const corona = ctx.createRadialGradient(cx, cy, sunR * 0.98, cx, cy, sunR * 1.5);
corona.addColorStop(0, 'rgba(255,255,240,0.55)');
corona.addColorStop(1, 'rgba(255,255,240,0)');
ctx.fillStyle = corona;
ctx.beginPath();
ctx.arc(cx, cy, sunR * 1.5, 0, Math.PI * 2);
ctx.fill();
}
}
function setView(mode) {
viewMode = mode;
btnSpaceView.classList.toggle('is-active', mode === 'space');
btnLocalView.classList.toggle('is-active', mode === 'local');
canvas3d.classList.toggle('is-hidden', mode !== 'space');
canvasLocal.classList.toggle('is-hidden', mode !== 'local');
localHint.classList.remove('is-visible');
if (mode === 'local') drawLocalView();
}
function fmtTimeInput(date) {
return date.toISOString().slice(11, 19);
}
// Belegt die Von/Bis-Felder mit dem vollen Simulationszeitraum der aktuell
// gewaehlten Finsternis vor. Die Ankerdaten merken sich den UTC-Kalendertag
// jedes Feldes, damit beim Auslesen (parseExportRange) aus der reinen Uhrzeit
// wieder ein eindeutiges Datum rekonstruiert werden kann.
function populateExportRange() {
const { min, max } = timeRange();
exportFromAnchor = simDateAt(min);
exportToAnchor = simDateAt(max);
exportFromTimeInput.value = fmtTimeInput(exportFromAnchor);
exportToTimeInput.value = fmtTimeInput(exportToAnchor);
}
function combineAnchorAndTime(anchorDate, timeStr) {
const [h, m, s] = timeStr.split(':').map(Number);
return new Date(Date.UTC(
anchorDate.getUTCFullYear(), anchorDate.getUTCMonth(), anchorDate.getUTCDate(),
h || 0, m || 0, s || 0
));
}
// Liest die vom Nutzer eingestellten Von/Bis-Zeiten, verankert sie am
// jeweiligen Kalendertag der Vorbelegung und begrenzt sie auf den technisch
// gueltigen Simulationszeitraum der Finsternis.
function parseExportRange() {
const { min: fullMin, max: fullMax } = timeRange();
if (!exportFromAnchor || !exportToAnchor || !exportFromTimeInput.value || !exportToTimeInput.value) {
return { min: fullMin, max: fullMax };
}
const fromDate = combineAnchorAndTime(exportFromAnchor, exportFromTimeInput.value);
const toDate = combineAnchorAndTime(exportToAnchor, exportToTimeInput.value);
const peakMs = eventPeakDate().getTime();
const min = Math.max(fullMin, Math.min(fullMax, (fromDate.getTime() - peakMs) / 60000));
const max = Math.max(fullMin, Math.min(fullMax, (toDate.getTime() - peakMs) / 60000));
if (max <= min) {
throw new Error('Die "Bis"-Zeit muss nach der "Von"-Zeit liegen.');
}
return { min, max };
}
// ─── Einzelbild-Export (24 fps) ─────────────────────────────────────────────
// Jedes Frame wird einzeln gerendert und sofort an einen PHP-Endpunkt
// hochgeladen, der es in einem Temp-Verzeichnis ablegt - der Browser haelt nie
// mehr als ein Bild gleichzeitig im Speicher. Am Ende baut der Server daraus
// die ZIP-Datei und liefert sie direkt aus (siehe public/ajax/sonnenfinsternis_export.php).
// Bei vielen tausend sequentiellen Requests (ein Upload pro Frame) reicht ein
// einzelner kurzer Netzwerk-/Server-Haenger (z.B. der PHP-Entwicklungsserver
// unter Dauerlast), um fetch() mit "Failed to fetch" abbrechen zu lassen. Echte
// Server-Antworten (Auth-/Validierungsfehler) werden dagegen sofort durchgereicht,
// da ein erneuter Versuch dort nichts aendern wuerde.
async function exportApiCall(action, params, body, maxAttempts = 4) {
const qs = new URLSearchParams({ action, token: EXPORT_TOKEN, ...params });
let lastErr;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
let res;
try {
res = await fetch(`${EXPORT_ENDPOINT}?${qs}`, {
method: 'POST',
body: body || null
});
} catch (err) {
lastErr = err;
if (attempt < maxAttempts) await delay(300 * attempt);
continue;
}
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.message || `Server-Fehler (${res.status})`);
}
return data;
}
throw lastErr;
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function dataUrlToBytes(dataUrl) {
const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
// Rendert Frame `i` und liest ihn als PNG-Bytes aus. canvas.toDataURL() liefert die
// Bilddaten synchron als Base64-String zurueck (statt ueber canvas.toBlob(), das
// Chromium bei groesseren Bildern intern ueber eine Temp-Datei abwickelt - greift in
// dem Moment z.B. ein Virenscanner oder OneDrive darauf zu, schlaegt das Auslesen
// mit einem NotReadableError fehl). So wird dieser Umweg komplett vermieden.
async function captureFrame(canvas, isSpaceView) {
const maxAttempts = 3;
let lastErr;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
if (isSpaceView) {
if (renderer.getContext().isContextLost()) {
throw new Error('WebGL-Kontext verloren (vermutlich Grafikspeicher-Engpass).');
}
renderer.render(scene, camera);
}
return dataUrlToBytes(canvas.toDataURL('image/png'));
} catch (err) {
lastErr = err;
if (attempt < maxAttempts) await delay(150 * attempt);
}
}
throw lastErr;
}
async function exportFrames() {
if (!currentEclipse || exportInProgress) return;
let min, max;
try {
({ min, max } = parseExportRange());
} catch (err) {
exportStatus.textContent = err.message;
return;
}
const realSeconds = (max - min) * 60 / playSpeed;
const frameCount = Math.max(1, Math.round(realSeconds * 24));
const totalFrames = frameCount + 1;
const ok = window.confirm(
`Export mit ${totalFrames} Einzelbildern bei aktueller Geschwindigkeit (×${playSpeed}) starten?\n` +
`Größere Geschwindigkeiten erzeugen weniger Bilder. Fortfahren?`
);
if (!ok) return;
const savedSimTime = simTime;
const savedPlaying = playing;
playing = false;
exportInProgress = true;
exportCancelRequested = false;
btnExportFrames.textContent = 'Abbrechen';
btnSpaceView.disabled = true;
btnLocalView.disabled = true;
const activeCanvas = viewMode === 'local' ? canvasLocal : canvas3d;
const dtMin = (max - min) / frameCount;
// Feste, moderate Export-Aufloesung statt der Live-Anzeigegroesse (die je nach
// Bildschirm/DPI ein Vielfaches groesser sein kann) - haelt den Speicherbedarf
// pro Frame vorhersagbar und begrenzt.
renderer.setSize(EXPORT_WIDTH, EXPORT_HEIGHT, false);
camera.aspect = EXPORT_WIDTH / EXPORT_HEIGHT;
camera.updateProjectionMatrix();
canvasLocal.width = EXPORT_WIDTH;
canvasLocal.height = EXPORT_HEIGHT;
let exportId = null;
try {
exportStatus.textContent = 'Starte Export…';
({ exportId } = await exportApiCall('start', {}));
for (let i = 0; i <= frameCount; i++) {
if (exportCancelRequested) {
await exportApiCall('cancel', { exportId });
exportStatus.textContent = 'Export abgebrochen.';
return;
}
simTime = min + i * dtMin;
updateSim();
let buf;
try {
buf = await captureFrame(activeCanvas, viewMode === 'space');
} catch (err) {
throw new Error(`Frame ${i + 1}/${totalFrames}: ${err.message || err}`);
}
await exportApiCall('frame', { exportId, index: i }, buf);
exportStatus.textContent = `Frame ${i + 1}/${totalFrames} hochgeladen…`;
}
exportStatus.textContent = 'Erzeuge ZIP-Datei…';
const qs = new URLSearchParams({ action: 'finish', token: EXPORT_TOKEN, exportId });
window.location.href = `${EXPORT_ENDPOINT}?${qs}`;
exportStatus.textContent = `Export abgeschlossen (${totalFrames} Bilder).`;
} catch (err) {
console.error('Einzelbild-Export fehlgeschlagen', err);
exportStatus.textContent = `Export fehlgeschlagen: ${err.message || err}`;
if (exportId) {
try { await exportApiCall('cancel', { exportId }); } catch (_) { /* bestmoeglich aufraeumen */ }
}
} finally {
exportInProgress = false;
exportCancelRequested = false;
btnExportFrames.textContent = 'Als Einzelbilder exportieren (24 fps)';
btnSpaceView.disabled = false;
updateLocalViewAvailability();
simTime = savedSimTime;
playing = savedPlaying;
resizeAll();
updateSim();
if (viewMode === 'space') renderer.render(scene, camera);
lastRAF = performance.now();
}
}
function geometryEventKind() {
return currentEclipse?.kind || null;
}
function configureSimulation() {
const kind = geometryEventKind();
const obsc = currentEclipse?.obscuration ?? 0.88;
simPartialHalf = 84 + obsc * 40 + (kind === 'partial' ? 22 : 0);
if (kind === 'total') {
simCentralHalf = 1.8 + obsc * 2.7;
} else if (kind === 'annular') {
simCentralHalf = 2.6 + obsc * 4.2;
} else {
simCentralHalf = 0;
}
const ratioBase = kind === 'total'
? 1.01 + obsc * 0.04
: kind === 'annular'
? 0.86 + obsc * 0.10
: 0.92 + obsc * 0.12;
moonToSunRadiusRatio = Math.max(0.86, Math.min(1.08, ratioBase));
const sunR = 1.0;
const moonR = moonToSunRadiusRatio;
if (simCentralHalf > 0 && simPartialHalf > simCentralHalf + 0.5) {
const v2 = ((sunR + moonR) ** 2 - (Math.abs(sunR - moonR)) ** 2) /
((simPartialHalf ** 2) - (simCentralHalf ** 2));
simTrackSpeed = Math.sqrt(Math.max(v2, 0.0008));
simTrackB = Math.sqrt(Math.max(0, (sunR + moonR) ** 2 - v2 * simPartialHalf ** 2));
} else {
simTrackB = Math.max(0.12, (1 - obsc) * 1.55);
simTrackSpeed = Math.sqrt(Math.max(0.0008, ((sunR + moonR) ** 2 - simTrackB ** 2) / (simPartialHalf ** 2)));
}
moonRadiusWorld = R_MOON_VIS * moonToSunRadiusRatio;
if (moonMesh) moonMesh.scale.setScalar(moonToSunRadiusRatio);
earthPenumbraRadius = 0.55 + obsc * 0.48;
earthShadowRadius = kind === 'partial'
? 0
: kind === 'total'
? 0.18 + obsc * 0.22
: 0.08 + obsc * 0.11;
geometryContacts = computeGeometryContacts(Math.max(180, simPartialHalf + 80), 2);
if (geometryContacts.c1 !== null && geometryContacts.c4 !== null) {
simPartialHalf = Math.max(Math.abs(geometryContacts.c1), Math.abs(geometryContacts.c4));
}
if (geometryContacts.c2 !== null && geometryContacts.c3 !== null) {
simCentralHalf = Math.max(Math.abs(geometryContacts.c2), Math.abs(geometryContacts.c3));
} else {
simCentralHalf = 0;
}
}
function solarLongitude(date) {
const J2000 = 2451545.0;
const jd = date.getTime() / 86400000 + 2440587.5;
const n = jd - J2000;
const L = (280.46 + 0.9856474 * n) % 360;
const g = ((357.528 + 0.9856003 * n) % 360) * Math.PI / 180;
return ((L + 1.915 * Math.sin(g) + 0.020 * Math.sin(2 * g)) % 360 + 360) % 360;
}
function orientEarthAxis(date) {
if (!earthTiltGroup) return;
const fixed = earthMeshBasisAtDate(date);
const mx = new THREE.Vector3(fixed.xAxis.x, fixed.xAxis.y, fixed.xAxis.z);
const my = new THREE.Vector3(fixed.yAxis.x, fixed.yAxis.y, fixed.yAxis.z);
const mz = new THREE.Vector3(fixed.zAxis.x, fixed.zAxis.y, fixed.zAxis.z);
const mat = new THREE.Matrix4().makeBasis(mx, my, mz);
earthTiltGroup.quaternion.setFromRotationMatrix(mat);
earthMesh.rotation.y = 0;
}
function timeRange() {
return { min: -simPartialHalf - 10, max: simPartialHalf + 10 };
}
function moonTrackPos(t) {
return { x: 0, y: simTrackB, z: -simTrackSpeed * t };
}
function vDot(a, b) {
return a.x * b.x + a.y * b.y + a.z * b.z;
}
function vLen(v) {
return Math.hypot(v.x, v.y, v.z);
}
function vScale(v, s) {
return { x: v.x * s, y: v.y * s, z: v.z * s };
}
function vAdd(a, b) {
return { x: a.x + b.x, y: a.y + b.y, z: a.z + b.z };
}
function vSub(a, b) {
return { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z };
}
function vNorm(v) {
const len = vLen(v) || 1;
return { x: v.x / len, y: v.y / len, z: v.z / len };
}
function vCross(a, b) {
return {
x: a.y * b.z - a.z * b.y,
y: a.z * b.x - a.x * b.z,
z: a.x * b.y - a.y * b.x
};
}
function latLonToEarthMesh(latDeg, lonDeg, radius = R_EARTH_VIS + 0.012) {
const phi = (90 - latDeg) * Math.PI / 180;
const theta = Math.PI + lonDeg * Math.PI / 180;
return {
x: -radius * Math.sin(phi) * Math.cos(theta),
y: radius * Math.cos(phi),
z: radius * Math.sin(phi) * Math.sin(theta)
};
}
function simDateAt(offsetMin = simTime) {
return new Date(eventPeakDate().getTime() + offsetMin * 60000);
}
function circleOverlapArea(r1, r2, d) {
if (d >= r1 + r2) return 0;
if (d <= Math.abs(r1 - r2)) return Math.PI * Math.min(r1, r2) * Math.min(r1, r2);
const a1 = Math.acos((d * d + r1 * r1 - r2 * r2) / (2 * d * r1));
const a2 = Math.acos((d * d + r2 * r2 - r1 * r1) / (2 * d * r2));
const part1 = r1 * r1 * a1;
const part2 = r2 * r2 * a2;
const part3 = 0.5 * Math.sqrt(
Math.max(0, (-d + r1 + r2) * (d + r1 - r2) * (d - r1 + r2) * (d + r1 + r2))
);
return part1 + part2 - part3;
}
function sceneBasisAtDate(date) {
const astroTime = AE.MakeTime(date);
const rot = AE.Rotation_EQJ_EQD(astroTime);
const sunEqd = AE.RotateVector(rot, AE.GeoVector('Sun', astroTime, true));
const moonEqd = AE.RotateVector(rot, AE.GeoVector('Moon', astroTime, true));
const earthAxisEqd = AE.RotateVector(rot, AE.RotationAxis('Earth', astroTime).north);
const xAxis = vNorm({ x: -sunEqd.x, y: -sunEqd.y, z: -sunEqd.z });
let north = vNorm({ x: earthAxisEqd.x, y: earthAxisEqd.y, z: earthAxisEqd.z });
let yAxis = vSub(north, vScale(xAxis, vDot(north, xAxis)));
if (vLen(yAxis) < 1e-6) {
north = { x: 0, y: 1, z: 0 };
yAxis = vSub(north, vScale(xAxis, vDot(north, xAxis)));
}
yAxis = vNorm(yAxis);
const zAxis = vNorm(vCross(xAxis, yAxis));
return { astroTime, sunEqd, moonEqd, earthAxisEqd, xAxis, yAxis, zAxis };
}
function earthMeshBasisAtDate(date) {
const basis = sceneBasisAtDate(date);
const astroTime = AE.MakeTime(date);
const greenwichEqd = AE.ObserverVector(astroTime, new AE.Observer(0, 0, 0), true);
const northEqd = AE.ObserverVector(astroTime, new AE.Observer(90, 0, 0), true);
const westEqd = AE.ObserverVector(astroTime, new AE.Observer(0, -90, 0), true);
const xAxis = vNorm(toSceneCoords(greenwichEqd, basis));
let yAxis = toSceneCoords(northEqd, basis);
yAxis = vNorm(vSub(yAxis, vScale(xAxis, vDot(yAxis, xAxis))));
let zAxis = vNorm(vCross(xAxis, yAxis));
const westAxis = vNorm(toSceneCoords(westEqd, basis));
if (vDot(zAxis, westAxis) < 0) {
zAxis = vScale(zAxis, -1);
}
return { xAxis, yAxis, zAxis };
}
function toSceneCoords(vec, basis) {
return {
x: vDot(vec, basis.xAxis),
y: vDot(vec, basis.yAxis),
z: vDot(vec, basis.zAxis)
};
}
function fromSceneCoords(vec, basis) {
return {
x: basis.xAxis.x * vec.x + basis.yAxis.x * vec.y + basis.zAxis.x * vec.z,
y: basis.xAxis.y * vec.x + basis.yAxis.y * vec.y + basis.zAxis.y * vec.z,
z: basis.xAxis.z * vec.x + basis.yAxis.z * vec.y + basis.zAxis.z * vec.z
};
}
function intersectLineSphere(origin, dir, center, radius) {
const oc = vSub(origin, center);
const b = 2 * vDot(oc, dir);
const c = vDot(oc, oc) - radius * radius;
const disc = b * b - 4 * c;
if (disc < 0) return null;
const s1 = (-b - Math.sqrt(disc)) / 2;
const s2 = (-b + Math.sqrt(disc)) / 2;
const s = s1 >= 0 ? s1 : s2 >= 0 ? s2 : null;
return s === null ? null : vAdd(origin, vScale(dir, s));
}
function closestPointOnRay(origin, dir, point = { x: 0, y: 0, z: 0 }) {
const t = Math.max(0, vDot(vSub(point, origin), dir));
return {
t,
point: vAdd(origin, vScale(dir, t))
};
}
function computeGeometryAtTime(date) {
const basis = sceneBasisAtDate(date);
const earthPos = { x: D_ME_VIS, y: 0, z: 0 };
const sunPos = { x: -D_SM_VIS, y: 0, z: 0 };
const moonSceneEq = toSceneCoords(basis.moonEqd, basis);
const moonActualOffset = vScale(moonSceneEq, AE.KM_PER_AU / EARTH_RADIUS_KM);
const moonOffset = vScale(moonSceneEq, D_EM_VIS / MEAN_MOON_DIST_AU);
const moonPos = vAdd(earthPos, moonOffset);
const moonShadowPos = vAdd(earthPos, moonActualOffset);
const moonActual = moonActualOffset;
const sunActual = vScale(toSceneCoords(basis.sunEqd, basis), AE.KM_PER_AU / EARTH_RADIUS_KM);
const sunDirScene = vNorm(toSceneCoords(basis.sunEqd, basis));
const shadowDir = vNorm(vSub(moonActual, sunActual));
const shadowPoint = intersectLineSphere(moonActual, shadowDir, { x: 0, y: 0, z: 0 }, R_EARTH_VIS);
const shadowClosest = closestPointOnRay(moonActual, shadowDir);
const shadowAxisDistance = vLen(shadowClosest.point);
const sunDistKm = vLen(basis.sunEqd) * AE.KM_PER_AU;
const moonDistKm = vLen(basis.moonEqd) * AE.KM_PER_AU;
const sunMoonDistKm = vLen(vSub(basis.moonEqd, basis.sunEqd)) * AE.KM_PER_AU;
const sunAng = Math.asin(Math.min(1, SUN_RADIUS_KM / sunDistKm));
const moonAng = Math.asin(Math.min(1, MOON_RADIUS_KM / moonDistKm));
const moonToSun = Math.max(0.82, Math.min(1.12, moonAng / sunAng));
const earthFromMoon = vScale(basis.moonEqd, -1);
const shadowDirEq = vNorm(vSub(basis.moonEqd, basis.sunEqd));
const axisDistanceKm = Math.max(0, vDot(earthFromMoon, shadowDirEq) * AE.KM_PER_AU);
const umbraRadiusKm = MOON_RADIUS_KM - ((SUN_RADIUS_KM - MOON_RADIUS_KM) * axisDistanceKm / Math.max(1, sunMoonDistKm));
const penumbraRadiusKm = MOON_RADIUS_KM + ((SUN_RADIUS_KM + MOON_RADIUS_KM) * axisDistanceKm / Math.max(1, sunMoonDistKm));
const centralShadowRadius = Math.abs(umbraRadiusKm) / EARTH_RADIUS_KM;
const penumbraRadius = penumbraRadiusKm / EARTH_RADIUS_KM;
const moonDir = vNorm(moonSceneEq);
const skyScale = Math.max(1e-6, -moonDir.x);
const skyOffset = {
y: moonDir.y / skyScale,
z: moonDir.z / skyScale
};
const shadowPointSc = shadowPoint ? {
x: shadowPoint.x,
y: shadowPoint.y,
z: shadowPoint.z
} : null;
const shadowClosestSc = {
x: shadowClosest.point.x,
y: shadowClosest.point.y,
z: shadowClosest.point.z
};
const shadowSurface = shadowPoint || shadowClosest.point;
const shadowEqd = fromSceneCoords(shadowSurface, basis);
const shadowObserver = AE.VectorObserver({
x: shadowEqd.x * EARTH_RADIUS_KM / AE.KM_PER_AU,
y: shadowEqd.y * EARTH_RADIUS_KM / AE.KM_PER_AU,
z: shadowEqd.z * EARTH_RADIUS_KM / AE.KM_PER_AU,
t: basis.astroTime
}, true);
return {
earthPos,
sunPos,
moonPos,
moonShadowPos,
sunDirScene,
shadowPoint,
shadowPointSc,
shadowClosest,
shadowClosestSc,
shadowAxisDistance,
shadowObserver,
moonToSun,
skyOffset,
penumbraRadius,
centralShadowRadius,
annular: umbraRadiusKm < 0
};
}
function refineTransition(t1, t2, predicate, iterations = 18) {
let a = t1;
let b = t2;
let va = predicate(a);
let vb = predicate(b);
if (va === vb) return (a + b) * 0.5;
for (let i = 0; i < iterations; i++) {
const m = (a + b) * 0.5;
const vm = predicate(m);
if (vm === va) {
a = m;
va = vm;
} else {
b = m;
vb = vm;
}
}
return (a + b) * 0.5;
}
function computeGeometryContacts(windowMin = 240, stepMin = 2) {
const partialPred = t => eclipseCoverageAtTime(t).maxInside > 1e-4;
const centralPred = t => eclipseCoverageAtTime(t).centralVisible;
let c1 = null, c4 = null, c2 = null, c3 = null;
let prevT = -windowMin;
let prevP = partialPred(prevT);
let prevC = centralPred(prevT);
for (let t = prevT + stepMin; t <= windowMin; t += stepMin) {
const curP = partialPred(t);
const curC = centralPred(t);
if (c1 === null && !prevP && curP) c1 = refineTransition(t - stepMin, t, partialPred);
if (c4 === null && prevP && !curP) c4 = refineTransition(t - stepMin, t, partialPred);
if (c2 === null && !prevC && curC) c2 = refineTransition(t - stepMin, t, centralPred);
if (c3 === null && prevC && !curC) c3 = refineTransition(t - stepMin, t, centralPred);
prevP = curP;
prevC = curC;
}
return { c1, c2, max: 0, c3, c4 };
}
function eclipseCoverageAtTime(t) {
const geom = computeGeometryAtTime(simDateAt(t));
const sunR = 1;
const moonR = geom.moonToSun;
const d = Math.hypot(geom.skyOffset.y, geom.skyOffset.z);
const overlapArea = circleOverlapArea(sunR, moonR, d);
const obscuration = Math.max(0, Math.min(1, overlapArea / (Math.PI * sunR * sunR)));
const central = d <= Math.abs(moonR - sunR);
const kind = geometryEventKind();
return {
geom,
distance: d,
maxInside: obscuration,
central,
ringVisible: kind === 'annular' && central,
totalVisible: kind === 'total' && central && moonR >= 1,
centralVisible: (kind === 'total' || kind === 'annular') && central
};
}
function phaseName(t) {
if (!currentEclipse) return null;
const cov = eclipseCoverageAtTime(t);
if (cov.totalVisible) return 'TOTALITÄT';
if (cov.ringVisible) return 'RINGPHASE';
if (cov.maxInside > 0.0005) return 'PARTIELLE PHASE';
return t < 0 ? 'VOR DER FINSTERNIS' : 'NACH DER FINSTERNIS';
}
function updateSim() {
if (!currentEclipse) return;
orientEarthAxis(simDateAt());
const cov = eclipseCoverageAtTime(simTime);
const geom = cov.geom;
if (moonMesh) {
moonMesh.position.set(geom.moonPos.x, geom.moonPos.y, geom.moonPos.z);
moonMesh.scale.setScalar(geom.moonToSun);
moonMesh.rotation.y = Math.PI;
}
if (moonShadowCaster) {
moonShadowCaster.position.set(geom.moonShadowPos.x, geom.moonShadowPos.y, geom.moonShadowPos.z);
const casterRadius = Math.max(0.0005, (geom.centralShadowRadius || 0) * umbraProxyScale);
moonShadowCaster.scale.setScalar(casterRadius);
}
if (moonPenumbraCaster) {
moonPenumbraCaster.position.set(geom.moonShadowPos.x, geom.moonShadowPos.y, geom.moonShadowPos.z);
const casterRadius = Math.max(0.0005, (geom.penumbraRadius || 0) * penumbraProxyScale);
moonPenumbraCaster.scale.setScalar(casterRadius);
}
if (sunLight) {
sunLight.position.set(
D_ME_VIS + geom.sunDirScene.x * 120,
geom.sunDirScene.y * 120,
geom.sunDirScene.z * 120
);
}
if (penumbraLight) {
penumbraLight.position.set(
D_ME_VIS + geom.sunDirScene.x * 120,
geom.sunDirScene.y * 120,
geom.sunDirScene.z * 120
);
}
if (umbraLight) {
umbraLight.position.set(
D_ME_VIS + geom.sunDirScene.x * 120,
geom.sunDirScene.y * 120,
geom.sunDirScene.z * 120
);
}
if (eclipsePenumbraSpot || eclipseUmbraSpot) {
const shadowDist = Number.isFinite(geom.shadowAxisDistance) ? geom.shadowAxisDistance : Infinity;
const penRadius = Math.max(0, geom.penumbraRadius || earthPenumbraRadius);
const coreRadius = Math.max(0, geom.centralShadowRadius || earthShadowRadius);
let normal, surfacePoint;
if (geom.shadowObserver && Number.isFinite(geom.shadowObserver.latitude) && Number.isFinite(geom.shadowObserver.longitude)) {
surfacePoint = latLonToEarthMesh(geom.shadowObserver.latitude, geom.shadowObserver.longitude, R_EARTH_VIS + 0.014);
normal = vNorm(surfacePoint);
} else {
normal = { x: -1, y: 0, z: 0 };
surfacePoint = vScale(normal, R_EARTH_VIS + 0.014);
}
const invQ = earthTiltGroup.quaternion.clone().invert();
const sunLoc = new THREE.Vector3(-D_SM_VIS - D_ME_VIS, 0, 0).applyQuaternion(invQ);
const lightDir = vNorm(vSub({ x: sunLoc.x, y: sunLoc.y, z: sunLoc.z }, surfacePoint));
const incidence = Math.max(0.08, Math.abs(vDot(normal, lightDir)));
const limbStretch = Math.min(3.6, 1 / incidence);
const tangentDirRaw = vSub(lightDir, vScale(normal, vDot(lightDir, normal)));
const tangentDir = vLen(tangentDirRaw) > 1e-6 ? vNorm(tangentDirRaw) : { x: 0, y: 1, z: 0 };
const binormal = vNorm(vCross(normal, tangentDir));
const tangentQuat = new THREE.Quaternion().setFromRotationMatrix(
new THREE.Matrix4().makeBasis(
new THREE.Vector3(tangentDir.x, tangentDir.y, tangentDir.z),
new THREE.Vector3(binormal.x, binormal.y, binormal.z),
new THREE.Vector3(normal.x, normal.y, normal.z)
)
);
const penTouch = penRadius > 0.0001 && shadowDist <= (1 + penRadius);
const centralKind = geometryEventKind() === 'total' || geometryEventKind() === 'annular';
const coreTouch = centralKind && penTouch;
const penFade = penTouch ? Math.max(0.18, Math.min(1, ((1 + penRadius) - shadowDist) / Math.max(0.08, penRadius))) : 0;
const coreFade = coreTouch ? Math.max(0.7, penFade) : 0;
const penScale = Math.max(0.08, penRadius * Math.max(0.12, penumbraProxyScale));
const umbraBase = coreRadius > 0.0001 ? coreRadius : penRadius * 0.16;
const umbraScale = Math.max(0.035, Math.max(umbraBase, penScale * 0.14) * Math.max(0.12, umbraProxyScale));
if (eclipsePenumbraSpot) {
eclipsePenumbraSpot.position.set(
surfacePoint.x + normal.x * 0.003,
surfacePoint.y + normal.y * 0.003,
surfacePoint.z + normal.z * 0.003
);
eclipsePenumbraSpot.quaternion.copy(tangentQuat);
eclipsePenumbraSpot.scale.set(penScale * limbStretch, penScale, 1);
eclipsePenumbraSpot.visible = penTouch;
eclipsePenumbraSpot.material.opacity = 0.28 * Math.pow(penFade, 0.85);
}
if (eclipseUmbraSpot) {
eclipseUmbraSpot.position.set(
surfacePoint.x + normal.x * 0.004,
surfacePoint.y + normal.y * 0.004,
surfacePoint.z + normal.z * 0.004
);
eclipseUmbraSpot.quaternion.copy(tangentQuat);
eclipseUmbraSpot.scale.set(umbraScale * limbStretch, umbraScale, 1);
eclipseUmbraSpot.visible = coreTouch;
eclipseUmbraSpot.material.opacity = (geom.annular ? 0.48 : 0.98) * coreFade;
}
}
const { min, max } = timeRange();
const frac = (simTime - min) / (max - min);
tlCursor.style.left = `${Math.max(0, Math.min(100, frac * 100)).toFixed(2)}%`;
const peakDate = eventPeakDate();
const cur = new Date(peakDate.getTime() + simTime * 60000);
tlDisplay.textContent = formatTime(cur, { withSeconds: false });
updateInfoPhase(cov);
if (viewMode === 'local') drawLocalView();
}
function eventPeakDate() {
return currentEclipse?.peak?.date || new Date();
}
function renderLoop(now) {
requestAnimationFrame(renderLoop);
if (exportInProgress) return;
if (playing && currentEclipse) {
const dt = (now - lastRAF) / 1000;
lastRAF = now;
simTime += dt * playSpeed;
const { max } = timeRange();
if (simTime >= max) {
simTime = max;
playing = false;
btnPlay.textContent = '▶ Play';
}
updateSim();
}
if (viewMode === 'space') renderer.render(scene, camera);
}
function timeZoneSuffix() {
return useLocalTime ? 'Uhr' : 'UTC';
}
function formatDate(date, options) {
const settings = useLocalTime ? {} : { timeZone: 'UTC' };
return date.toLocaleDateString('de-DE', { ...options, ...settings });
}
function formatTime(date, { withSeconds = true } = {}) {
const settings = {
hour: '2-digit',
minute: '2-digit',
...(withSeconds ? { second: '2-digit' } : {}),
hour12: false,
...(useLocalTime ? {} : { timeZone: 'UTC' }),
};
return `${date.toLocaleTimeString('de-DE', settings)} ${timeZoneSuffix()}`;
}
function refreshTimeDisplays() {
timeZoneLabelEl.textContent = timeZoneSuffix();
if (!currentEclipse) {
tlDisplay.textContent = `– – : – – ${timeZoneSuffix()}`;
return;
}
renderInfo();
renderContactTable();
updateSim();
}
function setInfoPanelCollapsed(collapsed) {
infoPanelCollapsed = collapsed;
infoPanel.classList.toggle('is-collapsed', collapsed);
infoToggleBtn.setAttribute('aria-expanded', String(!collapsed));
infoToggleBtn.title = collapsed ? 'Infokasten aufklappen' : 'Infokasten zuklappen';
}
function renderInfo() {
const ec = currentEclipse;
const d = ec.peak.date;
const ds = formatDate(d, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
const ts = formatTime(d);
const tc = eclipseTypeClass(ec.kind);
const tl = eclipseTypeLabel(ec.kind);
const locType = currentLocal ? eclipseTypeLabel(currentLocal.kind) : null;
const locObsc = currentLocal?.obscuration !== undefined ? (currentLocal.obscuration * 100).toFixed(1) + ' %' : null;
const centralLat = ec.latitude !== undefined && ec.latitude !== null ? `${ec.latitude.toFixed(2)}°` : '—';
const centralLon = ec.longitude !== undefined && ec.longitude !== null ? `${ec.longitude.toFixed(2)}°` : '—';
const distance = ec.distance !== undefined && ec.distance !== null ? `${Math.round(ec.distance)} km` : '—';
infoBody.innerHTML = `
<div class="eclipse-info-type ${tc}">${tl}</div>
<div class="eclipse-info-date">${ds}</div>
<div class="eclipse-info-time">${ts}</div>
<table class="data-table eclipse-info-table">
<tr><td>Globale Bedeckung</td><td>${((ec.obscuration ?? 0) * 100).toFixed(1)} %</td></tr>
<tr><td>Zentralpunkt</td><td>${centralLat}, ${centralLon}</td></tr>
<tr><td>Achsabstand</td><td>${distance}</td></tr>
${currentLocal ? `<tr><td>Am Standort</td><td>${locType}</td></tr>` : ''}
${currentLocal ? `<tr><td>Am Standort bedeckt</td><td>${locObsc}</td></tr>` : ''}
${!currentLocal && localObserver ? `<tr><td>Am Standort</td><td>nicht sichtbar</td></tr>` : ''}
</table>
<div class="eclipse-info-phase" id="eclipsePhaseLabel">—</div>
<div class="eclipse-info-scale-note">Distanzen stark komprimiert, Größenverhältnisse für die Anschauung angepasst</div>
`;
}
function updateInfoPhase(cov) {
const el = document.getElementById('eclipsePhaseLabel');
if (!el) return;
const name = phaseName(simTime) || '—';
const localAlt = currentLocal?.peak?.altitude;
const detail = cov.totalVisible
? ' · Sonne vollständig bedeckt'
: cov.ringVisible
? ' · Ring bleibt sichtbar'
: cov.maxInside > 0.01
? ` · ${Math.round(cov.maxInside * 100)} % der Sonnenscheibe im Eingriff`
: '';
const alt = Number.isFinite(localAlt) ? ` · Höhe ${localAlt.toFixed(1)}°` : '';
el.textContent = name + detail + alt;
}
function renderContactTable() {
if (geometryContacts?.c1 !== null && geometryContacts?.c4 !== null) {
const peak = eventPeakDate();
const rows = [
['C1', new Date(peak.getTime() + geometryContacts.c1 * 60000), 'Beginn partielle Phase'],
...(geometryContacts.c2 !== null ? [['C2', new Date(peak.getTime() + geometryContacts.c2 * 60000), geometryEventKind() === 'annular' ? 'Beginn Ringphase' : 'Beginn Totalität']] : []),
['MAX', peak, 'Größte Phase'],
...(geometryContacts.c3 !== null ? [['C3', new Date(peak.getTime() + geometryContacts.c3 * 60000), geometryEventKind() === 'annular' ? 'Ende Ringphase' : 'Ende Totalität']] : []),
['C4', new Date(peak.getTime() + geometryContacts.c4 * 60000), 'Ende partielle Phase']
];
contactTable.innerHTML = rows.map(([lbl, date, desc]) => `
<tr>
<td class="eclipse-contact-label">${lbl}</td>
<td class="eclipse-contact-time">${formatTime(date)}</td>
<td class="eclipse-contact-desc">${desc}</td>
</tr>
`).join('');
return;
}
if (currentLocal?.partial_begin && currentLocal?.partial_end) {
const rows = [
['C1', currentLocal.partial_begin.time.date, `Beginn partiell · Höhe ${currentLocal.partial_begin.altitude.toFixed(1)}°`],
...(currentLocal.total_begin ? [['C2', currentLocal.total_begin.time.date, `${currentLocal.kind === 'annular' ? 'Beginn Ringphase' : 'Beginn Totalität'} · Höhe ${currentLocal.total_begin.altitude.toFixed(1)}°`]] : []),
['MAX', currentLocal.peak.time.date, `Größte Phase · Höhe ${currentLocal.peak.altitude.toFixed(1)}°`],
...(currentLocal.total_end ? [['C3', currentLocal.total_end.time.date, `${currentLocal.kind === 'annular' ? 'Ende Ringphase' : 'Ende Totalität'} · Höhe ${currentLocal.total_end.altitude.toFixed(1)}°`]] : []),
['C4', currentLocal.partial_end.time.date, `Ende partiell · Höhe ${currentLocal.partial_end.altitude.toFixed(1)}°`]
];
contactTable.innerHTML = rows.map(([lbl, date, desc]) => `
<tr>
<td class="eclipse-contact-label">${lbl}</td>
<td class="eclipse-contact-time">${formatTime(date)}</td>
<td class="eclipse-contact-desc">${desc}</td>
</tr>
`).join('');
return;
}
const rows = [
['MAX', currentEclipse.peak.date, 'Größte globale Phase'],
['Typ', null, eclipseTypeLabel(currentEclipse.kind)],
['Bedeckung', null, ((currentEclipse.obscuration ?? 0) * 100).toFixed(1) + ' %'],
['Hinweis', null, localObserver ? 'Am gespeicherten Standort nicht sichtbar' : 'Für lokale Kontaktzeiten bitte Standort hinterlegen']
];
contactTable.innerHTML = rows.map(([lbl, date, desc]) => `
<tr>
<td class="eclipse-contact-label">${lbl}</td>
<td class="eclipse-contact-time">${date ? formatTime(date) : '—'}</td>
<td class="eclipse-contact-desc">${desc}</td>
</tr>
`).join('');
}
function renderTimeline() {
const { min: tMin, max: tMax } = timeRange();
const span = tMax - tMin;
const contacts = geometryContacts || { c1: -simPartialHalf, c2: simCentralHalf > 0 ? -simCentralHalf : null, max: 0, c3: simCentralHalf > 0 ? simCentralHalf : null, c4: simPartialHalf };
timelineBar.querySelectorAll('.eclipse-tl-fill,.eclipse-tl-marker').forEach(el => el.remove());
function pct(t) {
return ((t - tMin) / span * 100).toFixed(2) + '%';
}
fillBar(pct(contacts.c1 ?? -simPartialHalf), pct(contacts.c4 ?? simPartialHalf), 'rgba(220,130,20,0.16)');
if (contacts.c2 !== null && contacts.c3 !== null) {
fillBar(
pct(contacts.c2),
pct(contacts.c3),
geometryEventKind() === 'annular'
? 'rgba(235,190,60,0.22)'
: 'rgba(210,30,0,0.3)'
);
}
const markers = [
{ t: contacts.c1 ?? -simPartialHalf, lbl: 'C1', cls: 'umb' },
...(contacts.c2 !== null ? [{ t: contacts.c2, lbl: 'C2', cls: geometryEventKind() === 'annular' ? 'max' : 'tot' }] : []),
{ t: 0, lbl: 'MAX', cls: 'max' },
...(contacts.c3 !== null ? [{ t: contacts.c3, lbl: 'C3', cls: geometryEventKind() === 'annular' ? 'max' : 'tot' }] : []),
{ t: contacts.c4 ?? simPartialHalf, lbl: 'C4', cls: 'umb' }
];
markers.forEach(m => {
const el = document.createElement('div');
el.className = `eclipse-tl-marker eclipse-tl-${m.cls}`;
el.style.left = pct(m.t);
el.innerHTML = `<span class="eclipse-tl-label">${m.lbl}</span>`;
el.addEventListener('click', () => {
simTime = m.t;
updateSim();
});
timelineBar.appendChild(el);
});
function fillBar(l, r, color) {
const f = document.createElement('div');
f.className = 'eclipse-tl-fill';
f.style.left = l;
f.style.width = `calc(${r} - ${l})`;
f.style.background = color;
timelineBar.appendChild(f);
}
}
const originalUpdateSim = updateSim;
updateSim = function() {
originalUpdateSim();
stageTimeEl.textContent = tlDisplay.textContent;
};
const originalRefreshTimeDisplays = refreshTimeDisplays;
refreshTimeDisplays = function() {
originalRefreshTimeDisplays();
stageTimeEl.textContent = tlDisplay.textContent;
};
function setupInteraction() {
canvas3d.addEventListener('contextmenu', e => e.preventDefault());
canvas3d.addEventListener('mousedown', e => {
isDragging = true;
dragMode = (e.button === 2 || e.shiftKey) ? 'pan' : 'rotate';
dragX = e.clientX;
dragY = e.clientY;
});
window.addEventListener('mousemove', e => {
if (!isDragging) return;
const dx = e.clientX - dragX;
const dy = e.clientY - dragY;
if (dragMode === 'pan') {
panCamera(dx, dy);
} else {
camTheta -= dx * 0.005;
camPhi = Math.max(0.15, Math.min(Math.PI - 0.15, camPhi + dy * 0.005));
}
dragX = e.clientX;
dragY = e.clientY;
updateCamera();
});
window.addEventListener('mouseup', () => { isDragging = false; });
canvas3d.addEventListener('wheel', e => {
e.preventDefault();
camRadius = Math.max(1.2, Math.min(70, camRadius + e.deltaY * 0.02));
updateCamera();
}, { passive: false });
let tx = 0, ty = 0, touchMode = 'rotate';
canvas3d.addEventListener('touchstart', e => {
touchMode = e.touches.length >= 2 ? 'pan' : 'rotate';
if (touchMode === 'pan' && e.touches.length >= 2) {
tx = (e.touches[0].clientX + e.touches[1].clientX) * 0.5;
ty = (e.touches[0].clientY + e.touches[1].clientY) * 0.5;
} else {
tx = e.touches[0].clientX;
ty = e.touches[0].clientY;
}
}, { passive: true });
canvas3d.addEventListener('touchmove', e => {
e.preventDefault();
let nx, ny;
if (touchMode === 'pan' && e.touches.length >= 2) {
nx = (e.touches[0].clientX + e.touches[1].clientX) * 0.5;
ny = (e.touches[0].clientY + e.touches[1].clientY) * 0.5;
panCamera(nx - tx, ny - ty);
} else {
nx = e.touches[0].clientX;
ny = e.touches[0].clientY;
camTheta -= (nx - tx) * 0.005;
camPhi = Math.max(0.15, Math.min(Math.PI - 0.15, camPhi + (ny - ty) * 0.005));
}
tx = nx;
ty = ny;
updateCamera();
}, { passive: false });
function seekTimeline(e) {
if (!currentEclipse) return;
const rect = timelineBar.getBoundingClientRect();
const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const { min, max } = timeRange();
simTime = min + (max - min) * frac;
updateSim();
}
let tlDragging = false;
timelineBar.addEventListener('mousedown', e => { tlDragging = true; seekTimeline(e); });
window.addEventListener('mousemove', e => { if (tlDragging) seekTimeline(e); });
window.addEventListener('mouseup', () => { tlDragging = false; });
btnPlay.addEventListener('click', () => {
playing = !playing;
btnPlay.textContent = playing ? '⏸ Pause' : '▶ Play';
if (playing) lastRAF = performance.now();
});
document.getElementById('eclipseBtnRewind').addEventListener('click', () => {
simTime = timeRange().min;
updateSim();
});
document.getElementById('eclipseBtnEnd').addEventListener('click', () => {
simTime = timeRange().max;
updateSim();
});
speedSel.addEventListener('change', () => {
playSpeed = parseInt(speedSel.value, 10);
});
useLocalTimeEl.addEventListener('change', () => {
useLocalTime = useLocalTimeEl.checked;
refreshTimeDisplays();
});
showStageTimeEl.addEventListener('change', () => {
showStageTime = showStageTimeEl.checked;
stageTimeEl.classList.toggle('is-hidden', !showStageTime);
});
if (umbraScaleRange && umbraScaleValue) {
const syncUmbra = () => {
umbraProxyScale = parseFloat(umbraScaleRange.value);
umbraScaleValue.textContent = umbraProxyScale.toFixed(2);
if (currentEclipse) updateSim();
};
umbraScaleRange.addEventListener('input', syncUmbra);
syncUmbra();
}
if (penumbraScaleRange && penumbraScaleValue) {
const syncPenumbra = () => {
penumbraProxyScale = parseFloat(penumbraScaleRange.value);
penumbraScaleValue.textContent = penumbraProxyScale.toFixed(2);
if (currentEclipse) updateSim();
};
penumbraScaleRange.addEventListener('input', syncPenumbra);
syncPenumbra();
}
document.getElementById('eclipseSearchBtn').addEventListener('click', searchEclipses);
infoToggleBtn.addEventListener('click', () => {
setInfoPanelCollapsed(!infoPanelCollapsed);
});
btnSpaceView.addEventListener('click', () => setView('space'));
btnLocalView.addEventListener('click', () => setView('local'));
btnExportFrames.addEventListener('click', () => {
if (exportInProgress) {
exportCancelRequested = true;
} else {
exportFrames();
}
});
}
init().catch(err => {
stageWrap.innerHTML = `<div class="eclipse-error">Fehler: ${err.message}</div>`;
console.error(err);
});
</script>
<?php require __DIR__ . '/footer.php'; ?>