1274 lines
46 KiB
PHP
1274 lines
46 KiB
PHP
<?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" style="flex:1 1 80px"></select>
|
||
<span style="color:var(--text-dim);align-self:center">–</span>
|
||
<select id="eclipseYearTo" style="flex:1 1 80px"></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" for="umbraScaleRange" style="display:block;margin-bottom:0.35rem">
|
||
Kernschatten: <span id="umbraScaleValue">0.50</span>
|
||
</label>
|
||
<input id="umbraScaleRange" type="range" min="0.01" max="3.00" step="0.01" value="0.50" style="width:100%;margin-bottom:0.8rem">
|
||
<label class="solarsystem-label" for="penumbraScaleRange" style="display:block;margin-bottom:0.35rem">
|
||
Halbschatten: <span id="penumbraScaleValue">1.00</span>
|
||
</label>
|
||
<input id="penumbraScaleRange" type="range" min="0.01" max="5.00" step="0.01" value="1.00" style="width:100%">
|
||
</div>
|
||
|
||
<div class="solarsystem-sidebar-block" id="eclipseDataBlock" style="display:none">
|
||
<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" style="display:block;width:100%;height:100%"></canvas>
|
||
|
||
<div id="eclipseScaleNote" style="position:absolute;bottom:90px;left:10px;
|
||
font-size:10px;color:var(--text-dim);opacity:0.6;pointer-events:none;line-height:1.4">
|
||
Distanzen stark komprimiert<br>Größenverhältnisse für die Anschauung angepasst
|
||
</div>
|
||
|
||
<div class="solarsystem-info-panel" id="eclipseInfoPanel">
|
||
<div class="eclipse-info-empty">Wähle eine Finsternis aus der Liste</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" style="margin-left:auto">
|
||
<span class="solarsystem-label" style="display:inline">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 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();
|
||
for (let y = 1995; y <= now + 20; y++) {
|
||
yf.add(new Option(y, y, y === 1999, y === 1999));
|
||
yt.add(new Option(y, y, y === 2000, y === 2000));
|
||
}
|
||
}
|
||
|
||
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.style.display = '';
|
||
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` : '—';
|
||
|
||
infoPanel.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 style="color:var(--gold);font-weight:700;min-width:2.4rem">${lbl}</td>
|
||
<td style="font-variant-numeric:tabular-nums">${fmtUTC(date)}</td>
|
||
<td style="color:var(--text-dim);font-size:0.78rem">${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 style="color:var(--gold);font-weight:700;min-width:2.4rem">${lbl}</td>
|
||
<td style="font-variant-numeric:tabular-nums">${fmtUTC(date)}</td>
|
||
<td style="color:var(--text-dim);font-size:0.78rem">${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 style="color:var(--gold);font-weight:700;min-width:2.4rem">${lbl}</td>
|
||
<td style="font-variant-numeric:tabular-nums">${date ? fmtUTC(date) : '—'}</td>
|
||
<td style="color:var(--text-dim);font-size:0.78rem">${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);
|
||
}
|
||
|
||
init().catch(err => {
|
||
stageWrap.innerHTML = `<div style="padding:2rem;color:var(--text-dim)">Fehler: ${err.message}</div>`;
|
||
console.error(err);
|
||
});
|
||
</script>
|
||
|
||
<?php require __DIR__ . '/footer.php'; ?>
|