Files
skyview.astronomiemuseum.de/public/moonphase.php
T
EskimueandClaude Sonnet 5 4c6fe6faeb Mond-3D: Pan und Zoom ohne Blickwinkelveraenderung umsetzen
Verschieben nutzt jetzt camera.setViewOffset() statt Kameraposition/-ziel
zu bewegen, und Zoom erfolgt ueber camera.zoom (Brennweite) statt ueber
Annaeherung der Kamera. So bleibt der Blickwinkel auf den Mond in der
Standardansicht immer wie von der Erde aus - nur der freie Kamera-Modus
darf ihn aendern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 13:38:15 +02:00

2036 lines
71 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">
<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>
<button type="button" class="date-shift-btn moon-hd-btn" id="hdTextureBtn" title="Hochauflösende Texturen und Höhenkarte laden (60MB)">HD</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>
<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>
<details class="moon-controls" id="moonLightDebugPanel" hidden>
<summary class="control-label">Licht (Testregler)</summary>
<div class="control-card">
<div class="control-head">
<label class="control-label" for="lightSunIntensity">Sonnenlicht</label>
<span class="control-value" id="lightSunIntensityValue">-</span>
</div>
<input type="range" id="lightSunIntensity" min="0" max="10" step="0.1">
</div>
<div class="control-card">
<div class="control-head">
<label class="control-label" for="lightExposure">Belichtung</label>
<span class="control-value" id="lightExposureValue">-</span>
</div>
<input type="range" id="lightExposure" min="0.1" max="3" step="0.05">
</div>
<div class="control-card">
<div class="control-head">
<label class="control-label" for="lightAmbient">Umgebungslicht</label>
<span class="control-value" id="lightAmbientValue">-</span>
</div>
<input type="range" id="lightAmbient" min="0" max="1" step="0.01">
</div>
<div class="control-card">
<div class="control-head">
<label class="control-label" for="lightRim">Randlicht</label>
<span class="control-value" id="lightRimValue">-</span>
</div>
<input type="range" id="lightRim" min="0" max="0.2" step="0.005">
</div>
<div class="control-card">
<div class="control-head">
<label class="control-label" for="lightShadowNormalBias">Schatten-Bias</label>
<span class="control-value" id="lightShadowNormalBiasValue">-</span>
</div>
<input type="range" id="lightShadowNormalBias" min="0" max="0.3" step="0.005">
</div>
<button type="button" class="date-shift-btn moon-action-btn" id="lightSaveValuesBtn">Werte speichern</button>
</details>
</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';
let 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 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 hdTextureBtn = document.getElementById('hdTextureBtn');
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');
const lightSunIntensity = document.getElementById('lightSunIntensity');
const lightSunIntensityValue = document.getElementById('lightSunIntensityValue');
const lightExposure = document.getElementById('lightExposure');
const lightExposureValue = document.getElementById('lightExposureValue');
const lightAmbient = document.getElementById('lightAmbient');
const lightAmbientValue = document.getElementById('lightAmbientValue');
const lightRim = document.getElementById('lightRim');
const lightRimValue = document.getElementById('lightRimValue');
const lightShadowNormalBias = document.getElementById('lightShadowNormalBias');
const lightShadowNormalBiasValue = document.getElementById('lightShadowNormalBiasValue');
const lightSaveValuesBtn = document.getElementById('lightSaveValuesBtn');
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 hdTexturesLoaded = false;
let hdTexturesLoading = 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_4096.jpg';
const MOON_DISPLACEMENT_MAP_PATH = 'images/moons/moon_displacement_16b_low.jpg';
const MOON_TEXTURE_PATH_HD = 'images/moons/moon_16384.jpg';
const MOON_DISPLACEMENT_MAP_PATH_HD = '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;
// ---- Beleuchtung des 3D-Mondmodells ------------------------------------
// Alle Licht-Werte gesammelt an einer Stelle, damit man beim Justieren nicht
// im Code suchen muss. Reines JavaScript - nach einer Aenderung reicht ein
// Neuladen der Seite, kein Server-Neustart.
// Haupt-"Sonnenlicht" (gerichtetes Licht). Bestimmt, wie hell die beleuchtete
// Seite grundsaetzlich ist, gleichmaessig ueber alle Mondphasen hinweg.
// Hoeher = insgesamt heller (auch bei Vollmond, das dann eher ueberstrahlt).
let MOON_SUN_LIGHT_INTENSITY = 4.7;
// Wirkt wie eine Kamera-Belichtung, NACH der eigentlichen Lichtberechnung
// (Three.js ACESFilmicToneMapping). Im Gegensatz zu MOON_SUN_LIGHT_INTENSITY
// komprimiert sie helle Bereiche automatisch mit (ueberstrahlt also nicht
// gleich), waehrend sie dunkle/Streiflicht-Bereiche (duenne Sichel) sichtbarer
// macht. Faustregel: Wirkt eine duenne Sichel zu dunkel, aber Vollmond okay ->
// diesen Wert erhoehen statt MOON_SUN_LIGHT_INTENSITY. Gilt fuer Live-Ansicht
// UND PNG-Export gleichermassen (beide nutzen dieselbe Konstante).
let MOON_TONE_MAPPING_EXPOSURE = 1.15;
// Gleichmaessige Grundaufhellung der GESAMTEN Kugel, auch der unbeleuchteten
// Nachtseite, unabhaengig vom Lichtwinkel. Niedrig halten - sonst wirkt die
// Nachtseite grau statt schwarz und der Tag/Nacht-Kontrast (Kraterschatten)
// geht verloren.
const MOON_AMBIENT_LIGHT_COLOR = 0xffffff;
let MOON_AMBIENT_LIGHT_INTENSITY = 0.01;
// Schwaches zusaetzliches Licht von der Seite fuer einen dezenten Rand-/
// Tiefeneffekt auf der Nachtseite (kein echtes Erdlicht, rein optisch).
// Leicht blaeulich fuer einen kuehlen "Weltraum"-Farbton.
const MOON_RIM_LIGHT_COLOR = 0x89a8ff;
let MOON_RIM_LIGHT_INTENSITY = 0.095;
// Verhindert falsche Selbstverschattung ("Shadow Acne") der Krater-Shadow-Map
// bei sehr flachem Lichteinfall (duenne Sichel = fast ueberall Streiflicht).
// Die Tag/Nacht-Grenze selbst kommt schon aus der normalen Beleuchtungs-
// rechnung; die Shadow-Map ist nur fuer feine Krater-Schlagschatten noetig.
const MOON_SHADOW_BIAS = 0;
let MOON_SHADOW_NORMAL_BIAS = 0.005;
// Kantenschaerfe der Krater-Schlagschatten (nicht die Tag/Nacht-Grenze selbst -
// die kommt aus der normalen Beleuchtung und hat immer einen weichen, runden
// Verlauf, weil eine Kugel nie hart in Licht/Schatten uebergeht). Optionen von
// weich zu hart: THREE.PCFSoftShadowMap (weichste Kanten) < THREE.PCFShadowMap
// (Mittelweg) < THREE.BasicShadowMap (haerteste, scharfe Kanten, keine
// Weichzeichnung - kann bei feinen Kraterdetails leicht kantig/treppig wirken).
const MOON_SHADOW_MAP_TYPE = THREE.BasicShadowMap;
// -------------------------------------------------------------------------
const MOON_ZOOM_MIN = 0.2;
const MOON_ZOOM_MAX = 4.1;
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;
}
if (freeCameraMode) {
const r = MOON_CAMERA_BASE_Z / getMoonZoomFactor();
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);
moonSceneState.camera.zoom = 1;
moonSceneState.camera.clearViewOffset();
} else {
// Kamera bleibt IMMER exakt auf den Mond ausgerichtet UND auf fester
// Distanz ("Blickwinkel wie von der Erde aus" darf sich weder beim
// Verschieben noch beim Zoomen aendern - genau wie ein Teleskop, das den
// Mond vergroessert, ohne dass sich die Erde-Mond-Distanz aendert). Zoom
// wird deshalb NICHT durch Annaehern der Kamera simuliert (das wuerde
// durch die perspektivische Projektion echte Parallaxe/Verzerrung
// erzeugen, die bei einer realen Fernbeobachtung so nicht auftritt),
// sondern rein optisch ueber camera.zoom (Brennweiten-Vergroesserung
// ohne Positionsaenderung). Das Verschieben passiert ebenfalls ohne
// Positionsaenderung, rein als Bildausschnitt-Versatz (Shift-Objektiv-
// Prinzip via camera.setViewOffset).
moonSceneState.camera.position.set(0, 0, MOON_CAMERA_BASE_Z);
moonSceneState.camera.lookAt(0, 0, 0);
moonSceneState.camera.zoom = getMoonZoomFactor();
const fullWidth = Math.max(1, moonCanvas.clientWidth || 900);
const fullHeight = Math.max(1, moonCanvas.clientHeight || 900);
// Der Bildwinkel-Versatz muss mit dem Zoom mitwachsen: die scheinbare
// Mondgroesse auf dem Bildschirm skaliert mit dem Zoomfaktor, sonst reicht
// der Pan-Bereich bei starkem Zoom nicht mehr aus, um den Mond bis an den
// Rand zu ziehen.
const panPixelScale = Math.min(fullWidth, fullHeight) * 0.18 * getMoonZoomFactor();
const offsetX = moonPanX * panPixelScale;
const offsetY = -moonPanY * panPixelScale;
if (offsetX === 0 && offsetY === 0) {
moonSceneState.camera.clearViewOffset();
} else {
moonSceneState.camera.setViewOffset(fullWidth, fullHeight, offsetX, offsetY, fullWidth, fullHeight);
}
}
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();
}
async function fetchWithProgress(url, onProgress) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const contentLength = Number(response.headers.get('Content-Length') || 0);
const reader = response.body.getReader();
const chunks = [];
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
chunks.push(value);
received += value.length;
if (contentLength > 0) {
onProgress(received / contentLength);
}
}
return URL.createObjectURL(new Blob(chunks));
}
function destroyMoonScene() {
if (!moonSceneState) {
return;
}
const { renderer, scene, moonMesh } = moonSceneState;
moonMesh.material.map && moonMesh.material.map.dispose();
moonMesh.material.displacementMap && moonMesh.material.displacementMap.dispose();
moonMesh.material.dispose();
moonMesh.geometry.dispose();
scene.clear();
renderer.dispose();
// Canvas durch frisches Element ersetzen damit kein alter WebGL-Kontext hängt
const newCanvas = document.createElement('canvas');
newCanvas.id = moonCanvas.id;
newCanvas.setAttribute('aria-label', moonCanvas.getAttribute('aria-label') || '');
moonCanvas.replaceWith(newCanvas);
moonCanvas = newCanvas;
moonSceneState = null;
moonSceneInitPromise = null;
}
async function loadHdTextures() {
if (hdTexturesLoaded || hdTexturesLoading) {
return;
}
hdTexturesLoading = true;
hdTextureBtn.disabled = true;
moonLoading.classList.remove('is-hidden', 'is-error');
setMoonLoadingProgress(0, 'HD-Texturen werden geladen... 0 %');
const objectUrls = [];
try {
let prog1 = 0;
let prog2 = 0;
const updateProgress = () => {
const combined = (prog1 + prog2) / 2;
setMoonLoadingProgress(combined, `HD-Texturen werden geladen... ${Math.round(combined * 100)} %`);
};
// Beide Dateien parallel herunterladen (RAM, noch kein GPU)
const [url1, url2] = await Promise.all([
fetchWithProgress(MOON_TEXTURE_PATH_HD, p => { prog1 = p; updateProgress(); }),
fetchWithProgress(MOON_DISPLACEMENT_MAP_PATH_HD, p => { prog2 = p; updateProgress(); })
]);
objectUrls.push(url1, url2);
setMoonLoadingProgress(1, 'Alte Scene wird entfernt...');
// Alte Scene komplett zerstören → GPU-VRAM vollständig frei
destroyMoonScene();
// Warten damit der Browser/GPU den Speicher wirklich freigibt
await new Promise(resolve => window.setTimeout(resolve, 200));
setMoonLoadingProgress(1, 'HD-Scene wird aufgebaut...');
// Scene neu aufbauen mit den HD-Blob-URLs
await buildMoonSceneFromUrls(url1, url2);
// Canvas wurde in destroyMoonScene() ersetzt → Listener neu anhängen
attachCanvasListeners();
hdTexturesLoaded = true;
hdTexturesLoading = false;
hdTextureBtn.textContent = 'HD!';
hdTextureBtn.classList.add('is-active');
hdTextureBtn.disabled = true;
moonLoadingText.textContent = 'HD-Texturen aktiv';
window.setTimeout(hideMoonLoading, 600);
updateMoon();
} catch (error) {
console.error('HD-Textur-Ladefehler:', error);
hdTexturesLoading = false;
hdTextureBtn.disabled = false;
hdTextureBtn.textContent = 'HD fehlgeschl.';
hideMoonLoading();
} finally {
objectUrls.forEach(u => URL.revokeObjectURL(u));
}
}
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;
}
let moonSceneInitPromise = null;
async function buildMoonSceneFromUrls(url1, url2) {
const renderer = new THREE.WebGLRenderer({
canvas: moonCanvas,
antialias: true,
alpha: false,
powerPreference: 'high-performance'
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
// Tone-Mapping statt reinem Clamping: komprimiert helle Bereiche (Vollmond),
// ohne dunkle/Streiflicht-Bereiche (duenne Sichel) zu verlieren - eine feste
// Lichtstaerke allein kann sonst nicht beides gleichzeitig darstellen.
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = MOON_TONE_MAPPING_EXPOSURE;
renderer.setClearColor(0x010102, 1);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 3));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = MOON_SHADOW_MAP_TYPE;
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(MOON_AMBIENT_LIGHT_COLOR, MOON_AMBIENT_LIGHT_INTENSITY);
scene.add(ambientLight);
const sunLight = new THREE.DirectionalLight(0xffffff, MOON_SUN_LIGHT_INTENSITY);
sunLight.castShadow = true;
sunLight.shadow.mapSize.width = 4096;
sunLight.shadow.mapSize.height = 4096;
sunLight.shadow.bias = MOON_SHADOW_BIAS;
sunLight.shadow.normalBias = MOON_SHADOW_NORMAL_BIAS;
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(MOON_RIM_LIGHT_COLOR, MOON_RIM_LIGHT_INTENSITY);
rimLight.position.set(-2.5, 1.2, 3.2);
scene.add(rimLight);
const textureLoader = new THREE.TextureLoader();
const maxAnisotropy = renderer.capabilities.getMaxAnisotropy();
const loadTex = (url, colorSpace) => new Promise((resolve, reject) => {
textureLoader.load(url, tex => {
tex.colorSpace = colorSpace;
tex.anisotropy = maxAnisotropy;
resolve(tex);
}, undefined, reject);
});
// Sequenziell laden um GPU-Speicherspitzen zu vermeiden
const texture = await loadTex(url1, THREE.SRGBColorSpace);
const displacementMap = await loadTex(url2, THREE.NoColorSpace);
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 };
resizeMoonCanvas();
applyMoonZoom();
renderer.render(scene, camera);
}
async function ensureMoonScene() {
if (moonSceneState) {
return moonSceneState;
}
if (moonSceneInitPromise) {
return moonSceneInitPromise;
}
moonSceneInitPromise = (async () => {
moonLoading.classList.remove('is-hidden', 'is-error');
setMoonLoadingProgress(0, 'Lade Mondtexturen... 0 %');
const objectUrls = [];
try {
let prog1 = 0;
let prog2 = 0;
const updateProgress = () => {
const combined = (prog1 + prog2) / 2;
setMoonLoadingProgress(combined, `Lade Mondtexturen... ${Math.round(combined * 100)} %`);
};
const [url1, url2] = await Promise.all([
fetchWithProgress(MOON_TEXTURE_PATH, p => { prog1 = p; updateProgress(); }),
fetchWithProgress(MOON_DISPLACEMENT_MAP_PATH, p => { prog2 = p; updateProgress(); })
]);
objectUrls.push(url1, url2);
setMoonLoadingProgress(1, 'Mondtexturen werden verarbeitet...');
await buildMoonSceneFromUrls(url1, url2);
moonLoadingText.textContent = 'Mondtexturen geladen';
window.setTimeout(hideMoonLoading, 350);
return moonSceneState;
} catch (error) {
console.error('Textur-Ladefehler:', error);
moonLoading.classList.remove('is-hidden');
moonLoading.classList.add('is-error');
moonLoadingFill.style.width = '100%';
moonLoadingText.textContent = 'Texturen konnten nicht vollstaendig geladen werden';
moonSceneInitPromise = null;
return null;
} finally {
objectUrls.forEach(u => URL.revokeObjectURL(u));
}
})();
return moonSceneInitPromise;
}
function resizeMoonCanvas() {
const rect = moonCanvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 3);
const w = Math.round(Math.max(360, rect.width || 900));
const h = Math.round(Math.max(360, rect.height || 900));
if (moonSceneState) {
// 3D: Three.js setzt canvas.width/height intern
moonSceneState.renderer.setSize(w, h, false);
moonSceneState.camera.aspect = w / h;
moonSceneState.camera.updateProjectionMatrix();
} else {
// 2D: canvas-Intrinsic-Größe manuell an die Darstellungsgröße anpassen
const pw = Math.round(w * dpr);
const ph = Math.round(h * dpr);
if (moonCanvas.width !== pw || moonCanvas.height !== ph) {
moonCanvas.width = pw;
moonCanvas.height = ph;
}
}
}
function resizeMoonScene() {
resizeMoonCanvas();
}
async function renderMoon3D(phaseData, libration) {
const state = await 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';
// Dieselbe Laengen-Konvention wie bei Kratern/Sonne (moonLocalDirectionFromLonLat)
// verwenden, statt libration.longitude direkt zu nehmen - sonst rotiert die
// Textur/Kugel selbst in einem anderen Bezugssystem als Krater und Sonne,
// obwohl alle von derselben (gegen NASA validierten) Sub-Erd-Laenge ausgehen.
state.moonMesh.rotation.y = MOON_TEXTURE_BASE_ROTATION_Y + toRadians(-normalizeSignedLongitude(libration.longitude));
state.moonMesh.rotation.x = toRadians(libration.latitude);
state.moonMesh.rotation.z = toRadians(libration.positionAngle);
// Sonnenrichtung direkt aus der Geometrie: derselbe lokale Vektor wie bei
// Kratern (selenographische Laenge/Breite -> mondfester Einheitsvektor),
// gedreht mit derselben Mesh-Rotation. Exakt statt aus der Beleuchtungs-
// Prozentzahl zurueckgerechnet, und dadurch garantiert konsistent mit
// Kraterlage und Textur. Haengt nur von state.moonMesh.rotation ab (Datum/
// Uhrzeit), nicht von Kamera-Pan - "wie von der Erde aus zu sehen" bleibt
// beim Verschieben also unveraendert; nur der freie Kamera-Modus rotiert
// die Kamera selbst und veraendert damit den Blickwinkel auf Mond+Licht.
const sunDirectionWorld = moonLocalDirectionFromLonLat(
libration.subsolarLongitude,
libration.subsolarLatitude,
craterLongitudeOffsetDeg,
craterLatitudeOffsetDeg
).applyEuler(state.moonMesh.rotation);
state.sunLight.position.copy(sunDirectionWorld.multiplyScalar(8));
// ambientLight/rimLight sind konstant (siehe MOON_AMBIENT_LIGHT_INTENSITY /
// MOON_RIM_LIGHT_INTENSITY oben) und werden schon bei der Szenen-Erstellung
// gesetzt - hier bewusst kein erneutes Ueberschreiben mehr, damit beide
// Stellen nicht auseinanderlaufen koennen.
}
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 = await 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.toneMapping = THREE.ACESFilmicToneMapping;
exportRenderer.toneMappingExposure = MOON_TONE_MAPPING_EXPOSURE;
exportRenderer.setClearColor(0x010102, transparentBackground ? 0 : 1);
exportRenderer.setPixelRatio(1);
exportRenderer.shadowMap.enabled = true;
exportRenderer.shadowMap.type = MOON_SHADOW_MAP_TYPE;
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;
async function renderMoon(phaseData, libration) {
if (await 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;
}
// Einheitsvektor im mondfesten (lokalen, noch unrotierten) Koordinatensystem
// aus selenographischer Laenge/Breite (Ost-positiv, wie von api.py geliefert).
// Gemeinsame Grundlage fuer Krater UND Sonnenrichtung, damit beide nach
// Anwendung derselben state.moonMesh-Rotation garantiert zueinander passen.
function moonLocalDirectionFromLonLat(lonDeg, latDeg, lonOffsetDeg = 0, latOffsetDeg = 0) {
const latRad = toRadians(Number(latDeg || 0) + latOffsetDeg);
const lonRad = toRadians(-normalizeSignedLongitude(Number(lonDeg || 0) + lonOffsetDeg));
const cosLat = Math.cos(latRad);
return new THREE.Vector3(
cosLat * Math.cos(lonRad),
Math.sin(latRad),
cosLat * Math.sin(lonRad)
);
}
function craterToMoonLocalVector(crater) {
const craterRadius = MOON_SPHERE_RADIUS * craterOverlayScale;
return moonLocalDirectionFromLonLat(
Number(crater.lon || 0),
Number(crater.lat || 0),
craterLongitudeOffsetDeg,
craterLatitudeOffsetDeg
).multiplyScalar(craterRadius);
}
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;
await 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'
});
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 = '-';
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 });
// Testregler fuer die Licht-Konstanten (siehe MOON_SUN_LIGHT_INTENSITY etc.
// oben): Schieberegler + Mausrad, wirkt sofort ohne Neuladen der Seite.
const lightSliderConfigs = [
{
input: lightSunIntensity, label: lightSunIntensityValue, initial: MOON_SUN_LIGHT_INTENSITY,
apply: value => {
MOON_SUN_LIGHT_INTENSITY = value;
if (moonSceneState) moonSceneState.sunLight.intensity = value;
}
},
{
input: lightExposure, label: lightExposureValue, initial: MOON_TONE_MAPPING_EXPOSURE,
apply: value => {
MOON_TONE_MAPPING_EXPOSURE = value;
if (moonSceneState) moonSceneState.renderer.toneMappingExposure = value;
}
},
{
input: lightAmbient, label: lightAmbientValue, initial: MOON_AMBIENT_LIGHT_INTENSITY,
apply: value => {
MOON_AMBIENT_LIGHT_INTENSITY = value;
if (moonSceneState) moonSceneState.ambientLight.intensity = value;
}
},
{
input: lightRim, label: lightRimValue, initial: MOON_RIM_LIGHT_INTENSITY,
apply: value => {
MOON_RIM_LIGHT_INTENSITY = value;
if (moonSceneState) moonSceneState.rimLight.intensity = value;
}
},
{
input: lightShadowNormalBias, label: lightShadowNormalBiasValue, initial: MOON_SHADOW_NORMAL_BIAS,
apply: value => {
MOON_SHADOW_NORMAL_BIAS = value;
if (moonSceneState) moonSceneState.sunLight.shadow.normalBias = value;
}
}
];
function setupLightSlider({ input, label, initial, apply }) {
input.value = String(initial);
label.textContent = initial.toFixed(3);
const commit = () => {
const value = Number(input.value);
apply(value);
label.textContent = value.toFixed(3);
if (moonSceneState) {
moonSceneState.renderer.render(moonSceneState.scene, moonSceneState.camera);
}
};
input.addEventListener('input', commit);
input.addEventListener('wheel', event => {
event.preventDefault();
const step = Number(input.step) || 0.01;
const min = Number(input.min) || 0;
const max = Number(input.max) || 1;
const direction = event.deltaY > 0 ? 1 : -1;
const nextValue = Math.min(max, Math.max(min, Number(input.value) + direction * step));
input.value = String(nextValue);
commit();
}, { passive: false });
}
lightSliderConfigs.forEach(setupLightSlider);
// Aktuelle Reglerwerte als Textdatei speichern - fertig zum Einsetzen als
// neue Standardwerte oben bei den MOON_*-Konstanten.
lightSaveValuesBtn.addEventListener('click', () => {
const lines = [
`MOON_SUN_LIGHT_INTENSITY = ${MOON_SUN_LIGHT_INTENSITY}`,
`MOON_TONE_MAPPING_EXPOSURE = ${MOON_TONE_MAPPING_EXPOSURE}`,
`MOON_AMBIENT_LIGHT_INTENSITY = ${MOON_AMBIENT_LIGHT_INTENSITY}`,
`MOON_RIM_LIGHT_INTENSITY = ${MOON_RIM_LIGHT_INTENSITY}`,
`MOON_SHADOW_NORMAL_BIAS = ${MOON_SHADOW_NORMAL_BIAS}`
];
const blob = new Blob([lines.join('\n') + '\n'], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'mondlicht-werte.txt';
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
});
prevDayBtn.addEventListener('click', () => shiftDateByDays(-1));
nextDayBtn.addEventListener('click', () => shiftDateByDays(1));
nowBtn.addEventListener('click', () => {
stopMoonPlayback();
setNow();
updateMoon();
});
resetViewBtn.addEventListener('click', resetMoonView);
hdTextureBtn.addEventListener('click', loadHdTextures);
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);
});
});
function onMoonCanvasWheel(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();
}
}
function attachCanvasListeners() {
moonCanvas.addEventListener('wheel', onMoonCanvasWheel, { passive: false });
moonCanvas.addEventListener('pointerdown', beginMoonDrag);
moonCanvas.addEventListener('pointermove', updateMoonDrag);
moonCanvas.addEventListener('pointerup', endMoonDrag);
moonCanvas.addEventListener('pointercancel', endMoonDrag);
}
attachCanvasListeners();
window.addEventListener('resize', () => {
resizeMoonCanvas();
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();
resizeMoonCanvas();
updateMoon();
updateCraterButtons();
</script>
<?php require __DIR__ . '/footer.php'; ?>