1608 lines
54 KiB
PHP
1608 lines
54 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ür ein frei wä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-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ä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">−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ü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');}}">×</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 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;
|
|
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;
|
|
}
|
|
|
|
moonSceneState.camera.position.z = MOON_CAMERA_BASE_Z / getMoonZoomFactor();
|
|
moonSceneState.camera.position.x = moonPanX;
|
|
moonSceneState.camera.position.y = moonPanY;
|
|
moonSceneState.camera.updateProjectionMatrix();
|
|
}
|
|
|
|
function clampMoonPan(value) {
|
|
return THREE.MathUtils.clamp(value, -MOON_PAN_LIMIT, MOON_PAN_LIMIT);
|
|
}
|
|
|
|
function resetMoonView() {
|
|
moonZoom = 1;
|
|
moonPanX = 0;
|
|
moonPanY = 0;
|
|
updateMoon();
|
|
}
|
|
|
|
function syncDatePickerWithDate(date) {
|
|
const timezoneOffset = date.getTimezoneOffset() * 60000;
|
|
datePicker.value = new Date(date.getTime() - timezoneOffset).toISOString().slice(0, 10);
|
|
}
|
|
|
|
function setSelectedDateTime(date) {
|
|
syncDatePickerWithDate(date);
|
|
const minutes = date.getHours() * 60 + date.getMinutes();
|
|
const roundedMinutes = Math.round(minutes / 5) * 5;
|
|
timeSlider.value = String(Math.min(1435, roundedMinutes));
|
|
}
|
|
|
|
function updatePlaybackButton() {
|
|
playPauseBtn.textContent = 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));
|
|
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 = Math.abs(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' && distanceToTerminator > (state ? 0.12 : Math.max(8, radius * 0.03))) {
|
|
continue;
|
|
}
|
|
|
|
visibleCraters.push({
|
|
crater,
|
|
x: absoluteX,
|
|
y: absoluteY,
|
|
distanceToTerminator
|
|
});
|
|
}
|
|
|
|
if (craterDisplayMode === 'terminator') {
|
|
visibleCraters.sort((a, b) => a.distanceToTerminator - 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;
|
|
moonCanvas.classList.add('is-dragging');
|
|
moonCanvas.setPointerCapture(event.pointerId);
|
|
}
|
|
|
|
function updateMoonDrag(event) {
|
|
if (!isDraggingMoon || event.pointerId !== moonDragPointerId) {
|
|
return;
|
|
}
|
|
|
|
const rect = moonCanvas.getBoundingClientRect();
|
|
const size = Math.max(1, Math.min(rect.width, rect.height));
|
|
const panScale = 4.2 / (size * getMoonZoomFactor());
|
|
|
|
moonPanX = clampMoonPan(moonDragStartPanX - (event.clientX - moonDragStartX) * panScale);
|
|
moonPanY = clampMoonPan(moonDragStartPanY + (event.clientY - moonDragStartY) * panScale);
|
|
updateMoon();
|
|
}
|
|
|
|
function endMoonDrag(event) {
|
|
if (!isDraggingMoon || event.pointerId !== moonDragPointerId) {
|
|
return;
|
|
}
|
|
|
|
isDraggingMoon = false;
|
|
moonDragPointerId = null;
|
|
moonCanvas.classList.remove('is-dragging');
|
|
|
|
if (moonCanvas.hasPointerCapture(event.pointerId)) {
|
|
moonCanvas.releasePointerCapture(event.pointerId);
|
|
}
|
|
}
|
|
|
|
datePicker.addEventListener('input', () => {
|
|
stopMoonPlayback();
|
|
updateMoon();
|
|
});
|
|
timeSlider.addEventListener('input', () => {
|
|
stopMoonPlayback();
|
|
updateMoon();
|
|
});
|
|
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);
|
|
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'; ?>
|