Files
skyview.astronomiemuseum.de/public/moonphase.php
T

1665 lines
55 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';
$moonCraterRows = [];
function runPythonApi(string $scriptPath, string $action, array $arguments): array
{
$pythonCandidates = ['/usr/bin/python', '/usr/bin/python3', 'python3', 'python'];
$escapedArguments = [escapeshellarg($scriptPath), escapeshellarg($action)];
foreach ($arguments as $argument) {
$escapedArguments[] = escapeshellarg((string)$argument);
}
$lastError = 'Python konnte nicht ausgefuehrt werden.';
$debugOutput = [];
foreach ($pythonCandidates as $pythonBinary) {
$command = $pythonBinary . ' ' . implode(' ', $escapedArguments) . ' 2>&1';
$output = [];
$resultCode = 0;
exec($command, $output, $resultCode);
$joined = trim(implode("\n", $output));
$debugOutput = $output;
if ($resultCode !== 0) {
$lastError = $joined !== '' ? $joined : ('Fehlercode ' . $resultCode . ' bei ' . $pythonBinary);
continue;
}
$decoded = json_decode($joined, true);
if (!is_array($decoded)) {
$lastError = $joined !== '' ? 'Python-Ausgabe war kein gültiges JSON.' : 'Python lieferte keine Ausgabe zurück.';
continue;
}
if (!($decoded['ok'] ?? false)) {
return [
'ok' => false,
'error' => (string)($decoded['error'] ?? 'Unbekannter Python-Fehler'),
'debug' => $joined,
];
}
return ['ok' => true, 'data' => $decoded];
}
return [
'ok' => false,
'error' => $lastError,
'debug' => implode("\n", $debugOutput),
];
}
$isAjax = isset($_POST['ajax']) && (string)$_POST['ajax'] === '1';
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8');
$utcIso = isset($_POST['utc_iso']) ? trim((string)$_POST['utc_iso']) : '';
$timezoneName = isset($_POST['timezone']) ? trim((string)$_POST['timezone']) : 'Europe/Berlin';
if ($utcIso === '') {
echo json_encode([
'ok' => false,
'error' => 'utc_iso wurde nicht übergeben.',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
$pythonResult = runPythonApi(
__DIR__ . '/py/api.py',
'moon_phase_details',
[$utcIso, $timezoneName]
);
if (!$pythonResult['ok']) {
echo json_encode([
'ok' => false,
'error' => $pythonResult['error'] ?? 'Unbekannter Python-Fehler',
'debug' => $pythonResult['debug'] ?? null,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
echo json_encode($pythonResult['data'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
try {
$config = require __DIR__ . '/../config/database.php';
$dsn = sprintf(
'mysql:host=%s;dbname=%s;charset=%s',
$config['host'],
$config['dbname'],
$config['charset'] ?? 'utf8mb4'
);
$pdo = new PDO(
$dsn,
$config['user'],
$config['pass'],
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
$stmt = $pdo->query(
"SELECT `name`, `type`, `type_de`, `iau_type`, `iau_type_de`, `lati_n`, `lati_c`, `longi_n`, `longi_n_360`, `longi_c`,
COALESCE(`wide_km`, `length_km`) AS `diameter_km`
FROM `moon_craters`
WHERE `name` IS NOT NULL
AND `lati_n` IS NOT NULL
AND `longi_n` IS NOT NULL
ORDER BY COALESCE(`wide_km`, `length_km`) DESC, `name` ASC"
);
foreach ($stmt->fetchAll() as $row) {
$moonCraterRows[] = [
'name' => (string)($row['name'] ?? ''),
'type' => (string)($row['type'] ?? ''),
'typeDe' => (string)($row['type_de'] ?? ''),
'iauType' => (string)($row['iau_type'] ?? ''),
'iauTypeDe' => (string)($row['iau_type_de'] ?? ''),
'lat' => isset($row['lati_n']) ? (float)$row['lati_n'] : null,
'latLabel' => (string)($row['lati_c'] ?? ''),
'lon' => isset($row['longi_n']) ? (float)$row['longi_n'] : null,
'lon360' => isset($row['longi_n_360']) ? (float)$row['longi_n_360'] : null,
'lonLabel' => (string)($row['longi_c'] ?? ''),
'diameterKm' => isset($row['diameter_km']) && $row['diameter_km'] !== null ? (float)$row['diameter_km'] : null,
];
}
} catch (Throwable $exception) {
$moonCraterRows = [];
}
?>
<?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">
<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"></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-orbit-btn" id="freeCameraBtn" title="Kamera frei um den Mond bewegen">Freie Kamera</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>
<button type="button" class="date-shift-btn moon-crater-btn" id="showAllCratersBtn">Alle Krater</button>
<button type="button" class="date-shift-btn moon-crater-btn" id="showTerminatorCratersBtn">Krater am Terminator</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">Libration</span>
<span class="moon-stat-value" id="librationValue">-</span>
</div>
<div class="moon-stat info-card moon-stat-wide">
<span class="moon-stat-label">Ausgew&auml;hlt</span>
<span class="moon-stat-value" id="selectedValue">-</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>
<canvas id="moonCraterOverlay" width="800" height="800" aria-hidden="true"></canvas>
<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>
</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 moonCanvas = document.getElementById('moonCanvas');
const moonCraterOverlay = document.getElementById('moonCraterOverlay');
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 freeCameraBtn = document.getElementById('freeCameraBtn');
const playPauseBtn = document.getElementById('playPauseBtn');
const playbackSpeedGroup = document.getElementById('playbackSpeedGroup');
const exportMoonBtn = document.getElementById('exportMoonBtn');
const showAllCratersBtn = document.getElementById('showAllCratersBtn');
const showTerminatorCratersBtn = document.getElementById('showTerminatorCratersBtn');
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 moonPlaybackActive = false;
let moonPlaybackSpeed = 1;
let isMoonExportRunning = false;
let moonExportRestoreFocus = null;
let freeCameraMode = false;
let cameraOrbitTheta = 0;
let cameraOrbitPhi = Math.PI / 2;
let moonDragStartOrbitTheta = 0;
let moonDragStartOrbitPhi = Math.PI / 2;
const craterLongitudeOffsetDeg = 0.0;
const craterLatitudeOffsetDeg = 0.0;
const craterOverlayScale = 0.940;
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_SPHERE_RADIUS = 1.6;
const MOON_PROJECTED_RADIUS_SCALE = 0.962;
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 = 180;
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 MOON_CRATERS = <?= json_encode($moonCraterRows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const MOON_CRATER_DEBUG_ONLY = null;
const MOON_DATA_CACHE = new Map();
let moonDataRequestSerial = 0;
let craterDisplayMode = 'none';
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;
}
const r = MOON_CAMERA_BASE_Z / getMoonZoomFactor();
if (freeCameraMode) {
moonSceneState.camera.position.set(
r * Math.sin(cameraOrbitPhi) * Math.sin(cameraOrbitTheta),
r * Math.cos(cameraOrbitPhi),
r * Math.sin(cameraOrbitPhi) * Math.cos(cameraOrbitTheta)
);
moonSceneState.camera.lookAt(0, 0, 0);
} else {
moonSceneState.camera.position.set(moonPanX, moonPanY, r);
moonSceneState.camera.lookAt(moonPanX, moonPanY, 0);
}
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;
cameraOrbitTheta = 0;
cameraOrbitPhi = Math.PI / 2;
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 = moonPlaybackActive ? '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() {
moonPlaybackActive = false;
if (!moonPlaybackTimer) {
return;
}
window.clearTimeout(moonPlaybackTimer);
moonPlaybackTimer = null;
updatePlaybackButton();
}
async function advanceMoonTime(stepMinutes = MOON_PLAYBACK_STEP_MINUTES) {
const next = buildSelectedDate();
next.setMinutes(next.getMinutes() + stepMinutes);
setSelectedDateTime(next);
await updateMoon();
}
function queueMoonPlaybackTick() {
if (!moonPlaybackActive) {
moonPlaybackTimer = null;
updatePlaybackButton();
return;
}
moonPlaybackTimer = window.setTimeout(async () => {
try {
await advanceMoonTime(MOON_PLAYBACK_STEP_MINUTES * moonPlaybackSpeed);
} finally {
queueMoonPlaybackTick();
}
}, MOON_PLAYBACK_INTERVAL_MS);
}
function toggleMoonPlayback() {
if (moonPlaybackActive) {
stopMoonPlayback();
return;
}
moonPlaybackActive = true;
updatePlaybackButton();
queueMoonPlaybackTick();
}
function getPhaseLabel(age) {
const segment = SYNODIC_MONTH / 8;
const index = Math.floor((age + segment / 2) / segment) % 8;
return phaseLabels[index];
}
function getBrowserTimeZone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Europe/Berlin';
} catch (error) {
return 'Europe/Berlin';
}
}
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`;
}
async function fetchMoonPhaseData(date) {
const utcIso = date.toISOString().replace('.000Z', 'Z');
const cacheKey = `${utcIso}|${getBrowserTimeZone()}`;
if (MOON_DATA_CACHE.has(cacheKey)) {
return MOON_DATA_CACHE.get(cacheKey);
}
const formData = new FormData();
formData.set('ajax', '1');
formData.set('utc_iso', utcIso);
formData.set('timezone', getBrowserTimeZone());
const response = await fetch(window.location.href, {
method: 'POST',
body: formData,
headers: { 'X-Requested-With': 'fetch' }
});
const payload = await response.json();
if (!payload || !payload.ok) {
throw new Error(payload && payload.error ? payload.error : 'Mondphase konnte nicht geladen werden.');
}
MOON_DATA_CACHE.set(cacheKey, payload);
return payload;
}
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 gültige Bildgröße eingeben.', 'error');
return;
}
setMoonExportBusy(true);
updateMoonExportStatus(`Erzeuge PNG mit ${size} x ${size} Pixeln...`);
try {
const selectedDate = buildSelectedDate();
const moonData = await fetchMoonPhaseData(selectedDate);
const phaseData = moonData.phase;
const libration = moonData.libration;
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)) {
renderMoonCraterOverlay(phaseData, libration);
return;
}
renderMoon2D(phaseData);
renderMoonCraterOverlay(phaseData, libration);
}
function getMoonDisplayGeometry(width, height, zoom = null) {
if (moonSceneState && moonSceneState.camera) {
const camera = moonSceneState.camera;
const focalLengthPx = (height / 2) / Math.tan(toRadians(camera.fov / 2));
if (freeCameraMode) {
const depth = Math.max(0.001, camera.position.length());
const projectedRadius = focalLengthPx * (1.6 / depth) * MOON_PROJECTED_RADIUS_SCALE;
return {
centerX: width / 2,
centerY: height / 2,
radius: projectedRadius
};
}
const depth = Math.max(0.001, camera.position.z);
const projectedRadius = focalLengthPx * (1.6 / depth) * MOON_PROJECTED_RADIUS_SCALE;
return {
centerX: width / 2 - (camera.position.x * focalLengthPx / depth),
centerY: height / 2 + (camera.position.y * focalLengthPx / depth),
radius: projectedRadius
};
}
const minDimension = Math.min(width, height);
const panScale = minDimension * 0.18;
const zoomFactor = zoom === null ? getMoonZoomFactor() : zoom;
return {
centerX: width / 2 + moonPanX * panScale,
centerY: height / 2 - moonPanY * panScale,
radius: Math.min(minDimension * 0.275, minDimension * 0.1875 * zoomFactor)
};
}
function normalizeSignedLongitude(degrees) {
let value = ((degrees + 180) % 360 + 360) % 360 - 180;
return value;
}
function craterToMoonLocalVector(crater) {
const latRad = toRadians(Number(crater.lat || 0) + craterLatitudeOffsetDeg);
const lonRad = toRadians(-normalizeSignedLongitude(Number(crater.lon || 0) + craterLongitudeOffsetDeg));
const cosLat = Math.cos(latRad);
const craterRadius = MOON_SPHERE_RADIUS * craterOverlayScale;
return new THREE.Vector3(
craterRadius * cosLat * Math.cos(lonRad),
craterRadius * Math.sin(latRad),
craterRadius * cosLat * Math.sin(lonRad)
);
}
function getCraterVisualStyle(crater, mode = 'all') {
if (mode === 'terminator') {
return {
pointFill: 'rgba(110, 245, 255, 0.95)',
labelFill: '#c9fbff'
};
}
const rawType = String(crater.typeDe || crater.type || crater.iauTypeDe || crater.iauType || '').toLowerCase();
if (rawType.includes('rille') || rawType.includes('rillen')) {
return {
pointFill: 'rgba(118, 224, 255, 0.88)',
labelFill: '#bdefff'
};
}
if (rawType.includes('mare') || rawType.includes('meer') || rawType.includes('ocean') || rawType.includes('see') || rawType.includes('lake') || rawType.includes('bucht') || rawType.includes('bay')) {
return {
pointFill: 'rgba(115, 170, 255, 0.86)',
labelFill: '#c9dbff'
};
}
if (rawType.includes('mont') || rawType.includes('gebirge') || rawType.includes('mount') || rawType.includes('promontor') || rawType.includes('cape')) {
return {
pointFill: 'rgba(169, 255, 143, 0.88)',
labelFill: '#ddffd1'
};
}
if (rawType.includes('vall') || rawType.includes('tal') || rawType.includes('valley')) {
return {
pointFill: 'rgba(255, 166, 120, 0.88)',
labelFill: '#ffd7c0'
};
}
return {
pointFill: 'rgba(255, 214, 111, 0.82)',
labelFill: '#ffe0a1'
};
}
function projectCraterWithScene(crater, state, renderWidth, renderHeight) {
const localPoint = craterToMoonLocalVector(crater);
const worldPoint = localPoint.clone();
state.moonMesh.localToWorld(worldPoint);
const normalWorld = localPoint.clone().normalize().applyQuaternion(state.moonMesh.quaternion);
const viewDirection = state.camera.position.clone().sub(worldPoint).normalize();
const facing = normalWorld.dot(viewDirection);
if (facing <= 0) {
return null;
}
const ndc = worldPoint.clone().project(state.camera);
if (!Number.isFinite(ndc.x) || !Number.isFinite(ndc.y) || ndc.z < -1.1 || ndc.z > 1.1) {
return null;
}
const sunDirection = state.sunLight.position.clone().sub(worldPoint).normalize();
const terminatorDistance = normalWorld.dot(sunDirection);
return {
x: (ndc.x * 0.5 + 0.5) * renderWidth,
y: (-ndc.y * 0.5 + 0.5) * renderHeight,
terminatorDistance,
facing
};
}
function projectCraterToMoonDisk(crater, libration, radius) {
const latRad = toRadians(Number(crater.lat || 0));
const subEarthLatRad = toRadians(Number(libration.latitude || 0));
const deltaLonRad = toRadians(normalizeSignedLongitude(Number(crater.lon || 0) - Number(libration.longitude || 0)));
const baseX = Math.cos(latRad) * Math.sin(deltaLonRad);
const baseY = Math.sin(latRad) * Math.cos(subEarthLatRad) - Math.cos(latRad) * Math.sin(subEarthLatRad) * Math.cos(deltaLonRad);
const visibility = Math.sin(latRad) * Math.sin(subEarthLatRad) + Math.cos(latRad) * Math.cos(subEarthLatRad) * Math.cos(deltaLonRad);
if (visibility <= 0) {
return null;
}
const angle = toRadians(Number(libration.positionAngle || 0));
const rotX = baseX * Math.cos(angle) - baseY * Math.sin(angle);
const rotY = baseX * Math.sin(angle) + baseY * Math.cos(angle);
return {
x: rotX * radius,
y: -rotY * radius,
visibility
};
}
function renderMoonCraterOverlay(phaseData, libration) {
if (!moonCraterOverlay) {
return;
}
const renderWidth = Math.max(1, moonCanvas.width);
const renderHeight = Math.max(1, moonCanvas.height);
if (moonCraterOverlay.width !== renderWidth || moonCraterOverlay.height !== renderHeight) {
moonCraterOverlay.width = renderWidth;
moonCraterOverlay.height = renderHeight;
}
const ctx = moonCraterOverlay.getContext('2d');
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, renderWidth, renderHeight);
if (craterDisplayMode === 'none' || MOON_CRATERS.length === 0) {
return;
}
const state = moonSceneState;
const { centerX, centerY, radius } = getMoonDisplayGeometry(renderWidth, renderHeight);
const q = Math.cos(phaseData.phase * Math.PI * 2);
const zoomFactor = getMoonZoomFactor();
const isMaximumZoom = zoomFactor >= MOON_ZOOM_MAX - 0.001;
const displayDiameterThreshold = craterDisplayMode === 'all'
? Math.max(4, 72 - zoomFactor * 26)
: 0;
const visibleCraters = [];
for (const crater of MOON_CRATERS) {
if (MOON_CRATER_DEBUG_ONLY && String(crater.name || '').toLowerCase() !== MOON_CRATER_DEBUG_ONLY.toLowerCase()) {
continue;
}
const diameter = Number(crater.diameterKm || 0);
if (craterDisplayMode === 'all' && diameter < displayDiameterThreshold) {
continue;
}
const projected = state
? projectCraterWithScene(crater, state, renderWidth, renderHeight)
: projectCraterToMoonDisk(crater, libration, radius);
if (!projected) {
continue;
}
const absoluteX = state ? projected.x : centerX + projected.x;
const absoluteY = state ? projected.y : centerY + projected.y;
const distanceFromCenter = Math.hypot(absoluteX - centerX, absoluteY - centerY);
if (distanceFromCenter > radius * 0.975) {
continue;
}
let distanceToTerminator;
if (state) {
distanceToTerminator = projected.terminatorDistance;
} else {
const yFromCenter = absoluteY - centerY;
const xMax = Math.sqrt(Math.max(0, radius * radius - yFromCenter * yFromCenter));
const terminatorX = phaseData.waxing
? centerX + q * xMax
: centerX - q * xMax;
distanceToTerminator = Math.abs(absoluteX - terminatorX);
}
if (craterDisplayMode === 'terminator' && (state
? (distanceToTerminator < -1.05 || distanceToTerminator > -0.00)
: Math.abs(distanceToTerminator) > Math.max(8, radius * 0.03))) {
continue;
}
visibleCraters.push({
crater,
x: absoluteX,
y: absoluteY,
distanceToTerminator
});
}
if (craterDisplayMode === 'terminator') {
visibleCraters.sort((a, b) => Math.abs(a.distanceToTerminator) - Math.abs(b.distanceToTerminator));
} else {
visibleCraters.sort((a, b) => Number(b.crater.diameterKm || 0) - Number(a.crater.diameterKm || 0));
}
const labelLimit = craterDisplayMode === 'all'
? (isMaximumZoom ? visibleCraters.length : Math.min(visibleCraters.length, Math.round(10 + zoomFactor * 18)))
: Math.round(16 + zoomFactor * 10);
const labelDiameterThreshold = craterDisplayMode === 'all'
? (isMaximumZoom ? 0 : displayDiameterThreshold)
: Math.max(10, 34 - zoomFactor * 10);
visibleCraters.slice(0, craterDisplayMode === 'all' ? visibleCraters.length : 120).forEach((item, index) => {
const diameter = Number(item.crater.diameterKm || 0);
const visualStyle = getCraterVisualStyle(item.crater, craterDisplayMode);
const pointRadius = craterDisplayMode === 'terminator'
? Math.max(1.6, Math.min(3.4, diameter / 38))
: Math.max(1.1, Math.min(2.6, diameter / 52));
ctx.beginPath();
ctx.arc(item.x, item.y, pointRadius, 0, Math.PI * 2);
ctx.fillStyle = visualStyle.pointFill;
ctx.fill();
if (index < labelLimit && (craterDisplayMode === 'terminator' || diameter >= labelDiameterThreshold)) {
const fontSize = Math.max(10, Math.min(13, 9.5 + zoomFactor * 1.15));
const labelHeight = Math.round(fontSize + 5);
ctx.font = `${fontSize.toFixed(1)}px Space Mono, monospace`;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
const label = String(item.crater.name || '');
const textWidth = ctx.measureText(label).width;
ctx.fillStyle = 'rgba(4, 7, 14, 0.75)';
ctx.fillRect(item.x + 6, item.y - Math.round(labelHeight / 2), textWidth + 8, labelHeight);
ctx.fillStyle = visualStyle.labelFill;
ctx.fillText(label, item.x + 10, item.y);
}
});
}
function updateCraterButtons() {
if (showAllCratersBtn) {
showAllCratersBtn.classList.toggle('is-active', craterDisplayMode === 'all');
}
if (showTerminatorCratersBtn) {
showTerminatorCratersBtn.classList.toggle('is-active', craterDisplayMode === 'terminator');
}
}
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();
}
async function updateMoon() {
const selectedDate = buildSelectedDate();
const requestSerial = ++moonDataRequestSerial;
const minutes = Number(timeSlider.value);
const timeText = formatMinutes(minutes);
timeLabel.textContent = timeText + ' Uhr';
dateLabel.textContent = formatDateLong(selectedDate);
try {
const moonData = await fetchMoonPhaseData(selectedDate);
if (requestSerial !== moonDataRequestSerial) {
return;
}
const phaseData = moonData.phase;
const libration = moonData.libration;
renderMoon(phaseData, libration);
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.innerHTML = `${formatDateTime(selectedDate)} ${getBrowserTimeZone()}<br>${utcString} UTC`;
librationValue.textContent = formatLibration(libration);
} catch (error) {
if (requestSerial !== moonDataRequestSerial) {
return;
}
console.error(error);
phaseName.textContent = 'Mondphase konnte nicht geladen werden';
illuminationValue.textContent = '-';
distanceValue.textContent = '-';
ageValue.textContent = '-';
selectedValue.innerHTML = `${formatDateTime(selectedDate)} ${getBrowserTimeZone()}<br>-- UTC`;
librationValue.textContent = '-';
}
}
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;
moonDragStartOrbitTheta = cameraOrbitTheta;
moonDragStartOrbitPhi = cameraOrbitPhi;
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));
if (freeCameraMode) {
const sensitivity = 3.0 / size;
cameraOrbitTheta = moonDragStartOrbitTheta - (event.clientX - moonDragStartX) * sensitivity;
cameraOrbitPhi = THREE.MathUtils.clamp(
moonDragStartOrbitPhi - (event.clientY - moonDragStartY) * sensitivity,
0.05,
Math.PI - 0.05
);
} else {
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();
});
timeSlider.addEventListener('wheel', event => {
event.preventDefault();
stopMoonPlayback();
const step = Number(timeSlider.step || 5) || 5;
const direction = event.deltaY > 0 ? 1 : -1;
const minValue = Number(timeSlider.min || 0);
const maxValue = Number(timeSlider.max || 1435);
const currentValue = Number(timeSlider.value || 0);
const nextValue = Math.min(maxValue, Math.max(minValue, currentValue + direction * step));
if (nextValue !== currentValue) {
timeSlider.value = String(nextValue);
updateMoon();
}
}, { passive: false });
prevDayBtn.addEventListener('click', () => shiftDateByDays(-1));
nextDayBtn.addEventListener('click', () => shiftDateByDays(1));
nowBtn.addEventListener('click', () => {
stopMoonPlayback();
setNow();
updateMoon();
});
resetViewBtn.addEventListener('click', resetMoonView);
freeCameraBtn.addEventListener('click', () => {
freeCameraMode = !freeCameraMode;
freeCameraBtn.classList.toggle('is-active', freeCameraMode);
if (!freeCameraMode) {
moonPanX = 0;
moonPanY = 0;
}
cameraOrbitTheta = 0;
cameraOrbitPhi = Math.PI / 2;
updateMoon();
});
playPauseBtn.addEventListener('click', toggleMoonPlayback);
exportMoonBtn.addEventListener('click', openMoonExportDialog);
showAllCratersBtn.addEventListener('click', () => {
craterDisplayMode = craterDisplayMode === 'all' ? 'none' : 'all';
updateCraterButtons();
updateMoon();
});
showTerminatorCratersBtn.addEventListener('click', () => {
craterDisplayMode = craterDisplayMode === 'terminator' ? 'none' : 'terminator';
updateCraterButtons();
updateMoon();
});
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();
updateMoon();
});
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();
updateCraterButtons();
</script>
<?php require __DIR__ . '/footer.php'; ?>