Files
skyview.astronomiemuseum.de/public/sonnenfinsternis.php
T
EskimueandClaude Sonnet 5 fb75b180e2 Finsternis-Suche: Standardzeitraum auf aktuelles Jahr +10 setzen
Dropdown-Bereich auf 1950-2100 erweitert, Standardauswahl war bisher
fix auf 1999-2000 statt am aktuellen Jahr orientiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 08:39:55 +02:00

1288 lines
46 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);
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';
?>
<?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">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 (UTC)</div>
<table class="data-table" id="eclipseContactTable"></table>
</div>
</aside>
<div class="solarsystem-stage-wrap" id="eclipseStageWrap">
<canvas id="eclipseCanvas" class="eclipse-canvas"></canvas>
<div id="eclipseScaleNote" class="eclipse-scale-note">
Distanzen stark komprimiert<br>Größenverhältnisse für die Anschauung angepasst
</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>
</button>
<div class="eclipse-info-body" id="eclipseInfoBody">
<div class="eclipse-info-empty">Wähle eine Finsternis aus der Liste</div>
</div>
</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">
<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>
<span class="eclipse-time-display" id="eclipseTimeDisplay">– – : – – UTC</span>
<label class="eclipse-speed-label">
<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>
</div>
</div>
<script type="module">
const USER_LAT = <?= $userLat !== null ? $userLat : 'null' ?>;
const USER_LON = <?= $userLon !== null ? $userLon : 'null' ?>;
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 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 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 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 simTime = 0;
let playing = false;
let playSpeed = 10;
let lastRAF = 0;
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 });
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();
}
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);
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();
}
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 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 = cur.toISOString().slice(11, 16) + ' UTC';
updateInfoPhase(cov);
}
function eventPeakDate() {
return currentEclipse?.peak?.date || new Date();
}
function renderLoop(now) {
requestAnimationFrame(renderLoop);
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();
}
renderer.render(scene, camera);
}
function fmtUTC(date) {
return date.toISOString().slice(11, 19) + ' UTC';
}
function renderInfo() {
const ec = currentEclipse;
const d = ec.peak.date;
const ds = d.toLocaleDateString('de-DE', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
const ts = fmtUTC(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>
`;
}
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">${fmtUTC(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">${fmtUTC(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 ? fmtUTC(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);
}
}
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);
});
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', () => {
const collapsed = infoPanel.classList.toggle('is-collapsed');
infoToggleBtn.setAttribute('aria-expanded', String(!collapsed));
});
}
init().catch(err => {
stageWrap.innerHTML = `<div class="eclipse-error">Fehler: ${err.message}</div>`;
console.error(err);
});
</script>
<?php require __DIR__ . '/footer.php'; ?>