991 lines
42 KiB
PHP
991 lines
42 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
session_start();
|
||
|
||
$loggedIn = isset($_SESSION['user_id']);
|
||
$pageTitle = 'Mondfinsternis – AstroTools';
|
||
$bodyClass = 'solarsystem-page';
|
||
|
||
?>
|
||
<?php require __DIR__ . '/header.php'; ?>
|
||
<?php
|
||
// Standort kommt aus header.php ($currentLocation)
|
||
$userLat = isset($currentLocation['latitude']) ? (float)$currentLocation['latitude'] : null;
|
||
$userLon = isset($currentLocation['longitude']) ? (float)$currentLocation['longitude'] : null;
|
||
?>
|
||
<script src="js/astronomy.browser.min.js"></script>
|
||
|
||
<div class="solarsystem-tool">
|
||
<div class="solarsystem-layout">
|
||
|
||
<!-- ── Sidebar ─────────────────────────────────── -->
|
||
<aside class="card solarsystem-sidebar" id="eclipseSidebar">
|
||
|
||
<div class="solarsystem-sidebar-block">
|
||
<h2>Mondfinsternis</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" id="eclipseDataBlock" style="display:none">
|
||
<div class="solarsystem-label">Kontaktzeiten (UTC)</div>
|
||
<table class="data-table" id="eclipseContactTable"></table>
|
||
</div>
|
||
|
||
</aside>
|
||
|
||
<!-- ── Stage ──────────────────────────────────── -->
|
||
<div class="solarsystem-stage-wrap" id="eclipseStageWrap">
|
||
|
||
<!-- View toggle -->
|
||
<div class="eclipse-view-toggle">
|
||
<button class="btn btn-sm btn-secondary is-active" id="btnGeoView">Geometrie</button>
|
||
<button class="btn btn-sm btn-secondary" id="btnMoonView">Mondansicht</button>
|
||
<button class="btn btn-sm btn-secondary is-active" id="btnToggleShadows" title="Schattenkegel ein-/ausblenden">Schatten</button>
|
||
</div>
|
||
|
||
<!-- Three.js canvas (geometry) -->
|
||
<canvas id="eclipseCanvas" style="display:block;width:100%;height:100%"></canvas>
|
||
<!-- 2D Moon canvas -->
|
||
<canvas id="eclipseMoonCanvas" style="display:none;width:100%;height:100%"></canvas>
|
||
|
||
|
||
<!-- Scale notice -->
|
||
<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">
|
||
Abstände nicht maßstabsgetreu<br>(Mond real ~60× weiter entfernt)
|
||
</div>
|
||
|
||
<!-- Info overlay -->
|
||
<div class="solarsystem-info-panel" id="eclipseInfoPanel">
|
||
<div class="eclipse-info-empty">Wähle eine Finsternis aus der Liste</div>
|
||
</div>
|
||
|
||
<!-- Timeline -->
|
||
<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><!-- /stage-wrap -->
|
||
</div><!-- /solarsystem-layout -->
|
||
</div><!-- /solarsystem-tool -->
|
||
|
||
<script type="module">
|
||
const USER_LAT = <?= $userLat !== null ? $userLat : 'null' ?>;
|
||
const USER_LON = <?= $userLon !== null ? $userLon : 'null' ?>;
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// Constants
|
||
// ════════════════════════════════════════════════════════════
|
||
const AE = window.Astronomy;
|
||
|
||
// Visual scale: 1 vis-unit = 1 Earth radius
|
||
const R_E = 1.0; // Earth visual radius
|
||
const R_M = 0.2725; // Moon visual radius (0.2725 R_E)
|
||
const D_M = 7.0; // Compressed Moon orbit (real: 60.27 R_E)
|
||
|
||
// Shadow cone geometry derived from real Solar-system geometry
|
||
// Umbra apex: D_APEX = R_E * D_ES / (R_SUN - R_E) → compressed
|
||
// R_SUN/R_E ≈ 109.08, D_ES/R_E ≈ 23481
|
||
// D_APEX_real = 23481 / (109.08 - 1) = 217.3 R_E
|
||
const D_APEX_VIS = 217.3 * (D_M / 60.27); // ≈ 25.24 vis-units
|
||
// Penumbra apex on Sun-side:
|
||
// D_APEX_PEN_real = 23481 / (109.08 + 1) = 213.3 R_E
|
||
const D_APEX_PEN = 213.3 * (D_M / 60.27); // ≈ 24.77 vis-units
|
||
|
||
// Shadow radii at Moon's mean orbit (D_M from Earth)
|
||
const R_UMB = R_E * (1 - D_M / D_APEX_VIS); // ≈ 0.7227
|
||
const R_PEN = R_E * (D_APEX_PEN + D_M) / D_APEX_PEN; // ≈ 1.283
|
||
|
||
// Mean Moon speed in vis-units/min (real: ~1.023 km/s → 61.4 km/min → 0.009637 R_E/min)
|
||
const V_MOON_MEAN = 0.009637;
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// DOM refs
|
||
// ════════════════════════════════════════════════════════════
|
||
const canvas3d = document.getElementById('eclipseCanvas');
|
||
const canvas2d = document.getElementById('eclipseMoonCanvas');
|
||
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 btnGeoView = document.getElementById('btnGeoView');
|
||
const btnMoonView = document.getElementById('btnMoonView');
|
||
const speedSel = document.getElementById('eclipseSpeedSel');
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// State
|
||
// ════════════════════════════════════════════════════════════
|
||
let currentEclipse = null;
|
||
let eclipseListData = [];
|
||
let simTime = 0; // minutes from peak
|
||
let playing = false;
|
||
let playSpeed = 10; // minutes / real second
|
||
let lastRAF = 0;
|
||
let viewMode = 'geo';
|
||
|
||
// Eclipse geometry (computed per eclipse)
|
||
let eclB = 0; // impact parameter (vis-units)
|
||
let eclV = V_MOON_MEAN; // actual Moon speed (vis-units/min)
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// Three.js state
|
||
// ════════════════════════════════════════════════════════════
|
||
let THREE, renderer, scene, camera, cameraMoon;
|
||
let earthMesh, earthTiltGroup, moonMesh, moonMatRef;
|
||
let umbraMesh, penumbraMesh, moonTrailLine;
|
||
let sunLight, ambientLight, bloodAmbient;
|
||
let camTheta = 0.25, camPhi = 1.15, camRadius = 18;
|
||
const camTarget = { x: D_M * 0.5, y: 0, z: 0 };
|
||
let isDragging = false, dragX = 0, dragY = 0;
|
||
|
||
// 2D Moon texture image
|
||
let moonImgEl = null;
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// Init
|
||
// ════════════════════════════════════════════════════════════
|
||
async function init() {
|
||
// Pre-load Moon texture for 2D canvas
|
||
moonImgEl = new Image();
|
||
moonImgEl.src = 'images/moons/moon_2048.jpg';
|
||
|
||
THREE = await import('./js/three.module.js');
|
||
|
||
// Renderer
|
||
renderer = new THREE.WebGLRenderer({ canvas: canvas3d, antialias: true });
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||
renderer.toneMappingExposure = 1.1;
|
||
|
||
// Scene
|
||
scene = new THREE.Scene();
|
||
scene.background = new THREE.Color(0x000309);
|
||
|
||
// Camera
|
||
camera = new THREE.PerspectiveCamera(52, 1, 0.05, 500);
|
||
updateCamera();
|
||
|
||
// Close-up camera for Moon view (looking from Earth side toward the Moon)
|
||
cameraMoon = new THREE.PerspectiveCamera(22, 1, 0.01, 50);
|
||
|
||
// ── Lights ──────────────────────────────────────
|
||
ambientLight = new THREE.AmbientLight(0x0d1630, 1.0);
|
||
scene.add(ambientLight);
|
||
|
||
sunLight = new THREE.DirectionalLight(0xfff8e8, 2.0);
|
||
sunLight.position.set(-80, 1, 0.5);
|
||
scene.add(sunLight);
|
||
|
||
bloodAmbient = new THREE.AmbientLight(0xff1500, 0.0);
|
||
scene.add(bloodAmbient);
|
||
|
||
// Sun glow sphere (very far left)
|
||
const sunGeo = new THREE.SphereGeometry(2.0, 32, 32);
|
||
const sunMat = new THREE.MeshBasicMaterial({ color: 0xfff5aa });
|
||
const sunMesh = new THREE.Mesh(sunGeo, sunMat);
|
||
sunMesh.position.set(-84, 0, 0);
|
||
scene.add(sunMesh);
|
||
|
||
const haloGeo = new THREE.SphereGeometry(4.0, 32, 32);
|
||
const haloMat = new THREE.MeshBasicMaterial({ color: 0xffdd44, transparent: true, opacity: 0.07 });
|
||
const halo = new THREE.Mesh(haloGeo, haloMat);
|
||
halo.position.set(-84, 0, 0);
|
||
scene.add(halo);
|
||
|
||
// Starfield
|
||
buildStarfield();
|
||
|
||
// ── Earth ───────────────────────────────────────
|
||
const tl = new THREE.TextureLoader();
|
||
const earthTex = tl.load('images/planets/earth_day_1024.jpg');
|
||
earthMesh = new THREE.Mesh(
|
||
new THREE.SphereGeometry(R_E, 64, 64),
|
||
new THREE.MeshStandardMaterial({ map: earthTex, roughness: 0.88, metalness: 0.0 })
|
||
);
|
||
earthTiltGroup = new THREE.Group();
|
||
earthTiltGroup.add(earthMesh);
|
||
scene.add(earthTiltGroup);
|
||
|
||
// Roter Standort-Punkt des Users auf der Erde
|
||
if (USER_LAT !== null && USER_LON !== null) {
|
||
const phi = (90 - USER_LAT) * Math.PI / 180;
|
||
const theta = Math.PI + USER_LON * Math.PI / 180; // u=0 (Antimeridian) bei theta=0, Nullmeridian bei theta=π
|
||
const r = R_E + 0.012;
|
||
const px = -r * Math.sin(phi) * Math.cos(theta);
|
||
const py = r * Math.cos(phi);
|
||
const pz = r * Math.sin(phi) * Math.sin(theta);
|
||
const dot = new THREE.Mesh(
|
||
new THREE.SphereGeometry(0.03, 12, 12),
|
||
new THREE.MeshBasicMaterial({ color: 0xff2222 })
|
||
);
|
||
dot.position.set(px, py, pz);
|
||
earthMesh.add(dot); // Kind von earthMesh → dreht sich mit
|
||
}
|
||
|
||
// ── Shadow cones ────────────────────────────────
|
||
buildShadowCones();
|
||
|
||
// ── Moon ────────────────────────────────────────
|
||
const moonTex = tl.load('images/moons/moon_2048.jpg');
|
||
const moonBump = tl.load('images/moons/moon_displacement_16bit.jpg');
|
||
moonMatRef = new THREE.MeshStandardMaterial({
|
||
map: moonTex, bumpMap: moonBump, bumpScale: 0.06,
|
||
roughness: 0.92, metalness: 0.0,
|
||
emissive: new THREE.Color(0, 0, 0), emissiveIntensity: 0.0
|
||
});
|
||
|
||
// Per-fragment shadow: the shadow sweeps across the Moon disk as it enters the umbra/penumbra.
|
||
// We compute each fragment's distance from the Earth shadow axis (the +X axis) in world space.
|
||
moonMatRef.onBeforeCompile = (shader) => {
|
||
// Pass constants as uniforms so the shader can be updated if needed
|
||
shader.uniforms.uDApexVis = { value: D_APEX_VIS };
|
||
shader.uniforms.uDApexPen = { value: D_APEX_PEN };
|
||
moonMatRef.userData.shader = shader;
|
||
|
||
// Inject varying into vertex shader
|
||
shader.vertexShader = 'varying vec3 vMoonWorldPos;\n' + shader.vertexShader;
|
||
shader.vertexShader = shader.vertexShader.replace(
|
||
'#include <fog_vertex>',
|
||
`#include <fog_vertex>
|
||
vMoonWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;`
|
||
);
|
||
|
||
// Inject shadow darkening into fragment shader
|
||
shader.fragmentShader = 'varying vec3 vMoonWorldPos;\nuniform float uDApexVis;\nuniform float uDApexPen;\n'
|
||
+ shader.fragmentShader;
|
||
shader.fragmentShader = shader.fragmentShader.replace(
|
||
'#include <color_fragment>',
|
||
`#include <color_fragment>
|
||
if (vMoonWorldPos.x > 0.0) {
|
||
// Perpendicular distance from shadow axis (X axis)
|
||
float d = length(vMoonWorldPos.yz);
|
||
// Shadow cone radii at this x-position
|
||
float rUmb = max(0.0, ${R_E.toFixed(4)} * (1.0 - vMoonWorldPos.x / uDApexVis));
|
||
float rPen = ${R_E.toFixed(4)} * (uDApexPen + vMoonWorldPos.x) / uDApexPen;
|
||
float edge = ${(R_M * 0.8).toFixed(4)};
|
||
// 0 = fully inside, 1 = fully outside
|
||
float outUmb = smoothstep(rUmb - edge, rUmb + edge, d);
|
||
float outPen = smoothstep(rPen - edge, rPen + edge, d);
|
||
// Penumbra: partial shadow
|
||
float penFactor = (1.0 - outPen) * outUmb; // in penumbra but not umbra
|
||
// Umbra: deep shadow
|
||
float umbFactor = 1.0 - outUmb;
|
||
float darken = 1.0 - umbFactor * 0.93 - penFactor * 0.75;
|
||
diffuseColor.rgb *= darken;
|
||
// Blood-moon glow: deep red only in umbra
|
||
diffuseColor.rgb += vec3(0.10, 0.006, 0.0) * umbFactor * umbFactor;
|
||
}`
|
||
);
|
||
};
|
||
moonMesh = new THREE.Mesh(new THREE.SphereGeometry(R_M, 64, 64), moonMatRef);
|
||
moonMesh.position.set(D_M, 0, 0);
|
||
moonMesh.rotation.y = Math.PI; // near-side facing Earth, correct east/west orientation
|
||
scene.add(moonMesh);
|
||
|
||
// Moon trail line (placeholder, updated on eclipse select)
|
||
moonTrailLine = buildMoonTrail(0);
|
||
scene.add(moonTrailLine);
|
||
|
||
// ── Resize + events ─────────────────────────────
|
||
resizeAll();
|
||
window.addEventListener('resize', resizeAll);
|
||
setupInteraction();
|
||
buildYearSelectors();
|
||
|
||
// Start render loop
|
||
requestAnimationFrame(renderLoop);
|
||
|
||
// Auto-search
|
||
searchEclipses();
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// Three.js helpers
|
||
// ════════════════════════════════════════════════════════════
|
||
function buildStarfield() {
|
||
const pos = [];
|
||
for (let i = 0; i < 3000; i++) {
|
||
const t = Math.random() * Math.PI * 2;
|
||
const p = Math.acos(2 * Math.random() - 1);
|
||
const r = 180 + Math.random() * 60;
|
||
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.35, sizeAttenuation: true })));
|
||
}
|
||
|
||
function buildShadowCones() {
|
||
if (umbraMesh) { scene.remove(umbraMesh); umbraMesh.geometry.dispose(); }
|
||
if (penumbraMesh){ scene.remove(penumbraMesh); penumbraMesh.geometry.dispose(); }
|
||
|
||
// Replace filled cones with thin boundary rings in the YZ plane at Moon's orbital distance.
|
||
// RingGeometry lies in the XY plane by default → rotate 90° around Y to face +X.
|
||
const t = 0.015; // ring thickness
|
||
|
||
const umbraRingGeo = new THREE.RingGeometry(R_UMB - t, R_UMB + t, 96);
|
||
umbraMesh = new THREE.Mesh(umbraRingGeo, new THREE.MeshBasicMaterial({
|
||
color: 0x1a4a8a, transparent: true, opacity: 0.55,
|
||
side: THREE.DoubleSide, depthWrite: false
|
||
}));
|
||
umbraMesh.rotation.y = Math.PI / 2;
|
||
umbraMesh.position.x = D_M;
|
||
scene.add(umbraMesh);
|
||
|
||
const penRingGeo = new THREE.RingGeometry(R_PEN - t, R_PEN + t, 96);
|
||
penumbraMesh = new THREE.Mesh(penRingGeo, new THREE.MeshBasicMaterial({
|
||
color: 0x0d2244, transparent: true, opacity: 0.35,
|
||
side: THREE.DoubleSide, depthWrite: false
|
||
}));
|
||
penumbraMesh.rotation.y = Math.PI / 2;
|
||
penumbraMesh.position.x = D_M;
|
||
scene.add(penumbraMesh);
|
||
}
|
||
|
||
function buildMoonTrail(bVal) {
|
||
if (moonTrailLine) { scene.remove(moonTrailLine); moonTrailLine.geometry.dispose(); }
|
||
const pts = [];
|
||
const omega = eclV / D_M;
|
||
for (let t = -180; t <= 180; t += 3) {
|
||
const th = omega * t;
|
||
pts.push(new THREE.Vector3(D_M * Math.cos(th), bVal, -D_M * Math.sin(th)));
|
||
}
|
||
const geo = new THREE.BufferGeometry().setFromPoints(pts);
|
||
const mat = new THREE.LineBasicMaterial({ color: 0x223355, transparent: true, opacity: 0.5 });
|
||
return new THREE.Line(geo, mat);
|
||
}
|
||
|
||
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 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();
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// Eclipse search (AstronomyEngine)
|
||
// ════════════════════════════════════════════════════════════
|
||
function buildYearSelectors() {
|
||
const yf = document.getElementById('eclipseYearFrom');
|
||
const yt = document.getElementById('eclipseYearTo');
|
||
const now = new Date().getFullYear();
|
||
for (let y = now - 5; y <= now + 20; y++) {
|
||
yf.add(new Option(y, y, y === now, y === now));
|
||
yt.add(new Option(y, y, y === now + 1, y === now + 1));
|
||
}
|
||
}
|
||
|
||
function searchEclipses() {
|
||
const yf = parseInt(document.getElementById('eclipseYearFrom').value);
|
||
const yt = parseInt(document.getElementById('eclipseYearTo').value);
|
||
eclipseListData = [];
|
||
|
||
let date = new Date(yf, 0, 1);
|
||
const limit = new Date(yt + 1, 0, 1);
|
||
|
||
while (date < limit) {
|
||
let ecl;
|
||
try { ecl = AE.SearchLunarEclipse(date); } catch(e) { break; }
|
||
if (ecl.peak.date >= limit) break;
|
||
eclipseListData.push(ecl);
|
||
date = new Date(ecl.peak.date.getTime() + 29 * 86400000);
|
||
}
|
||
|
||
renderEclipseList();
|
||
|
||
// Auto-select first upcoming (or first in list)
|
||
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 = { penumbral: 'eclipse-type-pen', partial: 'eclipse-type-par', total: 'eclipse-type-tot' }[ecl.kind] || '';
|
||
const tl = { penumbral: 'Halbschatten', partial: 'Partial', total: 'Total' }[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(+el.dataset.i))
|
||
);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// Eclipse selection & geometry
|
||
// ════════════════════════════════════════════════════════════
|
||
function selectEclipse(idx) {
|
||
if (!eclipseListData[idx]) return;
|
||
currentEclipse = eclipseListData[idx];
|
||
|
||
// Highlight list item
|
||
eclipseListEl.querySelectorAll('.eclipse-list-item').forEach((el, i) =>
|
||
el.classList.toggle('is-selected', i === idx)
|
||
);
|
||
// Scroll into view
|
||
const sel = eclipseListEl.querySelector('.is-selected');
|
||
if (sel) sel.scrollIntoView({ block: 'nearest' });
|
||
|
||
computeEclipseGeometry();
|
||
orientEarthAxis(currentEclipse.peak.date);
|
||
|
||
simTime = timeRange().min;
|
||
updateSim();
|
||
renderInfo();
|
||
renderContactTable();
|
||
renderTimeline();
|
||
|
||
// Rebuild Moon trail
|
||
if (moonTrailLine) scene.remove(moonTrailLine);
|
||
moonTrailLine = buildMoonTrail(eclB);
|
||
scene.add(moonTrailLine);
|
||
|
||
dataBlock.style.display = '';
|
||
|
||
playing = false;
|
||
btnPlay.textContent = '▶ Play';
|
||
lastRAF = performance.now();
|
||
}
|
||
|
||
// Ekliptikale Länge der Sonne (Näherung, Grad) für ein gegebenes Datum
|
||
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;
|
||
}
|
||
|
||
// Erdachse korrekt ausrichten: Neigung 23.44° zeigt zum Himmelspol
|
||
// abhängig von der Stellung der Erde in ihrer Sonnenbahn
|
||
function orientEarthAxis(date) {
|
||
if (!earthTiltGroup) return;
|
||
const lambda = solarLongitude(date) * Math.PI / 180; // ekliptikale Länge der Sonne
|
||
const eps = 23.44 * Math.PI / 180; // Schiefe der Ekliptik
|
||
// Nordpol-Richtung in Szenenkoordinaten (X = Erde→Mond, Y = ekliptischer Nordpol)
|
||
const px = -Math.sin(eps) * Math.sin(lambda);
|
||
const py = Math.cos(eps);
|
||
const pz = Math.sin(eps) * Math.cos(lambda);
|
||
const pole = new THREE.Vector3(px, py, pz).normalize();
|
||
earthTiltGroup.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), pole);
|
||
// Spin-Startoffset: UTC-Stunde des Finsternismaximums → Längengrad auf Tagseite
|
||
const utcHours = date.getUTCHours() + date.getUTCMinutes() / 60;
|
||
earthMesh.rotation.y = (utcHours / 24) * Math.PI * 2;
|
||
}
|
||
|
||
function computeEclipseGeometry() {
|
||
const ec = currentEclipse;
|
||
if (ec.sd_partial > 0) {
|
||
// Use two contact semi-durations to find actual Moon speed and impact param
|
||
const rP = R_PEN + R_M;
|
||
const rU = R_UMB + R_M;
|
||
const sdP = ec.sd_penum, sdU = ec.sd_partial;
|
||
const v2 = (rP * rP - rU * rU) / (sdP * sdP - sdU * sdU);
|
||
eclV = Math.sqrt(Math.max(v2, V_MOON_MEAN * V_MOON_MEAN * 0.7));
|
||
eclB = Math.sqrt(Math.max(0, rP * rP - eclV * eclV * sdP * sdP));
|
||
} else {
|
||
eclV = V_MOON_MEAN;
|
||
const rP = R_PEN + R_M;
|
||
eclB = Math.sqrt(Math.max(0, rP * rP - eclV * eclV * ec.sd_penum * ec.sd_penum));
|
||
}
|
||
}
|
||
|
||
function moonPos(t) {
|
||
// Moon orbits Earth in the XZ plane (equatorial plane; Three.js Y = pole axis).
|
||
// eclB is the perpendicular offset in Y (ecliptic latitude).
|
||
// At t=0 (peak) the Moon is at z=0, x=D_M — closest alignment with shadow axis.
|
||
const theta = (eclV / D_M) * t; // angle swept in XZ plane
|
||
const xMoon = D_M * Math.cos(theta);
|
||
const zMoon = -D_M * Math.sin(theta);
|
||
const yMoon = eclB; // constant ecliptic-latitude offset
|
||
// Perpendicular distance from the shadow axis (X axis) = sqrt(y² + z²)
|
||
const dist = Math.sqrt(yMoon * yMoon + zMoon * zMoon);
|
||
return { x: xMoon, y: yMoon, z: zMoon, dist };
|
||
}
|
||
|
||
function shadowDepth(mp) {
|
||
// Returns { inUmbra: 0-1, inPenumbra: 0-1, totalDepth: 0-1 }
|
||
const dist = mp.dist; // distance from shadow-cone centre at (D_M, 0)
|
||
const inPen = Math.max(0, Math.min(1, (R_PEN + R_M - dist) / (2 * R_M)));
|
||
const inUmb = Math.max(0, Math.min(1, (R_UMB + R_M - dist) / (2 * R_M)));
|
||
// Total: Moon fully inside umbra when dist < R_UMB - R_M
|
||
const totD = Math.max(0, Math.min(1, (R_UMB - R_M - dist) / (2 * R_M) * 4));
|
||
return { inPenumbra: inPen, inUmbra: inUmb, totalDepth: Math.min(1, totD) };
|
||
}
|
||
|
||
function timeRange() {
|
||
if (!currentEclipse) return { min: -10, max: 10 };
|
||
const sd = currentEclipse.sd_penum;
|
||
return { min: -sd - 8, max: sd + 8 };
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// Simulation update
|
||
// ════════════════════════════════════════════════════════════
|
||
function updateSim() {
|
||
if (!currentEclipse) return;
|
||
const mp = moonPos(simTime);
|
||
const sdw = shadowDepth(mp);
|
||
|
||
// ── Three.js scene ──────────────────────────────
|
||
if (moonMesh) {
|
||
moonMesh.position.set(mp.x, mp.y, mp.z);
|
||
// Shadow is handled per-fragment in the Moon's onBeforeCompile shader.
|
||
// sunLight stays constant so Earth is unaffected.
|
||
}
|
||
|
||
// ── Timeline cursor ─────────────────────────────
|
||
const { min, max } = timeRange();
|
||
const frac = (simTime - min) / (max - min);
|
||
tlCursor.style.left = `${Math.max(0, Math.min(100, frac * 100)).toFixed(2)}%`;
|
||
|
||
// ── Time display ────────────────────────────────
|
||
const peakMs = currentEclipse.peak.date.getTime();
|
||
const cur = new Date(peakMs + simTime * 60000);
|
||
tlDisplay.textContent = cur.toISOString().slice(11, 16) + ' UTC';
|
||
|
||
// ── 2D moon view ────────────────────────────────
|
||
if (viewMode === 'moon') drawMoonView(mp, sdw);
|
||
|
||
// ── Info overlay phase ──────────────────────────
|
||
updateInfoPhase(sdw);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// Render loop
|
||
// ════════════════════════════════════════════════════════════
|
||
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();
|
||
}
|
||
|
||
if (earthMesh && currentEclipse) {
|
||
const utcH = currentEclipse.peak.date.getUTCHours() + currentEclipse.peak.date.getUTCMinutes() / 60;
|
||
earthMesh.rotation.y = ((utcH / 24) + (simTime / 1440)) * Math.PI * 2;
|
||
}
|
||
// In geo view the Moon spins slowly for aesthetics.
|
||
// In moon view we freeze rotation.y = Math.PI: correct texture orientation.
|
||
if (moonMesh && viewMode === 'geo') moonMesh.rotation.y += 0.0001;
|
||
|
||
if (viewMode === 'geo') {
|
||
renderer.render(scene, camera);
|
||
} else if (viewMode === 'moon' && moonMesh) {
|
||
const mp = moonMesh.position;
|
||
cameraMoon.up.set(0, 1, 0); // north (Y+) always at top
|
||
cameraMoon.position.set(mp.x - 2.8, mp.y, mp.z);
|
||
cameraMoon.lookAt(mp.x, mp.y, mp.z);
|
||
cameraMoon.aspect = camera.aspect;
|
||
cameraMoon.updateProjectionMatrix();
|
||
renderer.render(scene, cameraMoon);
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// 2D Moon view
|
||
// ════════════════════════════════════════════════════════════
|
||
function drawMoonView(mp, sdw) {
|
||
if (!currentEclipse) return;
|
||
if (!mp) mp = moonPos(simTime);
|
||
if (!sdw) sdw = shadowDepth(mp);
|
||
|
||
const ctx = canvas2d.getContext('2d');
|
||
const W = canvas2d.width;
|
||
const H = canvas2d.height;
|
||
ctx.clearRect(0, 0, W, H);
|
||
|
||
// Background
|
||
ctx.fillStyle = '#000309';
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
// Static stars
|
||
ctx.save();
|
||
for (let i = 0; i < 300; i++) {
|
||
const sx = ((i * 137.508 + 60) % (W - 4)) + 2;
|
||
const sy = ((i * 79.317 + 40) % (H - 4)) + 2;
|
||
const sz = 0.4 + (i % 5) * 0.18;
|
||
const sa = 0.2 + (i % 7) * 0.06;
|
||
ctx.beginPath(); ctx.arc(sx, sy, sz, 0, Math.PI * 2);
|
||
ctx.fillStyle = `rgba(255,255,255,${sa.toFixed(2)})`;
|
||
ctx.fill();
|
||
}
|
||
ctx.restore();
|
||
|
||
const R = Math.min(W, H) * 0.37;
|
||
const cx = W / 2, cy = H / 2;
|
||
const sc = R / R_M; // pixels per vis-unit
|
||
|
||
// ── Moon disc ───────────────────────────────────
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, R, 0, Math.PI * 2);
|
||
ctx.clip();
|
||
|
||
if (moonImgEl && moonImgEl.complete && moonImgEl.naturalWidth > 0) {
|
||
// Draw real texture – equirectangular, take centre strip
|
||
const iw = moonImgEl.naturalWidth, ih = moonImgEl.naturalHeight;
|
||
const sx = iw * 0.25, sy = ih * 0.25, sw = iw * 0.5, sh = ih * 0.5;
|
||
ctx.drawImage(moonImgEl, sx, sy, sw, sh, cx - R, cy - R, R * 2, R * 2);
|
||
} else {
|
||
// Fallback gradient
|
||
const g = ctx.createRadialGradient(cx - R * 0.2, cy - R * 0.2, R * 0.05, cx, cy, R);
|
||
g.addColorStop(0, '#d8d0c8'); g.addColorStop(0.6, '#b0a898'); g.addColorStop(1, '#7a7268');
|
||
ctx.fillStyle = g; ctx.fillRect(cx - R, cy - R, R * 2, R * 2);
|
||
}
|
||
|
||
// Limb darkening
|
||
const ld = ctx.createRadialGradient(cx, cy, R * 0.55, cx, cy, R);
|
||
ld.addColorStop(0, 'rgba(0,0,0,0)');
|
||
ld.addColorStop(1, 'rgba(0,0,0,0.48)');
|
||
ctx.fillStyle = ld; ctx.fillRect(cx - R, cy - R, R * 2, R * 2);
|
||
|
||
ctx.restore();
|
||
|
||
// ── Shadow overlay ──────────────────────────────
|
||
// Shadow center in canvas coordinates
|
||
// moonPos gives Moon center; shadow axis is at y=0, x=D_M always
|
||
// Shadow center relative to Moon center (in vis-units):
|
||
// shadow_dx = D_M - mp.x = -eclV * simTime (shadow doesn't move, Moon does)
|
||
// shadow_dy = 0 - mp.y = -eclB
|
||
const sdxVis = D_M - mp.x; // = -eclV * simTime
|
||
const sdyVis = 0 - mp.y; // = -eclB
|
||
const scx = cx + sdxVis * sc;
|
||
const scy = cy + sdyVis * sc; // canvas +Y is down
|
||
|
||
const Rpen = R_PEN * sc;
|
||
const Rumb = R_UMB * sc;
|
||
|
||
ctx.save();
|
||
ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); ctx.clip();
|
||
|
||
// Penumbra gradient
|
||
const pg = ctx.createRadialGradient(scx, scy, Math.max(0, Rumb * 0.95), scx, scy, Rpen * 1.05);
|
||
pg.addColorStop(0, 'rgba(0,0,0,0)');
|
||
pg.addColorStop(1, 'rgba(0, 0, 0, 0.95)');
|
||
ctx.fillStyle = pg; ctx.fillRect(0, 0, W, H);
|
||
|
||
// Umbra gradient – reddish deep shadow
|
||
if (Rumb > 0) {
|
||
const td = sdw.totalDepth;
|
||
const r0 = Math.round(15 + td * 50), r1 = Math.round(10 + td * 30);
|
||
const ug = ctx.createRadialGradient(scx, scy, 0, scx, scy, Rumb * 1.02);
|
||
ug.addColorStop(0, `rgba(${r0},${Math.round(td*6)},0,0.93)`);
|
||
ug.addColorStop(0.65,`rgba(${r1},2,0,0.88)`);
|
||
ug.addColorStop(1, 'rgba(0,0,0,0.62)');
|
||
ctx.fillStyle = ug; ctx.fillRect(0, 0, W, H);
|
||
}
|
||
ctx.restore();
|
||
|
||
// Moon rim
|
||
ctx.save();
|
||
ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2);
|
||
ctx.strokeStyle = 'rgba(200,180,140,0.25)'; ctx.lineWidth = 1.5; ctx.stroke();
|
||
ctx.restore();
|
||
|
||
// Phase label
|
||
const pl = phaseName(simTime);
|
||
if (pl) {
|
||
ctx.fillStyle = 'rgba(240,217,144,0.75)';
|
||
ctx.font = `bold ${Math.round(R * 0.07)}px monospace`;
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(pl, cx, H - 24);
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// UI helpers
|
||
// ════════════════════════════════════════════════════════════
|
||
function phaseName(t) {
|
||
const ec = currentEclipse;
|
||
if (!ec) return null;
|
||
if (ec.sd_total > 0 && Math.abs(t) <= ec.sd_total) return 'TOTALITÄT · BLUTMOND';
|
||
if (ec.sd_partial > 0 && Math.abs(t) <= ec.sd_partial) return 'PARTIELLE PHASE';
|
||
if (Math.abs(t) <= ec.sd_penum) return 'HALBSCHATTEN-PHASE';
|
||
return t < 0 ? 'VOR DER FINSTERNIS' : 'NACH DER FINSTERNIS';
|
||
}
|
||
|
||
function fmtUTC(peakDate, offsetMin) {
|
||
return new Date(peakDate.getTime() + offsetMin * 60000).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 = d.toISOString().slice(11, 19) + ' UTC';
|
||
const tc = { penumbral: 'eclipse-type-pen', partial: 'eclipse-type-par', total: 'eclipse-type-tot' }[ec.kind] || '';
|
||
const tl = { penumbral: 'Halbschatten-Finsternis', partial: 'Partielle Mondfinsternis', total: 'Totale Mondfinsternis' }[ec.kind] || ec.kind;
|
||
|
||
const mag = ec.umbral_magnitude !== undefined
|
||
? ec.umbral_magnitude.toFixed(3)
|
||
: (ec.penumbral_magnitude !== undefined ? ec.penumbral_magnitude.toFixed(3) + ' (Hb)' : '—');
|
||
|
||
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>Halbschatten</td><td>${Math.round(ec.sd_penum * 2)} Min.</td></tr>
|
||
${ec.sd_partial > 0 ? `<tr><td>Partielle Phase</td><td>${Math.round(ec.sd_partial * 2)} Min.</td></tr>` : ''}
|
||
${ec.sd_total > 0 ? `<tr><td>Totalität</td><td>${Math.round(ec.sd_total * 2)} Min.</td></tr>` : ''}
|
||
<tr><td>Magnitude</td><td>${mag}</td></tr>
|
||
</table>
|
||
<div class="eclipse-info-phase" id="eclipsePhaseLabel">—</div>
|
||
`;
|
||
}
|
||
|
||
function updateInfoPhase(sdw) {
|
||
const el = document.getElementById('eclipsePhaseLabel');
|
||
if (!el) return;
|
||
const name = phaseName(simTime) || '—';
|
||
const depth = sdw.inUmbra > 0.01
|
||
? ` · ${Math.round(sdw.inUmbra * 100)} % im Kernschatten`
|
||
: sdw.inPenumbra > 0.01
|
||
? ` · ${Math.round(sdw.inPenumbra * 100)} % im Halbschatten`
|
||
: '';
|
||
el.textContent = name + depth;
|
||
}
|
||
|
||
function renderContactTable() {
|
||
const ec = currentEclipse;
|
||
const pk = ec.peak.date;
|
||
const rows = [
|
||
['P1', -ec.sd_penum, 'Eintritt Halbschatten'],
|
||
...(ec.sd_partial > 0 ? [['U1', -ec.sd_partial, 'Eintritt Kernschatten']] : []),
|
||
...(ec.sd_total > 0 ? [['U2', -ec.sd_total, 'Beginn Totalität' ]] : []),
|
||
['MAX', 0, 'Größte Phase'],
|
||
...(ec.sd_total > 0 ? [['U3', ec.sd_total, 'Ende Totalität' ]] : []),
|
||
...(ec.sd_partial > 0 ? [['U4', ec.sd_partial, 'Austritt Kernschatten']] : []),
|
||
['P4', ec.sd_penum, 'Austritt Halbschatten'],
|
||
];
|
||
contactTable.innerHTML = rows.map(([lbl, off, desc]) =>
|
||
`<tr>
|
||
<td style="color:var(--gold);font-weight:700;min-width:2.4rem">${lbl}</td>
|
||
<td style="font-variant-numeric:tabular-nums">${fmtUTC(pk, off)}</td>
|
||
<td style="color:var(--text-dim);font-size:0.78rem">${desc}</td>
|
||
</tr>`
|
||
).join('');
|
||
}
|
||
|
||
function renderTimeline() {
|
||
const ec = currentEclipse;
|
||
const { min: tMin, max: tMax } = timeRange();
|
||
const span = tMax - tMin;
|
||
|
||
// Remove old markers / fills
|
||
timelineBar.querySelectorAll('.eclipse-tl-fill,.eclipse-tl-marker').forEach(el => el.remove());
|
||
|
||
function pct(t) { return ((t - tMin) / span * 100).toFixed(2) + '%'; }
|
||
|
||
// Penumbra fill
|
||
fillBar(pct(-ec.sd_penum), pct(ec.sd_penum), 'rgba(120,140,200,0.15)');
|
||
|
||
// Umbra fill
|
||
if (ec.sd_partial > 0) fillBar(pct(-ec.sd_partial), pct(ec.sd_partial), 'rgba(200,90,15,0.22)');
|
||
|
||
// Totality fill
|
||
if (ec.sd_total > 0) fillBar(pct(-ec.sd_total), pct(ec.sd_total), 'rgba(210,30,0,0.32)');
|
||
|
||
// Markers
|
||
const markers = [
|
||
{ t: -ec.sd_penum, lbl: 'P1', cls: 'pen' },
|
||
...(ec.sd_partial > 0 ? [{ t: -ec.sd_partial, lbl: 'U1', cls: 'umb' }] : []),
|
||
...(ec.sd_total > 0 ? [{ t: -ec.sd_total, lbl: 'U2', cls: 'tot' }] : []),
|
||
{ t: 0, lbl: 'MAX', cls: 'max' },
|
||
...(ec.sd_total > 0 ? [{ t: ec.sd_total, lbl: 'U3', cls: 'tot' }] : []),
|
||
...(ec.sd_partial > 0 ? [{ t: ec.sd_partial, lbl: 'U4', cls: 'umb' }] : []),
|
||
{ t: ec.sd_penum, lbl: 'P4', cls: 'pen' },
|
||
];
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// Interaction
|
||
// ════════════════════════════════════════════════════════════
|
||
function setupInteraction() {
|
||
// Camera orbit
|
||
canvas3d.addEventListener('mousedown', e => { isDragging = true; dragX = e.clientX; dragY = e.clientY; });
|
||
window.addEventListener('mousemove', e => {
|
||
if (!isDragging) return;
|
||
camTheta -= (e.clientX - dragX) * 0.005;
|
||
camPhi = Math.max(0.12, Math.min(Math.PI - 0.12, camPhi + (e.clientY - dragY) * 0.005));
|
||
dragX = e.clientX; dragY = e.clientY;
|
||
updateCamera();
|
||
});
|
||
window.addEventListener('mouseup', () => isDragging = false);
|
||
canvas3d.addEventListener('wheel', e => {
|
||
e.preventDefault();
|
||
camRadius = Math.max(4, Math.min(55, camRadius + e.deltaY * 0.025));
|
||
updateCamera();
|
||
}, { passive: false });
|
||
|
||
// Touch orbit
|
||
let tx = 0, ty = 0;
|
||
canvas3d.addEventListener('touchstart', e => { tx = e.touches[0].clientX; ty = e.touches[0].clientY; }, { passive: true });
|
||
canvas3d.addEventListener('touchmove', e => {
|
||
e.preventDefault();
|
||
camTheta -= (e.touches[0].clientX - tx) * 0.005;
|
||
camPhi = Math.max(0.12, Math.min(Math.PI - 0.12, camPhi + (e.touches[0].clientY - ty) * 0.005));
|
||
tx = e.touches[0].clientX; ty = e.touches[0].clientY;
|
||
updateCamera();
|
||
}, { passive: false });
|
||
|
||
// Timeline click / drag
|
||
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);
|
||
|
||
// Play
|
||
btnPlay.addEventListener('click', () => {
|
||
playing = !playing;
|
||
btnPlay.textContent = playing ? '⏸ Pause' : '▶ Play';
|
||
if (playing) lastRAF = performance.now();
|
||
});
|
||
|
||
// Rewind / End
|
||
document.getElementById('eclipseBtnRewind').addEventListener('click', () => {
|
||
simTime = timeRange().min; updateSim();
|
||
});
|
||
document.getElementById('eclipseBtnEnd').addEventListener('click', () => {
|
||
simTime = timeRange().max; updateSim();
|
||
});
|
||
|
||
// Speed
|
||
speedSel.addEventListener('change', () => { playSpeed = parseInt(speedSel.value); });
|
||
|
||
// View toggle
|
||
btnGeoView.addEventListener('click', () => setView('geo'));
|
||
btnMoonView.addEventListener('click', () => setView('moon'));
|
||
|
||
document.getElementById('btnToggleShadows').addEventListener('click', function() {
|
||
const visible = umbraMesh && umbraMesh.visible;
|
||
if (umbraMesh) umbraMesh.visible = !visible;
|
||
if (penumbraMesh) penumbraMesh.visible = !visible;
|
||
if (moonTrailLine) moonTrailLine.visible = !visible;
|
||
this.classList.toggle('is-active', !visible);
|
||
});
|
||
|
||
// Search
|
||
document.getElementById('eclipseSearchBtn').addEventListener('click', searchEclipses);
|
||
|
||
|
||
}
|
||
|
||
function setView(mode) {
|
||
viewMode = mode;
|
||
btnGeoView.classList.toggle('is-active', mode === 'geo');
|
||
btnMoonView.classList.toggle('is-active', mode === 'moon');
|
||
// Both views use the 3D canvas – Moon view just switches to the close-up camera
|
||
canvas3d.style.display = 'block';
|
||
canvas2d.style.display = 'none';
|
||
if (mode === 'moon' && moonMesh) moonMesh.rotation.y = Math.PI;
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// Boot
|
||
// ════════════════════════════════════════════════════════════
|
||
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'; ?>
|