Files
skyview.astronomiemuseum.de/public/moonphase_old.php
T
2026-03-31 09:44:27 +02:00

1156 lines
38 KiB
PHP

<?php
declare(strict_types=1);
ini_set('display_errors', '1');
error_reporting(E_ALL);
session_start();
$loggedIn = isset($_SESSION['user_id']);
$pageTitle = 'Mondphase - AstroTools';
$bodyClass = 'moonphase-page';
?>
<?php require __DIR__ . '/header.php'; ?>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;1,300;1,400&family=Space+Mono:wght@400;700&family=Orbitron:wght@400;600;700;900&display=swap" rel="stylesheet">
<script src="js/astronomy.browser.min.js"></script>
<div class="page moon-shell">
<section class="moon-panel">
<div class="moon-layout">
<div class="moon-side">
<p class="moon-subtitle">
Interaktive Anzeige der Mondphase f&uuml;r ein frei w&auml;hlbares Datum und eine Uhrzeit.
Der Mond wird direkt aus Datum und Uhrzeit berechnet und zentral visualisiert.
</p>
<div class="moon-caption">
<div class="moon-phase-name" id="phaseName">Lade Mondphase...</div>
<div class="moon-actions">
<button type="button" class="date-shift-btn moon-reset-btn" id="resetViewBtn" title="Grafik reset">Grafik reset</button>
<button
type="button"
class="date-shift-btn moon-action-btn"
id="exportMoonBtn"
onclick="if(window.openMoonExportDialog){window.openMoonExportDialog();}else{const modal=document.getElementById('moonExportModal');if(modal){modal.hidden=false;modal.classList.add('is-open');modal.setAttribute('aria-hidden','false');document.body.classList.add('has-modal');}}"
>PNG export</button>
</div>
</div>
<div class="moon-meta">
<div class="moon-stat info-card">
<span class="moon-stat-label">Beleuchtung</span>
<span class="moon-stat-value" id="illuminationValue">-</span>
</div>
<div class="moon-stat info-card">
<span class="moon-stat-label">Entfernung</span>
<span class="moon-stat-value" id="distanceValue">-</span>
</div>
<div class="moon-stat info-card">
<span class="moon-stat-label">Mondalter</span>
<span class="moon-stat-value" id="ageValue">-</span>
</div>
<div class="moon-stat info-card">
<span class="moon-stat-label">Ausgew&auml;hlt</span>
<span class="moon-stat-value" id="selectedValue">-</span>
</div>
<div class="moon-stat info-card">
<span class="moon-stat-label">Libration</span>
<span class="moon-stat-value" id="librationValue">-</span>
</div>
</div>
<div class="moon-controls">
<div class="control-card">
<div class="control-head">
<label class="control-label" for="datePicker">Datum</label>
<span class="control-value" id="dateLabel">-</span>
</div>
<div class="date-row">
<button type="button" class="date-shift-btn btn btn-today" id="prevDayBtn">&#8722;1 Tag</button>
<input type="date" id="datePicker">
<button type="button" class="date-shift-btn btn btn-today" id="nextDayBtn">+1 Tag</button>
<button type="button" class="date-shift-btn btn btn-today" id="nowBtn">Jetzt</button>
</div>
</div>
<div class="control-card">
<div class="control-head">
<label class="control-label" for="timeSlider">Uhrzeit</label>
<span class="control-value" id="timeLabel">00:00</span>
</div>
<input type="range" id="timeSlider" min="0" max="1435" step="5" value="0">
<div class="time-scale">
<span>00:00</span>
<span>12:00</span>
<span>23:55</span>
</div>
<div class="time-actions" id="playbackSpeedGroup">
<button type="button" class="date-shift-btn moon-action-btn" id="playPauseBtn">Play</button>
<button type="button" class="date-shift-btn moon-speed-btn is-active" data-speed="1">1x</button>
<button type="button" class="date-shift-btn moon-speed-btn" data-speed="2">2x</button>
<button type="button" class="date-shift-btn moon-speed-btn" data-speed="5">5x</button>
<button type="button" class="date-shift-btn moon-speed-btn" data-speed="10">10x</button>
</div>
</div>
</div>
<div class="moon-note info-card">
Hinweis: Die Anzeige zeigt die geozentrische Mondphase als anschauliche Visualisierung
und eignet sich sehr gut f&uuml;r Museums- und Bildungszwecke.
</div>
</div>
<div class="moon-visual">
<div class="moon-stage">
<canvas id="moonCanvas" width="800" height="800" aria-label="Mondphasenanzeige"></canvas>
</div>
<div class="moon-loading" id="moonLoading" aria-live="polite">
<div class="moon-loading-bar">
<div class="moon-loading-fill" id="moonLoadingFill"></div>
</div>
<div class="moon-loading-text" id="moonLoadingText">Lade Mondtexturen... 0 %</div>
</div>
</div>
</div>
</section>
</div>
<div class="moon-export-modal" id="moonExportModal" aria-hidden="true" hidden>
<div class="moon-export-dialog" role="dialog" aria-modal="true" aria-labelledby="moonExportTitle">
<div class="moon-export-head">
<div class="moon-export-title-wrap">
<div class="moon-export-kicker">BILD-EXPORT</div>
<h2 class="moon-export-title" id="moonExportTitle">Mondansicht als PNG speichern</h2>
</div>
<button type="button" class="moon-export-close" id="moonExportCloseBtn" aria-label="Exportfenster schliessen" onclick="if(window.closeMoonExportDialog){window.closeMoonExportDialog();}else{const modal=document.getElementById('moonExportModal');if(modal){modal.classList.remove('is-open');modal.setAttribute('aria-hidden','true');modal.hidden=true;document.body.classList.remove('has-modal');}}">&times;</button>
</div>
<p class="moon-export-copy">
Exportiert wird die aktuelle Mondansicht mit dem derzeitigen Zoom, Pan und der eingestellten Mondphase.
</p>
<div class="moon-export-fields">
<label class="moon-export-field">
<span class="moon-export-label">Bildgroesse</span>
<select class="moon-export-select" id="moonExportSizeSelect">
<option value="1024">1024 x 1024 px</option>
<option value="2048" selected>2048 x 2048 px</option>
<option value="3072">3072 x 3072 px</option>
<option value="4096">4096 x 4096 px</option>
<option value="custom">Eigene Groesse</option>
</select>
</label>
<label class="moon-export-field is-hidden" id="moonExportCustomField">
<span class="moon-export-label">Eigene Pixelgroesse</span>
<input
class="moon-export-input"
id="moonExportCustomInput"
type="number"
min="512"
max="4096"
step="1"
value="2048"
inputmode="numeric"
>
</label>
<label class="moon-export-toggle">
<input type="checkbox" id="moonExportTransparentCheckbox">
<span>Transparenter Hintergrund</span>
</label>
</div>
<div class="moon-export-note">
Format: PNG, quadratisch, Hintergrund optional transparent.
</div>
<div class="moon-export-status" id="moonExportStatus" aria-live="polite"></div>
<div class="moon-export-actions">
<button type="button" class="date-shift-btn" id="moonExportCancelBtn" onclick="if(window.closeMoonExportDialog){window.closeMoonExportDialog();}else{const modal=document.getElementById('moonExportModal');if(modal){modal.classList.remove('is-open');modal.setAttribute('aria-hidden','true');modal.hidden=true;document.body.classList.remove('has-modal');}}">Abbrechen</button>
<button type="button" class="date-shift-btn moon-action-btn" id="moonExportConfirmBtn">PNG herunterladen</button>
</div>
</div>
</div>
<script type="module">
import * as THREE from './js/three.module.min.js';
const AstronomyEngine = window.Astronomy;
const moonCanvas = document.getElementById('moonCanvas');
const datePicker = document.getElementById('datePicker');
const timeSlider = document.getElementById('timeSlider');
const phaseName = document.getElementById('phaseName');
const illuminationValue = document.getElementById('illuminationValue');
const distanceValue = document.getElementById('distanceValue');
const ageValue = document.getElementById('ageValue');
const selectedValue = document.getElementById('selectedValue');
const librationValue = document.getElementById('librationValue');
const timeLabel = document.getElementById('timeLabel');
const dateLabel = document.getElementById('dateLabel');
const moonLoading = document.getElementById('moonLoading');
const moonLoadingFill = document.getElementById('moonLoadingFill');
const moonLoadingText = document.getElementById('moonLoadingText');
const prevDayBtn = document.getElementById('prevDayBtn');
const nextDayBtn = document.getElementById('nextDayBtn');
const nowBtn = document.getElementById('nowBtn');
const resetViewBtn = document.getElementById('resetViewBtn');
const playPauseBtn = document.getElementById('playPauseBtn');
const playbackSpeedGroup = document.getElementById('playbackSpeedGroup');
const exportMoonBtn = document.getElementById('exportMoonBtn');
const moonExportModal = document.getElementById('moonExportModal');
const moonExportCloseBtn = document.getElementById('moonExportCloseBtn');
const moonExportSizeSelect = document.getElementById('moonExportSizeSelect');
const moonExportCustomField = document.getElementById('moonExportCustomField');
const moonExportCustomInput = document.getElementById('moonExportCustomInput');
const moonExportTransparentCheckbox = document.getElementById('moonExportTransparentCheckbox');
const moonExportStatus = document.getElementById('moonExportStatus');
const moonExportCancelBtn = document.getElementById('moonExportCancelBtn');
const moonExportConfirmBtn = document.getElementById('moonExportConfirmBtn');
let moonSceneState = null;
let moonZoom = 1;
let moonPanX = 0;
let moonPanY = 0;
let isDraggingMoon = false;
let moonDragPointerId = null;
let moonDragStartX = 0;
let moonDragStartY = 0;
let moonDragStartPanX = 0;
let moonDragStartPanY = 0;
let moonPlaybackTimer = null;
let moonPlaybackSpeed = 1;
let isMoonExportRunning = false;
let moonExportRestoreFocus = null;
const SYNODIC_MONTH = 29.530588853;
const KNOWN_NEW_MOON_JD = 2451550.1;
const MOON_AXIS_INCLINATION = 1.54242;
const MOON_TEXTURE_PATH = 'images/moons/moon_8192.jpg';
const MOON_DISPLACEMENT_MAP_PATH = 'images/moons/moon_displacement_16bit.jpg';
const MOON_TEXTURE_BASE_ROTATION_Y = -Math.PI / 2;
const MOON_DISPLACEMENT_SCALE = 0.0095;
const MOON_DISPLACEMENT_BIAS = -0.1;
const MOON_BUMP_SCALE = 2.0;
const MOON_CAMERA_BASE_Z = 6.8;
const MOON_ZOOM_MIN = 0.8;
const MOON_ZOOM_MAX = 3.5;
const MOON_PAN_LIMIT = 1.8;
const MOON_PLAYBACK_STEP_MINUTES = 5;
const MOON_PLAYBACK_INTERVAL_MS = 50;
const KM_PER_AU = 149597870.7;
const MOON_TEXTURE_ASSET_COUNT = 2;
const MOON_EXPORT_SIZE_MIN = 512;
const MOON_EXPORT_SIZE_MAX = 4096;
const phaseLabels = [
'Neumond',
'Erstes Viertel / zunehmende Sichel',
'Zunehmender Halbmond',
'Zweites Viertel',
'Vollmond',
'Drittes Viertel',
'Abnehmender Halbmond',
'Letztes Viertel / abnehmende Sichel'
];
function toJulianDate(date) {
return date.getTime() / 86400000 + 2440587.5;
}
function normalize(value, max) {
return ((value % max) + max) % max;
}
function toRadians(degrees) {
return degrees * Math.PI / 180;
}
function toDegrees(radians) {
return radians * 180 / Math.PI;
}
function normalizeDegrees(degrees) {
return normalize(degrees, 360);
}
function normalizeSignedDegrees(degrees) {
return normalize(degrees + 180, 360) - 180;
}
function padNumber(value) {
return String(value).padStart(2, '0');
}
function formatMinutes(totalMinutes) {
const hours = Math.floor(totalMinutes / 60).toString().padStart(2, '0');
const minutes = (totalMinutes % 60).toString().padStart(2, '0');
return `${hours}:${minutes}`;
}
function formatDateLong(date) {
return date.toLocaleDateString('de-DE', {
weekday: 'long',
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
function formatDateTime(date) {
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function formatBadgeDate(date) {
return formatDateTime(date).replace(', ', ' · ');
}
function canvasToBlob(canvas, type = 'image/png') {
return new Promise((resolve, reject) => {
canvas.toBlob(blob => {
if (blob) {
resolve(blob);
return;
}
reject(new Error('Canvas konnte nicht als Bild exportiert werden.'));
}, type);
});
}
function setMoonLoadingProgress(progress, message) {
const safeProgress = THREE.MathUtils.clamp(progress, 0, 1);
moonLoadingFill.style.width = `${Math.round(safeProgress * 100)}%`;
moonLoadingText.textContent = message || `Lade Mondtexturen... ${Math.round(safeProgress * 100)} %`;
}
function hideMoonLoading() {
moonLoading.classList.add('is-hidden');
}
function getMoonZoomFactor() {
return THREE.MathUtils.clamp(moonZoom, MOON_ZOOM_MIN, MOON_ZOOM_MAX);
}
function applyMoonZoom() {
if (!moonSceneState) {
return;
}
moonSceneState.camera.position.z = MOON_CAMERA_BASE_Z / getMoonZoomFactor();
moonSceneState.camera.position.x = moonPanX;
moonSceneState.camera.position.y = moonPanY;
moonSceneState.camera.updateProjectionMatrix();
}
function clampMoonPan(value) {
return THREE.MathUtils.clamp(value, -MOON_PAN_LIMIT, MOON_PAN_LIMIT);
}
function resetMoonView() {
moonZoom = 1;
moonPanX = 0;
moonPanY = 0;
updateMoon();
}
function syncDatePickerWithDate(date) {
const timezoneOffset = date.getTimezoneOffset() * 60000;
datePicker.value = new Date(date.getTime() - timezoneOffset).toISOString().slice(0, 10);
}
function setSelectedDateTime(date) {
syncDatePickerWithDate(date);
const minutes = date.getHours() * 60 + date.getMinutes();
const roundedMinutes = Math.round(minutes / 5) * 5;
timeSlider.value = String(Math.min(1435, roundedMinutes));
}
function updatePlaybackButton() {
playPauseBtn.textContent = moonPlaybackTimer ? 'Stop' : 'Play';
}
function updateMoonExportCustomVisibility() {
const isCustomSize = moonExportSizeSelect.value === 'custom';
moonExportCustomField.classList.toggle('is-hidden', !isCustomSize);
}
function updateMoonExportStatus(message, state = '') {
moonExportStatus.textContent = message;
moonExportStatus.dataset.state = state;
}
function setMoonExportBusy(isBusy) {
isMoonExportRunning = isBusy;
[moonExportCloseBtn, moonExportSizeSelect, moonExportCustomInput, moonExportTransparentCheckbox, moonExportCancelBtn, moonExportConfirmBtn]
.forEach(element => {
element.disabled = isBusy;
});
moonExportConfirmBtn.textContent = isBusy ? 'PNG wird erstellt...' : 'PNG herunterladen';
}
function getMoonExportSize() {
const rawValue = moonExportSizeSelect.value === 'custom'
? Number(moonExportCustomInput.value)
: Number(moonExportSizeSelect.value);
if (!Number.isFinite(rawValue)) {
return null;
}
const size = THREE.MathUtils.clamp(Math.round(rawValue), MOON_EXPORT_SIZE_MIN, MOON_EXPORT_SIZE_MAX);
if (moonExportSizeSelect.value === 'custom') {
moonExportCustomInput.value = String(size);
}
return size;
}
function useTransparentMoonExport() {
return moonExportTransparentCheckbox.checked;
}
function openMoonExportDialog() {
stopMoonPlayback();
moonExportRestoreFocus = document.activeElement;
updateMoonExportCustomVisibility();
updateMoonExportStatus('');
setMoonExportBusy(false);
moonExportModal.hidden = false;
moonExportModal.classList.add('is-open');
moonExportModal.setAttribute('aria-hidden', 'false');
document.body.classList.add('has-modal');
window.requestAnimationFrame(() => {
moonExportSizeSelect.focus();
});
}
function closeMoonExportDialog() {
if (isMoonExportRunning || !moonExportModal.classList.contains('is-open')) {
return;
}
moonExportModal.classList.remove('is-open');
moonExportModal.setAttribute('aria-hidden', 'true');
moonExportModal.hidden = true;
document.body.classList.remove('has-modal');
updateMoonExportStatus('');
if (moonExportRestoreFocus && typeof moonExportRestoreFocus.focus === 'function') {
moonExportRestoreFocus.focus();
}
moonExportRestoreFocus = null;
}
function stopMoonPlayback() {
if (!moonPlaybackTimer) {
return;
}
window.clearInterval(moonPlaybackTimer);
moonPlaybackTimer = null;
updatePlaybackButton();
}
function advanceMoonTime(stepMinutes = MOON_PLAYBACK_STEP_MINUTES) {
const next = buildSelectedDate();
next.setMinutes(next.getMinutes() + stepMinutes);
setSelectedDateTime(next);
updateMoon();
}
function toggleMoonPlayback() {
if (moonPlaybackTimer) {
stopMoonPlayback();
return;
}
moonPlaybackTimer = window.setInterval(() => {
advanceMoonTime(MOON_PLAYBACK_STEP_MINUTES * moonPlaybackSpeed);
}, MOON_PLAYBACK_INTERVAL_MS);
updatePlaybackButton();
}
function getPhaseLabel(age) {
const segment = SYNODIC_MONTH / 8;
const index = Math.floor((age + segment / 2) / segment) % 8;
return phaseLabels[index];
}
function calculateMoonPositionAngle(date) {
const moonVector = AstronomyEngine.GeoVector('Moon', date, true);
const moonEquator = AstronomyEngine.EquatorFromVector(moonVector);
const axis = AstronomyEngine.RotationAxis('Moon', date);
const poleEquator = AstronomyEngine.EquatorFromVector(axis.north);
const deltaRa = toRadians((poleEquator.ra - moonEquator.ra) * 15);
const moonDec = toRadians(moonEquator.dec);
const poleDec = toRadians(poleEquator.dec);
return toDegrees(Math.atan2(
Math.cos(poleDec) * Math.sin(deltaRa),
Math.sin(poleDec) * Math.cos(moonDec) -
Math.cos(poleDec) * Math.sin(moonDec) * Math.cos(deltaRa)
));
}
function toThreeVector(vector) {
return new THREE.Vector3(vector.x, vector.y, vector.z);
}
function calculateMoonAxisLatitudes(date) {
const axis = AstronomyEngine.RotationAxis('Moon', date);
const moonVector = AstronomyEngine.GeoVector('Moon', date, true);
const sunVector = AstronomyEngine.GeoVector('Sun', date, true);
const north = toThreeVector(axis.north).normalize();
const earthFromMoon = toThreeVector(moonVector).multiplyScalar(-1).normalize();
const sunFromMoon = toThreeVector(sunVector).sub(toThreeVector(moonVector)).normalize();
return {
subearthLatitude: toDegrees(Math.asin(THREE.MathUtils.clamp(earthFromMoon.dot(north), -1, 1))),
subsolarLatitude: toDegrees(Math.asin(THREE.MathUtils.clamp(sunFromMoon.dot(north), -1, 1)))
};
}
function calculateLibration(date) {
const libration = AstronomyEngine.Libration(date);
const axisLatitudes = calculateMoonAxisLatitudes(date);
const positionAngle = calculateMoonPositionAngle(date);
return {
longitude: -normalizeSignedDegrees(libration.elon),
latitude: libration.elat,
subsolarLatitude: axisLatitudes.subsolarLatitude,
positionAngle,
distKm: libration.dist_km
};
}
function formatSignedAngle(value, negativeLabel, positiveLabel) {
const direction = value < 0 ? negativeLabel : positiveLabel;
return `${Math.abs(value).toFixed(1).replace('.', ',')}° ${direction}`;
}
function formatLibration(libration) {
const eastWest = formatSignedAngle(libration.longitude, 'W', 'E');
const northSouth = formatSignedAngle(libration.latitude, 'S', 'N');
return `${eastWest} / ${northSouth}`;
}
function formatMoonDistance(distanceKm) {
return `${Math.round(distanceKm).toLocaleString('de-DE')} km`;
}
function calculateMoonPhase(date) {
const illumination = AstronomyEngine.Illumination('Moon', date);
const elongation = normalizeDegrees(AstronomyEngine.MoonPhase(date));
const phase = elongation / 360;
const previousNewMoon = AstronomyEngine.SearchMoonPhase(0, date, -35);
const age = previousNewMoon
? (date.getTime() - previousNewMoon.date.getTime()) / 86400000
: phase * SYNODIC_MONTH;
return {
age,
phase,
illumination: illumination.phase_fraction,
waxing: elongation <= 180,
label: getPhaseLabel(age)
};
}
function ensureMoonScene() {
if (moonSceneState || typeof THREE === 'undefined') {
return moonSceneState;
}
let loadedMoonAssets = 0;
let moonTextureFailed = false;
const handleMoonAssetLoaded = () => {
loadedMoonAssets += 1;
setMoonLoadingProgress(loadedMoonAssets / MOON_TEXTURE_ASSET_COUNT);
if (loadedMoonAssets >= MOON_TEXTURE_ASSET_COUNT && !moonTextureFailed) {
moonLoadingText.textContent = 'Mondtexturen geladen';
window.setTimeout(hideMoonLoading, 350);
}
};
const handleMoonAssetError = () => {
moonTextureFailed = true;
moonLoading.classList.remove('is-hidden');
moonLoading.classList.add('is-error');
moonLoadingFill.style.width = '100%';
moonLoadingText.textContent = 'Texturen konnten nicht vollstaendig geladen werden';
};
moonLoading.classList.remove('is-hidden', 'is-error');
setMoonLoadingProgress(0);
const renderer = new THREE.WebGLRenderer({
canvas: moonCanvas,
antialias: true,
alpha: false,
powerPreference: 'high-performance'
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setClearColor(0x010102, 1);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 3));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(28, 1, 0.1, 100);
camera.position.set(0, 0, MOON_CAMERA_BASE_Z);
const ambientLight = new THREE.AmbientLight(0x1b2230, 0.004);
scene.add(ambientLight);
const sunLight = new THREE.DirectionalLight(0xffffff, 2.9);
sunLight.castShadow = true;
sunLight.shadow.mapSize.width = 4096;
sunLight.shadow.mapSize.height = 4096;
sunLight.shadow.bias = -0.00015;
sunLight.shadow.normalBias = 0.02;
sunLight.shadow.camera.near = 1;
sunLight.shadow.camera.far = 20;
sunLight.shadow.camera.left = -3;
sunLight.shadow.camera.right = 3;
sunLight.shadow.camera.top = 3;
sunLight.shadow.camera.bottom = -3;
scene.add(sunLight);
const rimLight = new THREE.DirectionalLight(0x89a8ff, 0.015);
rimLight.position.set(-2.5, 1.2, 3.2);
scene.add(rimLight);
const textureLoader = new THREE.TextureLoader();
const texture = textureLoader.load(
MOON_TEXTURE_PATH,
() => {
handleMoonAssetLoaded();
resizeMoonScene();
renderer.render(scene, camera);
},
undefined,
handleMoonAssetError
);
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = renderer.capabilities.getMaxAnisotropy();
const displacementMap = textureLoader.load(
MOON_DISPLACEMENT_MAP_PATH,
() => {
handleMoonAssetLoaded();
resizeMoonScene();
renderer.render(scene, camera);
},
undefined,
handleMoonAssetError
);
displacementMap.colorSpace = THREE.NoColorSpace;
displacementMap.anisotropy = renderer.capabilities.getMaxAnisotropy();
const geometry = new THREE.SphereGeometry(1.6, 256, 256);
const material = new THREE.MeshStandardMaterial({
map: texture,
displacementMap,
displacementScale: MOON_DISPLACEMENT_SCALE,
displacementBias: MOON_DISPLACEMENT_BIAS,
bumpMap: displacementMap,
bumpScale: MOON_BUMP_SCALE,
roughness: 1,
metalness: 0
});
const moonMesh = new THREE.Mesh(geometry, material);
moonMesh.castShadow = true;
moonMesh.receiveShadow = true;
moonMesh.rotation.y = MOON_TEXTURE_BASE_ROTATION_Y;
scene.add(moonMesh);
moonSceneState = {
renderer,
scene,
camera,
moonMesh,
sunLight,
ambientLight,
rimLight
};
resizeMoonScene();
applyMoonZoom();
return moonSceneState;
}
function resizeMoonScene() {
if (!moonSceneState) {
return;
}
const rect = moonCanvas.getBoundingClientRect();
const size = Math.round(Math.max(360, rect.width || 900));
moonSceneState.renderer.setSize(size, size, false);
moonSceneState.camera.aspect = 1;
moonSceneState.camera.updateProjectionMatrix();
}
function renderMoon3D(phaseData, libration) {
const state = ensureMoonScene();
if (!state) {
return false;
}
applyMoonZoom();
applyMoon3DRenderState(state, phaseData, libration);
state.renderer.render(state.scene, state.camera);
return true;
}
function applyMoon3DRenderState(state, phaseData, libration) {
state.moonMesh.rotation.order = 'ZYX';
state.moonMesh.rotation.y = MOON_TEXTURE_BASE_ROTATION_Y + toRadians(libration.longitude);
state.moonMesh.rotation.x = toRadians(libration.latitude);
state.moonMesh.rotation.z = toRadians(libration.positionAngle);
const viewAlignedSunZ = phaseData.illumination * 2 - 1;
const northOnScreen = new THREE.Vector3(0, 1, 0).applyEuler(state.moonMesh.rotation).normalize();
const planeNorth = new THREE.Vector2(northOnScreen.x, northOnScreen.y);
const planeNorthLength = planeNorth.length();
const horizontalMagnitude = Math.sqrt(Math.max(0, 1 - viewAlignedSunZ * viewAlignedSunZ));
const subsolarNorthDot = Math.sin(toRadians(libration.subsolarLatitude));
let xComponent;
let yComponent;
if (planeNorthLength < 1e-6 || horizontalMagnitude < 1e-6) {
xComponent = phaseData.waxing ? horizontalMagnitude : -horizontalMagnitude;
yComponent = 0;
} else {
const northUnit = planeNorth.normalize();
const eastUnit = new THREE.Vector2(-northUnit.y, northUnit.x);
const targetAlongNorth = THREE.MathUtils.clamp(
(subsolarNorthDot - northOnScreen.z * viewAlignedSunZ) / planeNorthLength,
-horizontalMagnitude,
horizontalMagnitude
);
const sideMagnitude = Math.sqrt(Math.max(0, horizontalMagnitude * horizontalMagnitude - targetAlongNorth * targetAlongNorth));
const sideSign = phaseData.waxing ? -1 : 1;
const screenVector =
northUnit.multiplyScalar(targetAlongNorth)
.add(eastUnit.multiplyScalar(sideSign * sideMagnitude));
xComponent = screenVector.x;
yComponent = screenVector.y;
}
state.sunLight.position.set(xComponent * 8, yComponent * 8, viewAlignedSunZ * 8);
state.ambientLight.intensity = 0.004;
state.rimLight.intensity = 0.006;
}
function drawMoonTexture(ctx, cx, cy, radius) {
const patches = [
[-0.28, -0.22, 0.14, 0.10],
[0.22, -0.14, 0.12, 0.09],
[-0.12, 0.12, 0.18, 0.12],
[0.20, 0.22, 0.10, 0.08],
[-0.32, 0.28, 0.08, 0.06]
];
ctx.save();
ctx.globalAlpha = 0.15;
for (const patch of patches) {
const [px, py, rx, ry] = patch;
const gradient = ctx.createRadialGradient(
cx + px * radius,
cy + py * radius,
radius * 0.02,
cx + px * radius,
cy + py * radius,
radius * Math.max(rx, ry)
);
gradient.addColorStop(0, 'rgba(90, 100, 110, 0.75)');
gradient.addColorStop(1, 'rgba(90, 100, 110, 0)');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.ellipse(cx + px * radius, cy + py * radius, radius * rx, radius * ry, 0, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
function renderMoon2DToCanvas(targetCanvas, phaseData, options = {}) {
const { includeBackground = false, transparentBackground = false } = options;
const ctx = targetCanvas.getContext('2d');
const w = targetCanvas.width;
const h = targetCanvas.height;
const minDimension = Math.min(w, h);
const panScale = minDimension * 0.18;
const cx = w / 2 + moonPanX * panScale;
const cy = h / 2 - moonPanY * panScale;
const radius = Math.min(minDimension * 0.275, minDimension * 0.1875 * getMoonZoomFactor());
const q = Math.cos(phaseData.phase * Math.PI * 2);
ctx.clearRect(0, 0, w, h);
if (includeBackground) {
if (transparentBackground) {
ctx.clearRect(0, 0, w, h);
} else {
ctx.fillStyle = '#010102';
ctx.fillRect(0, 0, w, h);
}
}
const glow = ctx.createRadialGradient(cx, cy, radius * 0.55, cx, cy, radius * 1.55);
glow.addColorStop(0, 'rgba(255, 245, 210, 0.12)');
glow.addColorStop(1, 'rgba(255, 245, 210, 0)');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(cx, cy, radius * 1.6, 0, Math.PI * 2);
ctx.fill();
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.clip();
const darkBase = ctx.createRadialGradient(cx - radius * 0.22, cy - radius * 0.28, radius * 0.08, cx, cy, radius);
darkBase.addColorStop(0, '#4f5464');
darkBase.addColorStop(0.58, '#1e2330');
darkBase.addColorStop(1, '#080b14');
ctx.fillStyle = darkBase;
ctx.fillRect(cx - radius, cy - radius, radius * 2, radius * 2);
ctx.strokeStyle = 'rgba(240, 245, 255, 0.9)';
ctx.lineWidth = 1.3;
for (let y = -radius; y <= radius; y += 1) {
const xMax = Math.sqrt(radius * radius - y * y);
let x1;
let x2;
if (phaseData.waxing) {
x1 = q * xMax;
x2 = xMax;
} else {
x1 = -xMax;
x2 = -q * xMax;
}
if (x2 > x1) {
ctx.beginPath();
ctx.moveTo(cx + x1, cy + y);
ctx.lineTo(cx + x2, cy + y);
ctx.stroke();
}
}
const lightOverlay = ctx.createRadialGradient(cx - radius * 0.35, cy - radius * 0.36, radius * 0.05, cx, cy, radius * 1.1);
lightOverlay.addColorStop(0, 'rgba(255, 255, 255, 0.92)');
lightOverlay.addColorStop(0.58, 'rgba(230, 232, 236, 0.7)');
lightOverlay.addColorStop(1, 'rgba(190, 198, 208, 0.15)');
ctx.globalCompositeOperation = 'screen';
ctx.fillStyle = lightOverlay;
ctx.fillRect(cx - radius, cy - radius, radius * 2, radius * 2);
ctx.globalCompositeOperation = 'source-over';
drawMoonTexture(ctx, cx, cy, radius);
ctx.restore();
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)';
ctx.lineWidth = 2;
ctx.stroke();
}
function renderMoon2D(phaseData) {
renderMoon2DToCanvas(moonCanvas, phaseData);
}
async function renderMoon2DToBlob(phaseData, size, transparentBackground = false) {
const exportCanvas = document.createElement('canvas');
exportCanvas.width = size;
exportCanvas.height = size;
renderMoon2DToCanvas(exportCanvas, phaseData, {
includeBackground: true,
transparentBackground
});
return canvasToBlob(exportCanvas, 'image/png');
}
async function renderMoon3DToBlob(phaseData, libration, size, transparentBackground = false) {
const state = ensureMoonScene();
if (!state) {
return null;
}
applyMoonZoom();
applyMoon3DRenderState(state, phaseData, libration);
const exportCanvas = document.createElement('canvas');
const exportRenderer = new THREE.WebGLRenderer({
canvas: exportCanvas,
antialias: true,
alpha: transparentBackground,
preserveDrawingBuffer: true,
powerPreference: 'high-performance'
});
exportRenderer.outputColorSpace = THREE.SRGBColorSpace;
exportRenderer.setClearColor(0x010102, transparentBackground ? 0 : 1);
exportRenderer.setPixelRatio(1);
exportRenderer.shadowMap.enabled = true;
exportRenderer.shadowMap.type = THREE.PCFSoftShadowMap;
exportRenderer.setSize(size, size, false);
const previousAspect = state.camera.aspect;
state.camera.aspect = 1;
state.camera.updateProjectionMatrix();
try {
exportRenderer.render(state.scene, state.camera);
return await canvasToBlob(exportRenderer.domElement, 'image/png');
} finally {
state.camera.aspect = previousAspect;
state.camera.updateProjectionMatrix();
if (typeof exportRenderer.forceContextLoss === 'function') {
exportRenderer.forceContextLoss();
}
exportRenderer.dispose();
}
}
function createMoonExportFileName(size) {
const selectedDate = buildSelectedDate();
return `mondphase_${selectedDate.getFullYear()}-${padNumber(selectedDate.getMonth() + 1)}-${padNumber(selectedDate.getDate())}_${padNumber(selectedDate.getHours())}-${padNumber(selectedDate.getMinutes())}_${size}px.png`;
}
async function exportMoonPng() {
const size = getMoonExportSize();
const transparentBackground = useTransparentMoonExport();
if (!size) {
updateMoonExportStatus('Bitte eine gueltige Bildgroesse eingeben.', 'error');
return;
}
setMoonExportBusy(true);
updateMoonExportStatus(`Erzeuge PNG mit ${size} x ${size} Pixeln...`);
const selectedDate = buildSelectedDate();
const phaseData = calculateMoonPhase(selectedDate);
const libration = calculateLibration(selectedDate);
try {
let blob = null;
try {
blob = await renderMoon3DToBlob(phaseData, libration, size, transparentBackground);
} catch (error) {
console.warn('3D-Export fehlgeschlagen, wechsle auf 2D-Fallback.', error);
}
if (!blob) {
blob = await renderMoon2DToBlob(phaseData, size, transparentBackground);
}
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = createMoonExportFileName(size);
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(downloadUrl), 2000);
setMoonExportBusy(false);
closeMoonExportDialog();
} catch (error) {
console.error(error);
setMoonExportBusy(false);
updateMoonExportStatus('Export fehlgeschlagen. Bitte versuche eine kleinere Bildgroesse.', 'error');
}
}
window.openMoonExportDialog = openMoonExportDialog;
window.closeMoonExportDialog = closeMoonExportDialog;
window.exportMoonPng = exportMoonPng;
function renderMoon(phaseData, libration) {
if (renderMoon3D(phaseData, libration)) {
return;
}
renderMoon2D(phaseData);
}
function buildSelectedDate() {
const [year, month, day] = datePicker.value.split('-').map(Number);
const minutes = Number(timeSlider.value);
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return new Date(year, month - 1, day, hours, mins, 0, 0);
}
function shiftDateByDays(days) {
const current = buildSelectedDate();
current.setDate(current.getDate() + days);
syncDatePickerWithDate(current);
updateMoon();
}
function updateMoon() {
const selectedDate = buildSelectedDate();
const minutes = Number(timeSlider.value);
const timeText = formatMinutes(minutes);
const phaseData = calculateMoonPhase(selectedDate);
const libration = calculateLibration(selectedDate);
renderMoon(phaseData, libration);
timeLabel.textContent = timeText + ' Uhr';
dateLabel.textContent = formatDateLong(selectedDate);
phaseName.textContent = phaseData.label;
illuminationValue.textContent = (phaseData.illumination * 100).toFixed(1).replace('.', ',') + ' %';
distanceValue.textContent = formatMoonDistance(libration.distKm);
ageValue.textContent = phaseData.age.toFixed(1).replace('.', ',') + ' Tage';
const utcString = selectedDate.toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', timeZone: 'UTC'
});
selectedValue.textContent = formatDateTime(selectedDate) + ' · ' + utcString + ' UTC';
librationValue.textContent = formatLibration(libration);
}
function setNow() {
const now = new Date();
setSelectedDateTime(now);
}
function beginMoonDrag(event) {
isDraggingMoon = true;
moonDragPointerId = event.pointerId;
moonDragStartX = event.clientX;
moonDragStartY = event.clientY;
moonDragStartPanX = moonPanX;
moonDragStartPanY = moonPanY;
moonCanvas.classList.add('is-dragging');
moonCanvas.setPointerCapture(event.pointerId);
}
function updateMoonDrag(event) {
if (!isDraggingMoon || event.pointerId !== moonDragPointerId) {
return;
}
const rect = moonCanvas.getBoundingClientRect();
const size = Math.max(1, Math.min(rect.width, rect.height));
const panScale = 4.2 / (size * getMoonZoomFactor());
moonPanX = clampMoonPan(moonDragStartPanX - (event.clientX - moonDragStartX) * panScale);
moonPanY = clampMoonPan(moonDragStartPanY + (event.clientY - moonDragStartY) * panScale);
updateMoon();
}
function endMoonDrag(event) {
if (!isDraggingMoon || event.pointerId !== moonDragPointerId) {
return;
}
isDraggingMoon = false;
moonDragPointerId = null;
moonCanvas.classList.remove('is-dragging');
if (moonCanvas.hasPointerCapture(event.pointerId)) {
moonCanvas.releasePointerCapture(event.pointerId);
}
}
datePicker.addEventListener('input', () => {
stopMoonPlayback();
updateMoon();
});
timeSlider.addEventListener('input', () => {
stopMoonPlayback();
updateMoon();
});
prevDayBtn.addEventListener('click', () => shiftDateByDays(-1));
nextDayBtn.addEventListener('click', () => shiftDateByDays(1));
nowBtn.addEventListener('click', () => {
stopMoonPlayback();
setNow();
updateMoon();
});
resetViewBtn.addEventListener('click', resetMoonView);
playPauseBtn.addEventListener('click', toggleMoonPlayback);
exportMoonBtn.addEventListener('click', openMoonExportDialog);
playbackSpeedGroup.addEventListener('click', event => {
const button = event.target.closest('.moon-speed-btn');
if (!button) {
return;
}
moonPlaybackSpeed = Number(button.dataset.speed) || 1;
playbackSpeedGroup.querySelectorAll('.moon-speed-btn').forEach(item => {
item.classList.toggle('is-active', item === button);
});
});
moonCanvas.addEventListener('wheel', event => {
event.preventDefault();
const zoomDelta = event.deltaY > 0 ? -0.1 : 0.1;
const nextZoom = THREE.MathUtils.clamp(moonZoom + zoomDelta, MOON_ZOOM_MIN, MOON_ZOOM_MAX);
if (nextZoom !== moonZoom) {
moonZoom = nextZoom;
updateMoon();
}
}, { passive: false });
moonCanvas.addEventListener('pointerdown', beginMoonDrag);
moonCanvas.addEventListener('pointermove', updateMoonDrag);
moonCanvas.addEventListener('pointerup', endMoonDrag);
moonCanvas.addEventListener('pointercancel', endMoonDrag);
window.addEventListener('resize', resizeMoonScene);
moonExportCloseBtn.addEventListener('click', closeMoonExportDialog);
moonExportCancelBtn.addEventListener('click', closeMoonExportDialog);
moonExportConfirmBtn.addEventListener('click', exportMoonPng);
moonExportSizeSelect.addEventListener('input', () => {
updateMoonExportCustomVisibility();
updateMoonExportStatus('');
if (moonExportSizeSelect.value === 'custom') {
moonExportCustomInput.focus();
}
});
moonExportCustomInput.addEventListener('input', () => updateMoonExportStatus(''));
moonExportCustomInput.addEventListener('keydown', event => {
if (event.key === 'Enter') {
event.preventDefault();
exportMoonPng();
}
});
moonExportModal.addEventListener('click', event => {
if (event.target === moonExportModal) {
closeMoonExportDialog();
}
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && moonExportModal.classList.contains('is-open')) {
event.preventDefault();
closeMoonExportDialog();
}
});
updateMoonExportCustomVisibility();
setNow();
updatePlaybackButton();
updateMoon();
</script>
<?php require __DIR__ . '/footer.php'; ?>