first commit

This commit is contained in:
2026-03-31 09:44:27 +02:00
commit 8dd4253ee3
123 changed files with 369814 additions and 0 deletions
+990
View File
@@ -0,0 +1,990 @@
<?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'; ?>