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

1798 lines
61 KiB
PHP

<?php
declare(strict_types=1);
session_start();
require_once __DIR__ . '/python_api.php';
$pageTitle = 'Dämmerungszeiten im Zeitraum - AstroTools';
$bodyClass = 'daemmerungszeiten-page';
$headerIntroPre = 'Astronomische Visualisierung';
$headerIntroTitle = 'Dämmerungszeiten im Zeitraum';
$headerIntroSub = 'Jeder Tag des gewählten Zeitraums als eigene Spur mit Nacht, astronomischer, nautischer und bürgerlicher Dämmerung sowie Sonnenstand.';
$availableLocations = [];
$pdo = null;
if (isset($_SESSION['user_id'])) {
try {
$dbConfig = require __DIR__ . '/../config/database.php';
$dsn = sprintf(
'mysql:host=%s;dbname=%s;charset=%s',
$dbConfig['host'],
$dbConfig['dbname'],
$dbConfig['charset'] ?? 'utf8mb4'
);
$pdo = new PDO($dsn, $dbConfig['user'], $dbConfig['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$locationStmt = $pdo->prepare(
"SELECT id, name, latitude, longitude, elevation, timezone, is_default
FROM app_user_locations
WHERE user_id = ?
ORDER BY is_default DESC, name ASC, id ASC"
);
$locationStmt->execute([(int) $_SESSION['user_id']]);
while ($row = $locationStmt->fetch()) {
if ($row['latitude'] === null || $row['longitude'] === null) {
continue;
}
$availableLocations[] = [
'id' => $row['id'] !== null ? (int) $row['id'] : null,
'name' => trim((string) ($row['name'] ?? 'Standort')),
'latitude' => (float) $row['latitude'],
'longitude' => (float) $row['longitude'],
'elevation' => $row['elevation'] !== null ? (float) $row['elevation'] : 0.0,
'timezone' => trim((string) ($row['timezone'] ?? 'Europe/Berlin')),
'is_default' => !empty($row['is_default']),
];
}
} catch (Throwable $e) {
$availableLocations = [];
$pdo = null;
}
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string) ($_POST['ajax'] ?? '') === '1') {
header('Content-Type: application/json; charset=utf-8');
$pythonResult = runPythonApiWithJsonPayloadFile('twilight_chart', [
'latitude' => (float) ($_POST['latitude'] ?? 0),
'longitude' => (float) ($_POST['longitude'] ?? 0),
'timeZone' => trim((string) ($_POST['timeZone'] ?? '')),
'startDate' => trim((string) ($_POST['startDate'] ?? '')),
'endDate' => trim((string) ($_POST['endDate'] ?? '')),
]);
if (!$pythonResult['ok']) {
echo json_encode([
'ok' => false,
'error' => (string) ($pythonResult['error'] ?? 'Python-Fehler'),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
echo json_encode($pythonResult['data'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
require __DIR__ . '/header.php';
$initialLocation = [
'id' => isset($currentLocation['id']) ? (int) $currentLocation['id'] : null,
'name' => $currentLocation['name'] ?? 'Sternwarte Sonneberg',
'latitude' => isset($currentLocation['latitude']) ? (float) $currentLocation['latitude'] : 50.3597,
'longitude' => isset($currentLocation['longitude']) ? (float) $currentLocation['longitude'] : 11.1907,
'timezone' => $currentLocation['timezone'] ?? 'Europe/Berlin',
];
if (!$availableLocations) {
$availableLocations[] = [
'id' => $initialLocation['id'],
'name' => $initialLocation['name'],
'latitude' => $initialLocation['latitude'],
'longitude' => $initialLocation['longitude'],
'elevation' => isset($currentLocation['elevation']) && $currentLocation['elevation'] !== null ? (float) $currentLocation['elevation'] : 0.0,
'timezone' => $initialLocation['timezone'],
'is_default' => true,
];
}
$defaultStartDate = date('Y-01-01');
$defaultEndDate = date('Y-12-31');
?>
<style>
body.daemmerungszeiten-page main > .container {
max-width: min(1700px, calc(100% - 32px));
}
.twilight-year-layout {
display: grid;
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
gap: 1.25rem;
align-items: start;
}
.twilight-controls,
.twilight-chart-card {
padding: 1.1rem;
}
.twilight-controls {
display: grid;
gap: 1rem;
}
.twilight-control-block {
display: grid;
gap: 0.75rem;
}
.twilight-control-block h2,
.twilight-chart-head h2 {
margin: 0;
color: var(--gold-pale);
font-size: 1rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.twilight-help {
color: var(--text-dim);
font-size: 0.92rem;
line-height: 1.65;
}
.twilight-form-grid {
display: grid;
gap: 0.75rem;
}
.twilight-field {
display: grid;
gap: 0.35rem;
}
.twilight-field label,
.twilight-meta-label {
color: var(--text-dim);
font-size: 0.78rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.twilight-field input,
.twilight-field select {
width: 100%;
padding: 0.7rem 0.8rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: rgba(8, 12, 24, 0.92);
color: var(--text);
font: inherit;
}
.twilight-field input:focus,
.twilight-field select:focus {
outline: none;
border-color: rgba(201, 168, 76, 0.5);
box-shadow: 0 0 0 3px rgba(201, 168, 76, 0.12);
}
.twilight-inline-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.twilight-button-row {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
}
.twilight-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.7rem 1rem;
border: 1px solid var(--border);
border-radius: 999px;
background: rgba(201, 168, 76, 0.08);
color: var(--gold-pale);
cursor: pointer;
font: inherit;
font-weight: 600;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
.twilight-btn:hover {
background: rgba(201, 168, 76, 0.16);
border-color: rgba(201, 168, 76, 0.42);
}
.twilight-btn.secondary {
background: rgba(79, 195, 216, 0.08);
border-color: rgba(79, 195, 216, 0.2);
color: #9fe6f1;
}
.twilight-btn.secondary:hover {
background: rgba(79, 195, 216, 0.14);
border-color: rgba(79, 195, 216, 0.38);
}
.twilight-btn:disabled {
opacity: 0.55;
cursor: wait;
}
.twilight-meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.twilight-meta-card {
padding: 0.85rem 0.9rem;
border: 1px solid rgba(201, 168, 76, 0.12);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.02);
}
.twilight-meta-value {
color: var(--text);
font-size: 0.98rem;
}
.twilight-chart-card {
display: grid;
gap: 0.9rem;
}
.twilight-chart-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.twilight-chart-sub {
color: var(--text-dim);
font-size: 0.92rem;
}
.twilight-status {
color: var(--gold-pale);
font-size: 0.9rem;
letter-spacing: 0.04em;
}
.twilight-chart-wrap {
position: relative;
border: 1px solid rgba(201, 168, 76, 0.16);
border-radius: calc(var(--radius) + 4px);
background:
radial-gradient(circle at top, rgba(79, 195, 216, 0.08), transparent 35%),
linear-gradient(180deg, rgba(9, 12, 24, 0.98) 0%, rgba(5, 8, 18, 0.98) 100%);
overflow: hidden;
}
.twilight-chart-canvas {
display: block;
width: 100%;
height: min(72vh, 860px);
}
.twilight-tooltip {
position: absolute;
min-width: 220px;
max-width: 280px;
padding: 0.75rem 0.85rem;
border: 1px solid rgba(201, 168, 76, 0.26);
border-radius: var(--radius);
background: rgba(5, 8, 18, 0.94);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
color: var(--text);
pointer-events: none;
opacity: 0;
transform: translate(12px, 12px);
transition: opacity 0.12s ease;
z-index: 3;
}
.twilight-tooltip.is-visible {
opacity: 1;
}
.twilight-tooltip strong {
display: block;
color: var(--gold-pale);
margin-bottom: 0.35rem;
}
.twilight-tooltip-line {
color: var(--text-dim);
font-size: 0.88rem;
line-height: 1.55;
}
.twilight-legend {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
}
.twilight-legend-item {
display: inline-flex;
align-items: center;
gap: 0.45rem;
color: var(--text-dim);
font-size: 0.88rem;
}
.twilight-legend-swatch {
width: 14px;
height: 14px;
border-radius: 3px;
border: 1px solid rgba(255, 255, 255, 0.14);
}
@media (max-width: 1080px) {
.twilight-year-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.twilight-inline-fields,
.twilight-meta-grid {
grid-template-columns: 1fr;
}
.twilight-chart-canvas {
height: 540px;
}
}
</style>
<div class="twilight-year-layout">
<section class="card twilight-controls">
<div class="twilight-control-block">
<h2>Standort und Zeitraum</h2>
<p class="twilight-help">
Die Grafik zeigt jeden Tag des gewählten Zeitraums als senkrechte Spur. Von oben nach unten läuft die lokale Uhrzeit des Standorts, farblich getrennt nach Nacht, astronomischer, nautischer und bürgerlicher Dämmerung sowie Tag.
</p>
<p class="twilight-help">
Die Dämmerungsgrenzen werden serverseitig mit <strong>Skyfield</strong> und lokalen JPL-Ephemeriden berechnet, damit besonders die Übergänge an der astronomischen Dämmerung möglichst präzise bleiben.
</p>
<div class="twilight-form-grid">
<div class="twilight-field">
<label for="presetLocation">Gespeicherte Standorte</label>
<select id="presetLocation">
<option value="">Standort auswählen</option>
</select>
</div>
<div class="twilight-field">
<label for="locationName">Ortsname</label>
<input id="locationName" type="text" value="<?= htmlspecialchars($initialLocation['name'], ENT_QUOTES, 'UTF-8') ?>">
</div>
<div class="twilight-inline-fields">
<div class="twilight-field">
<label for="timezone">Zeitzone</label>
<input id="timezone" type="text" list="timezoneSuggestions" value="<?= htmlspecialchars($initialLocation['timezone'], ENT_QUOTES, 'UTF-8') ?>">
</div>
</div>
<div class="twilight-inline-fields">
<div class="twilight-field">
<label for="startDate">Von</label>
<input id="startDate" type="date" value="<?= htmlspecialchars($defaultStartDate, ENT_QUOTES, 'UTF-8') ?>">
</div>
<div class="twilight-field">
<label for="endDate">Bis</label>
<input id="endDate" type="date" value="<?= htmlspecialchars($defaultEndDate, ENT_QUOTES, 'UTF-8') ?>">
</div>
</div>
<div class="twilight-inline-fields">
<div class="twilight-field">
<label for="lineOverlay">Linienoverlay</label>
<select id="lineOverlay">
<option value="on" selected>Einblenden</option>
<option value="off">Ausblenden</option>
</select>
</div>
<div class="twilight-field">
<label for="pngScaleSelect">PNG-Größe</label>
<select id="pngScaleSelect">
<option value="1">Standard</option>
<option value="2" selected>2x Export</option>
<option value="3">3x Export</option>
</select>
</div>
</div>
</div>
<div class="twilight-button-row">
<button class="twilight-btn" id="renderButton" type="button">Grafik neu berechnen</button>
<button class="twilight-btn secondary" id="exportPngButton" type="button">Als PNG exportieren</button>
</div>
<datalist id="timezoneSuggestions">
<option value="Europe/Berlin"></option>
<option value="Europe/Zurich"></option>
<option value="Europe/Vienna"></option>
<option value="Atlantic/Canary"></option>
<option value="Europe/London"></option>
<option value="America/New_York"></option>
<option value="America/Los_Angeles"></option>
<option value="America/Santiago"></option>
<option value="Asia/Tokyo"></option>
<option value="Australia/Sydney"></option>
</datalist>
</div>
<div class="twilight-control-block">
<h2>Aktuelle Auswertung</h2>
<div class="twilight-meta-grid">
<div class="twilight-meta-card">
<div class="twilight-meta-label">Standort</div>
<div class="twilight-meta-value" id="metaLocation">-</div>
</div>
<div class="twilight-meta-card">
<div class="twilight-meta-label">Zeitzone</div>
<div class="twilight-meta-value" id="metaTimezone">-</div>
</div>
<div class="twilight-meta-card">
<div class="twilight-meta-label">Längster Tag</div>
<div class="twilight-meta-value" id="metaLongestDay">-</div>
</div>
<div class="twilight-meta-card">
<div class="twilight-meta-label">Längste astronomische Nacht</div>
<div class="twilight-meta-value" id="metaLongestNight">-</div>
</div>
</div>
</div>
</section>
<section class="card twilight-chart-card">
<div class="twilight-chart-head">
<div>
<h2>Dämmerungsgrafik</h2>
<div class="twilight-chart-sub" id="chartSubline">Berechnung läuft für den gewählten Standort.</div>
</div>
<div class="twilight-status" id="renderStatus">Initialisiere …</div>
</div>
<div class="twilight-chart-wrap" id="chartWrap">
<canvas class="twilight-chart-canvas" id="twilightCanvas"></canvas>
<div class="twilight-tooltip" id="chartTooltip"></div>
</div>
<div class="twilight-legend" id="legend"></div>
</section>
</div>
<script>
const INITIAL_LOCATION = <?= json_encode($initialLocation, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const PRESET_LOCATIONS = <?= json_encode($availableLocations, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
/*
{ name: 'Sternwarte Sonneberg', latitude: 50.3597, longitude: 11.1907, timezone: 'Europe/Berlin' },
{ name: 'Berlin', latitude: 52.5200, longitude: 13.4050, timezone: 'Europe/Berlin' },
{ name: 'Hamburg', latitude: 53.5511, longitude: 9.9937, timezone: 'Europe/Berlin' },
{ name: 'Wien', latitude: 48.2082, longitude: 16.3738, timezone: 'Europe/Vienna' },
{ name: 'La Palma', latitude: 28.7540, longitude: -17.8890, timezone: 'Atlantic/Canary' },
{ name: 'Tromsø', latitude: 69.6492, longitude: 18.9553, timezone: 'Europe/Oslo' },
{ name: 'Santiago de Chile', latitude: -33.4489, longitude: -70.6693, timezone: 'America/Santiago' },
{ name: 'Tokio', latitude: 35.6764, longitude: 139.6500, timezone: 'Asia/Tokyo' }
];
*/
const PHASES = [
{ key: 'night', label: 'Astronomische Nacht', color: [7, 10, 22, 255] },
{ key: 'astronomical', label: 'Astronomische Dämmerung', color: [34, 64, 108, 255] },
{ key: 'nautical', label: 'Nautische Dämmerung', color: [88, 118, 160, 255] },
{ key: 'civil', label: 'Bürgerliche Dämmerung', color: [214, 138, 88, 255] },
{ key: 'day', label: 'Tag / Sonne über Horizont', color: [240, 208, 108, 255] }
];
const canvas = document.getElementById('twilightCanvas');
const tooltip = document.getElementById('chartTooltip');
const renderStatus = document.getElementById('renderStatus');
const chartSubline = document.getElementById('chartSubline');
const metaLocation = document.getElementById('metaLocation');
const metaTimezone = document.getElementById('metaTimezone');
const metaLongestDay = document.getElementById('metaLongestDay');
const metaLongestNight = document.getElementById('metaLongestNight');
const fieldPreset = document.getElementById('presetLocation');
const fieldName = document.getElementById('locationName');
const fieldTimezone = document.getElementById('timezone');
const fieldStartDate = document.getElementById('startDate');
const fieldEndDate = document.getElementById('endDate');
const fieldOverlay = document.getElementById('lineOverlay');
const fieldPngScale = document.getElementById('pngScaleSelect');
const renderButton = document.getElementById('renderButton');
const exportPngButton = document.getElementById('exportPngButton');
let currentRenderToken = 0;
let currentChart = null;
let chartMetrics = null;
let selectedLocation = { ...INITIAL_LOCATION };
function populatePresets() {
PRESET_LOCATIONS.forEach((preset, index) => {
const option = document.createElement('option');
option.value = String(index);
option.textContent = `${preset.name} · ${preset.timezone}`;
fieldPreset.appendChild(option);
});
}
function applyLocation(location) {
selectedLocation = {
...selectedLocation,
...location,
latitude: Number(location.latitude),
longitude: Number(location.longitude)
};
fieldName.value = location.name;
fieldTimezone.value = location.timezone;
}
function validateTimeZone(timeZone) {
try {
new Intl.DateTimeFormat('de-DE', { timeZone }).format(new Date());
return true;
} catch (error) {
return false;
}
}
function formatDateGerman(date, timeZone) {
return new Intl.DateTimeFormat('de-DE', {
timeZone,
day: '2-digit',
month: '2-digit',
year: 'numeric'
}).format(date);
}
function formatMonthShort(date, timeZone) {
return new Intl.DateTimeFormat('de-DE', {
timeZone,
month: 'short'
}).format(date);
}
function formatDayMonthShort(date, timeZone) {
return new Intl.DateTimeFormat('de-DE', {
timeZone,
day: '2-digit',
month: '2-digit'
}).format(date).replace(/\s/g, '');
}
function formatMonthYearShort(date, timeZone) {
return new Intl.DateTimeFormat('de-DE', {
timeZone,
month: 'short',
year: '2-digit'
}).format(date);
}
function formatDuration(minutes) {
const safeMinutes = Math.max(0, Math.round(minutes));
const hours = Math.floor(safeMinutes / 60);
const rest = safeMinutes % 60;
return `${hours} h ${String(rest).padStart(2, '0')} min`;
}
function getTimeZoneParts(date, timeZone) {
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23'
});
const parts = formatter.formatToParts(date);
const values = {};
for (const part of parts) {
if (part.type !== 'literal') {
values[part.type] = part.value;
}
}
return {
year: Number(values.year),
month: Number(values.month),
day: Number(values.day),
hour: Number(values.hour),
minute: Number(values.minute),
second: Number(values.second)
};
}
function zonedDateTimeToUtc(year, month, day, hour, minute, timeZone, second = 0) {
let utcMillis = Date.UTC(year, month - 1, day, hour, minute, second);
for (let iteration = 0; iteration < 5; iteration += 1) {
const probe = new Date(utcMillis);
const parts = getTimeZoneParts(probe, timeZone);
const representedMillis = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
const targetMillis = Date.UTC(year, month - 1, day, hour, minute, second);
const delta = representedMillis - targetMillis;
if (delta === 0) {
return probe;
}
utcMillis -= delta;
}
return new Date(utcMillis);
}
function parseIsoDate(isoDate) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const date = new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
if (
date.getUTCFullYear() !== year
|| date.getUTCMonth() !== month - 1
|| date.getUTCDate() !== day
) {
return null;
}
return date;
}
function formatIsoDate(date) {
return [
String(date.getUTCFullYear()).padStart(4, '0'),
String(date.getUTCMonth() + 1).padStart(2, '0'),
String(date.getUTCDate()).padStart(2, '0')
].join('-');
}
function buildDateRange(startDate, endDate) {
const days = [];
const cursor = new Date(startDate.getTime());
cursor.setUTCHours(12, 0, 0, 0);
while (cursor.getTime() <= endDate.getTime()) {
days.push(new Date(cursor.getTime()));
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return days;
}
function localDateFromDayIndex(dayDates, dayIndex) {
return dayDates[dayIndex];
}
function buildXAxisTicks(dayDates, timeZone) {
const totalDays = dayDates.length;
if (totalDays <= 1) {
return [{ dayIndex: 0, label: formatDateGerman(dayDates[0], timeZone) }];
}
if (totalDays <= 16) {
return dayDates.map((date, dayIndex) => ({
dayIndex,
label: formatDayMonthShort(date, timeZone)
}));
}
if (totalDays <= 45) {
return dayDates
.map((date, dayIndex) => ({ date, dayIndex }))
.filter((entry) => entry.dayIndex === 0 || entry.dayIndex === totalDays - 1 || entry.dayIndex % 7 === 0)
.map((entry) => ({
dayIndex: entry.dayIndex,
label: formatDayMonthShort(entry.date, timeZone)
}));
}
if (totalDays <= 120) {
return dayDates
.map((date, dayIndex) => ({ date, dayIndex }))
.filter((entry) => entry.dayIndex === 0 || entry.dayIndex === totalDays - 1 || entry.dayIndex % 14 === 0)
.map((entry) => ({
dayIndex: entry.dayIndex,
label: formatDayMonthShort(entry.date, timeZone)
}));
}
const monthTicks = dayDates
.map((date, dayIndex) => ({ date, dayIndex, parts: getTimeZoneParts(date, timeZone) }))
.filter((entry) => entry.dayIndex === 0 || entry.parts.day === 1)
.map((entry) => ({
dayIndex: entry.dayIndex,
label: totalDays <= 730
? formatMonthShort(entry.date, timeZone).replace('.', '')
: formatMonthYearShort(entry.date, timeZone).replace('.', '')
}));
if (totalDays <= 730) {
return monthTicks;
}
return monthTicks.filter((entry, index) => index === 0 || index === monthTicks.length - 1 || index % 3 === 0);
}
function sunAltitude(observer, utcDate) {
const equator = Astronomy.Equator(Astronomy.Body.Sun, utcDate, observer, true, true);
const horizon = Astronomy.Horizon(utcDate, observer, equator.ra, equator.dec, 'normal');
return horizon.altitude;
}
function phaseIndexFromAltitude(altitude) {
if (altitude >= 0) return 4;
if (altitude >= -6) return 3;
if (altitude >= -12) return 2;
if (altitude >= -18) return 1;
return 0;
}
const THRESHOLD_DEFINITIONS = [
{ key: 'day', altitude: 0 },
{ key: 'civil', altitude: -6 },
{ key: 'nautical', altitude: -12 },
{ key: 'astronomical', altitude: -18 }
];
const PREVIEW_SUPERSAMPLE = 3;
function altitudeAtLocalMinutes(observer, localParts, timeZone, totalMinutes) {
const hour = Math.floor(totalMinutes / 60);
const minuteFloat = totalMinutes - (hour * 60);
const minute = Math.floor(minuteFloat);
const second = Math.round((minuteFloat - minute) * 60);
const utcDate = zonedDateTimeToUtc(localParts.year, localParts.month, localParts.day, hour, minute, timeZone, second);
return sunAltitude(observer, utcDate);
}
function localMinutesFromUtcDate(utcDate, timeZone) {
const parts = getTimeZoneParts(utcDate, timeZone);
return (parts.hour * 60) + parts.minute + (parts.second / 60);
}
function isSameLocalDate(parts, localParts) {
return parts.year === localParts.year
&& parts.month === localParts.month
&& parts.day === localParts.day;
}
function searchAltitudeEvent(observer, localParts, timeZone, targetAltitude, direction) {
const startUtc = zonedDateTimeToUtc(localParts.year, localParts.month, localParts.day, 0, 0, timeZone);
try {
const result = Astronomy.SearchAltitude(
Astronomy.Body.Sun,
observer,
direction,
startUtc,
1.2,
targetAltitude
);
if (!result || !result.date) {
return null;
}
const eventParts = getTimeZoneParts(result.date, timeZone);
if (!isSameLocalDate(eventParts, localParts)) {
return null;
}
return localMinutesFromUtcDate(result.date, timeZone) / 60;
} catch (error) {
return null;
}
}
function sampleAltitudeRange(observer, localParts, timeZone) {
let minAltitude = Infinity;
let maxAltitude = -Infinity;
for (let totalMinutes = 0; totalMinutes <= 24 * 60; totalMinutes += 30) {
const altitude = altitudeAtLocalMinutes(observer, localParts, timeZone, totalMinutes);
minAltitude = Math.min(minAltitude, altitude);
maxAltitude = Math.max(maxAltitude, altitude);
}
return { minAltitude, maxAltitude };
}
function coverageIntervalForThreshold(observer, localParts, timeZone, targetAltitude, context = null) {
const evaluation = context || {
startAltitude: altitudeAtLocalMinutes(observer, localParts, timeZone, 0),
endAltitude: altitudeAtLocalMinutes(observer, localParts, timeZone, 24 * 60),
...sampleAltitudeRange(observer, localParts, timeZone)
};
const startAbove = evaluation.startAltitude >= targetAltitude;
const endAbove = evaluation.endAltitude >= targetAltitude;
const rise = searchAltitudeEvent(observer, localParts, timeZone, targetAltitude, +1);
const set = searchAltitudeEvent(observer, localParts, timeZone, targetAltitude, -1);
if (rise !== null && set !== null) {
return rise <= set
? { start: rise, end: set, full: false, empty: false, segments: [[rise, set]] }
: { start: rise, end: set, full: false, empty: false, wrap: true, segments: [[0, set], [rise, 24]] };
}
if (rise === null && set === null) {
return evaluation.minAltitude >= targetAltitude
? { start: 0, end: 24, full: true, empty: false, segments: [[0, 24]] }
: { start: null, end: null, full: false, empty: true, segments: [] };
}
if (set !== null && startAbove) {
return { start: 0, end: set, full: false, empty: false, segments: [[0, set]] };
}
if (rise !== null && endAbove) {
return { start: rise, end: 24, full: false, empty: false, segments: [[rise, 24]] };
}
return evaluation.minAltitude >= targetAltitude
? { start: 0, end: 24, full: true, empty: false, segments: [[0, 24]] }
: { start: null, end: null, full: false, empty: true, segments: [] };
}
function buildThresholdIntervals(observer, localParts, timeZone) {
const evaluation = {
startAltitude: altitudeAtLocalMinutes(observer, localParts, timeZone, 0),
endAltitude: altitudeAtLocalMinutes(observer, localParts, timeZone, 24 * 60),
...sampleAltitudeRange(observer, localParts, timeZone)
};
const intervals = {};
THRESHOLD_DEFINITIONS.forEach((definition) => {
intervals[definition.key] = coverageIntervalForThreshold(
observer,
localParts,
timeZone,
definition.altitude,
evaluation
);
});
intervals.bands = buildExclusiveBands(intervals);
return intervals;
}
function intervalSegments(interval) {
if (!interval || interval.empty) {
return [];
}
if (Array.isArray(interval.segments)) {
return interval.segments
.filter((segment) => Array.isArray(segment) && segment.length === 2)
.map((segment) => [segment[0], segment[1]]);
}
if (interval.start === null || interval.end === null) {
return [];
}
return [[interval.start, interval.end]];
}
function subtractSegmentLists(outerSegments, innerSegments) {
let result = cloneSegments(outerSegments);
innerSegments.forEach((inner) => {
const next = [];
result.forEach((outer) => {
if (inner[1] <= outer[0] || inner[0] >= outer[1]) {
next.push(outer);
return;
}
if (inner[0] > outer[0]) {
next.push([outer[0], Math.min(inner[0], outer[1])]);
}
if (inner[1] < outer[1]) {
next.push([Math.max(inner[1], outer[0]), outer[1]]);
}
});
result = next;
});
return result.filter((segment) => segment[1] - segment[0] > 0.01);
}
function buildExclusiveBands(dayCurve) {
return {
day: intervalSegments(dayCurve.day),
civil: subtractSegmentLists(intervalSegments(dayCurve.civil), intervalSegments(dayCurve.day)),
nautical: subtractSegmentLists(intervalSegments(dayCurve.nautical), intervalSegments(dayCurve.civil)),
astronomical: subtractSegmentLists(intervalSegments(dayCurve.astronomical), intervalSegments(dayCurve.nautical))
};
}
function intervalContainsHour(interval, hourValue) {
return intervalSegments(interval).some((segment) => hourValue >= segment[0] && hourValue < segment[1]);
}
function phaseIndexFromCurve(dayCurve, minutesOfDay) {
const hourValue = minutesOfDay / 60;
if (intervalContainsHour(dayCurve.day, hourValue)) return 4;
if (intervalContainsHour(dayCurve.civil, hourValue)) return 3;
if (intervalContainsHour(dayCurve.nautical, hourValue)) return 2;
if (intervalContainsHour(dayCurve.astronomical, hourValue)) return 1;
return 0;
}
function mixPhaseColors(leftPhaseIndex, rightPhaseIndex, blend) {
const leftColor = PHASES[leftPhaseIndex].color;
const rightColor = PHASES[rightPhaseIndex].color;
const inverseBlend = 1 - blend;
return [
Math.round((leftColor[0] * inverseBlend) + (rightColor[0] * blend)),
Math.round((leftColor[1] * inverseBlend) + (rightColor[1] * blend)),
Math.round((leftColor[2] * inverseBlend) + (rightColor[2] * blend)),
Math.round((leftColor[3] * inverseBlend) + (rightColor[3] * blend))
];
}
function cloneSegments(segments) {
return Array.isArray(segments) ? segments.map((segment) => [segment[0], segment[1]]) : [];
}
function interpolateSegmentList(startSegments, endSegments, blend) {
if (startSegments.length !== endSegments.length) {
return blend < 0.5 ? cloneSegments(startSegments) : cloneSegments(endSegments);
}
return startSegments.map((segment, index) => {
const target = endSegments[index];
return [
segment[0] + ((target[0] - segment[0]) * blend),
segment[1] + ((target[1] - segment[1]) * blend)
];
});
}
function interpolateInterval(startInterval, endInterval, blend) {
const startVisible = startInterval && !startInterval.empty && startInterval.start !== null && startInterval.end !== null;
const endVisible = endInterval && !endInterval.empty && endInterval.start !== null && endInterval.end !== null;
if (!startVisible && !endVisible) {
return { start: null, end: null, full: false, empty: true };
}
if (!startVisible || !endVisible) {
const source = blend < 0.5 ? startInterval : endInterval;
return source ? { ...source } : { start: null, end: null, full: false, empty: true };
}
if (!!startInterval.full !== !!endInterval.full) {
const source = blend < 0.5 ? startInterval : endInterval;
return { ...source };
}
return {
start: startInterval.start + ((endInterval.start - startInterval.start) * blend),
end: startInterval.end + ((endInterval.end - startInterval.end) * blend),
full: !!startInterval.full && !!endInterval.full,
empty: false
};
}
function interpolateDayCurve(curves, dayPosition) {
if (!Array.isArray(curves) || curves.length === 0) {
return null;
}
const clampedPosition = Math.max(0, Math.min(curves.length - 1, dayPosition));
const leftIndex = Math.floor(clampedPosition);
const rightIndex = Math.min(curves.length - 1, Math.ceil(clampedPosition));
if (leftIndex === rightIndex) {
return curves[leftIndex];
}
const blend = clampedPosition - leftIndex;
const leftCurve = curves[leftIndex];
const rightCurve = curves[rightIndex];
const interpolated = {};
THRESHOLD_DEFINITIONS.forEach((definition) => {
interpolated[definition.key] = interpolateInterval(
leftCurve[definition.key],
rightCurve[definition.key],
blend
);
});
interpolated.bands = {
day: interpolateSegmentList(leftCurve.bands.day, rightCurve.bands.day, blend),
civil: interpolateSegmentList(leftCurve.bands.civil, rightCurve.bands.civil, blend),
nautical: interpolateSegmentList(leftCurve.bands.nautical, rightCurve.bands.nautical, blend),
astronomical: interpolateSegmentList(leftCurve.bands.astronomical, rightCurve.bands.astronomical, blend)
};
return interpolated;
}
function fillLegend() {
const legend = document.getElementById('legend');
legend.innerHTML = '';
PHASES.forEach((phase) => {
const item = document.createElement('div');
item.className = 'twilight-legend-item';
const swatch = document.createElement('span');
swatch.className = 'twilight-legend-swatch';
swatch.style.backgroundColor = `rgba(${phase.color[0]}, ${phase.color[1]}, ${phase.color[2]}, ${phase.color[3] / 255})`;
const label = document.createElement('span');
label.textContent = phase.label;
item.appendChild(swatch);
item.appendChild(label);
legend.appendChild(item);
});
}
function readFormState() {
const latitude = Number(selectedLocation.latitude);
const longitude = Number(selectedLocation.longitude);
const stepMinutes = 1;
const timeZone = fieldTimezone.value.trim();
const name = fieldName.value.trim() || 'Eigener Standort';
const startDate = parseIsoDate(fieldStartDate.value);
const endDate = parseIsoDate(fieldEndDate.value);
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
throw new Error('Bitte einen gültigen Breitengrad zwischen -90 und 90 eingeben.');
}
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
throw new Error('Bitte einen gültigen Längengrad zwischen -180 und 180 eingeben.');
}
if (!startDate || !endDate) {
throw new Error('Bitte einen gültigen Zeitraum mit Start- und Enddatum wählen.');
}
if (startDate.getTime() > endDate.getTime()) {
throw new Error('Das Startdatum muss vor oder gleich dem Enddatum liegen.');
}
const totalDays = Math.round((endDate.getTime() - startDate.getTime()) / 86400000) + 1;
if (totalDays < 1) {
throw new Error('Bitte einen Zeitraum mit mindestens einem Tag wählen.');
}
if (!validateTimeZone(timeZone)) {
throw new Error('Die angegebene Zeitzone ist ungültig. Beispiel: Europe/Berlin');
}
return {
name,
latitude,
longitude,
timeZone,
startDateIso: formatIsoDate(startDate),
endDateIso: formatIsoDate(endDate),
dayDates: buildDateRange(startDate, endDate),
stepMinutes,
canvasQuality: 3,
pngScale: Number(fieldPngScale.value) || 1,
showLines: fieldOverlay.value === 'on'
};
}
function nextFrame() {
return new Promise((resolve) => requestAnimationFrame(resolve));
}
async function requestTwilightChartData(state) {
const formData = new FormData();
formData.set('ajax', '1');
formData.set('latitude', String(state.latitude));
formData.set('longitude', String(state.longitude));
formData.set('timeZone', state.timeZone);
formData.set('startDate', state.startDateIso);
formData.set('endDate', state.endDateIso);
const response = await fetch(window.location.href, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
if (!response.ok) {
throw new Error(`Serverfehler ${response.status} bei der Dämmerungsberechnung.`);
}
const payload = await response.json();
if (!payload || payload.ok !== true || !payload.result) {
throw new Error(payload && payload.error ? payload.error : 'Die Dämmerungsdaten konnten nicht geladen werden.');
}
return payload.result;
}
async function buildPixelCurves(chart, columns) {
const clampedColumns = Math.max(32, Math.floor(columns));
const pixelCurves = [];
for (let columnIndex = 0; columnIndex < clampedColumns; columnIndex += 1) {
const progress = clampedColumns === 1 ? 0 : columnIndex / (clampedColumns - 1);
const dayPosition = progress * (chart.totalDays - 1);
const dayIndex = Math.max(0, Math.min(chart.totalDays - 1, Math.round(dayPosition)));
pixelCurves.push(chart.dayCurves[dayIndex]);
if (columnIndex % 48 === 0) {
renderStatus.textContent = `Berechne Anzeige ${columnIndex + 1} / ${clampedColumns} …`;
await nextFrame();
}
}
return pixelCurves;
}
async function buildDisplayRaster(chart, columns, stepMinutes = 1) {
const width = Math.max(32, Math.floor(columns));
const rows = Math.floor((24 * 60) / stepMinutes);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = rows;
const context = canvas.getContext('2d');
const image = context.createImageData(width, rows);
const horizontalSamples = 4;
const verticalSamples = 2;
const sourceRows = chart.previewRows || rows;
const sourceStepMinutes = chart.previewStepMinutes || stepMinutes;
const sourceGrid = chart.previewPhaseGrid || chart.phaseGrid;
for (let columnIndex = 0; columnIndex < width; columnIndex += 1) {
for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
const offset = ((rowIndex * width) + columnIndex) * 4;
let red = 0;
let green = 0;
let blue = 0;
let alpha = 0;
const totalSamples = horizontalSamples * verticalSamples;
for (let horizontalIndex = 0; horizontalIndex < horizontalSamples; horizontalIndex += 1) {
const sampleX = (columnIndex + ((horizontalIndex + 0.5) / horizontalSamples)) / width;
const dayPosition = sampleX * (chart.totalDays - 1);
const leftDayIndex = Math.max(0, Math.min(chart.totalDays - 1, Math.floor(dayPosition)));
const rightDayIndex = Math.max(0, Math.min(chart.totalDays - 1, Math.ceil(dayPosition)));
const dayBlend = dayPosition - leftDayIndex;
for (let verticalIndex = 0; verticalIndex < verticalSamples; verticalIndex += 1) {
const sampleMinutes = (rowIndex + ((verticalIndex + 0.5) / verticalSamples)) * stepMinutes;
const sourceRowIndex = Math.max(
0,
Math.min(sourceRows - 1, Math.round(sampleMinutes / sourceStepMinutes))
);
const rowOffset = sourceRowIndex * chart.totalDays;
const leftPhaseIndex = sourceGrid[rowOffset + leftDayIndex];
const rightPhaseIndex = sourceGrid[rowOffset + rightDayIndex];
const color = leftDayIndex === rightDayIndex
? PHASES[leftPhaseIndex].color
: mixPhaseColors(leftPhaseIndex, rightPhaseIndex, dayBlend);
red += color[0];
green += color[1];
blue += color[2];
alpha += color[3];
}
}
image.data[offset] = Math.round(red / totalSamples);
image.data[offset + 1] = Math.round(green / totalSamples);
image.data[offset + 2] = Math.round(blue / totalSamples);
image.data[offset + 3] = Math.round(alpha / totalSamples);
}
if (columnIndex % 24 === 0) {
renderStatus.textContent = `Rastere Anzeige ${columnIndex + 1} / ${width} …`;
await nextFrame();
}
}
context.putImageData(image, 0, 0);
return {
canvas,
width,
rows,
stepMinutes
};
}
async function buildChartData(state, token) {
renderStatus.textContent = 'Lade Dämmerungsdaten …';
const serverChart = await requestTwilightChartData(state);
if (token !== currentRenderToken) {
return null;
}
const totalDays = Number(serverChart.totalDays) || state.dayDates.length;
const rows = Math.floor((24 * 60) / state.stepMinutes);
const dayCurves = Array.isArray(serverChart.dayCurves) ? serverChart.dayCurves : [];
const phaseGrid = new Uint8Array(totalDays * rows);
const imageCanvas = document.createElement('canvas');
imageCanvas.width = totalDays;
imageCanvas.height = rows;
const imageContext = imageCanvas.getContext('2d');
const image = imageContext.createImageData(totalDays, rows);
const xAxisTicks = buildXAxisTicks(state.dayDates, state.timeZone);
for (let dayIndex = 0; dayIndex < totalDays; dayIndex += 1) {
const dayCurve = dayCurves[dayIndex];
for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
const offset = rowIndex * totalDays + dayIndex;
const minutesOfDay = rowIndex * state.stepMinutes;
const phaseIndex = dayCurve ? phaseIndexFromCurve(dayCurve, minutesOfDay) : 0;
const imageOffset = offset * 4;
const color = PHASES[phaseIndex] ? PHASES[phaseIndex].color : PHASES[0].color;
phaseGrid[offset] = phaseIndex;
image.data[imageOffset] = color[0];
image.data[imageOffset + 1] = color[1];
image.data[imageOffset + 2] = color[2];
image.data[imageOffset + 3] = color[3];
}
if (dayIndex % 10 === 0) {
renderStatus.textContent = `Baue Raster ${dayIndex + 1} / ${totalDays} …`;
await nextFrame();
}
}
imageContext.putImageData(image, 0, 0);
return {
state,
totalDays,
rows,
phaseGrid,
previewPhaseGrid: phaseGrid,
previewRows: rows,
previewStepMinutes: state.stepMinutes,
dayCurves,
imageCanvas,
xAxisTicks,
longestDayIndex: Number(serverChart.longestDayIndex) || 0,
longestDayMinutes: Number(serverChart.longestDayMinutes) || 0,
longestNightIndex: Number(serverChart.longestNightIndex) || 0,
longestNightMinutes: Number(serverChart.longestNightMinutes) || 0
};
}
function drawChart(chart, targetContext = null, targetWidth = null, targetHeight = null, targetScale = null, backgroundFill = null) {
const context = targetContext || canvas.getContext('2d');
const width = targetWidth || canvas.clientWidth;
const height = targetHeight || canvas.clientHeight;
const pixelRatio = targetScale || ((window.devicePixelRatio || 1) * chart.state.canvasQuality);
if (targetContext === null) {
canvas.width = Math.floor(width * pixelRatio);
canvas.height = Math.floor(height * pixelRatio);
} else {
context.canvas.width = Math.floor(width * pixelRatio);
context.canvas.height = Math.floor(height * pixelRatio);
}
context.setTransform(1, 0, 0, 1, 0, 0);
context.scale(pixelRatio, pixelRatio);
context.clearRect(0, 0, width, height);
if (backgroundFill) {
context.fillStyle = backgroundFill;
context.fillRect(0, 0, width, height);
}
const margin = { top: 22, right: 18, bottom: 48, left: 70 };
const plotWidth = Math.max(10, width - margin.left - margin.right);
const plotHeight = Math.max(10, height - margin.top - margin.bottom);
chartMetrics = { margin, plotWidth, plotHeight };
context.fillStyle = 'rgba(255,255,255,0.02)';
context.fillRect(margin.left, margin.top, plotWidth, plotHeight);
context.strokeStyle = 'rgba(201, 168, 76, 0.16)';
context.lineWidth = 1;
context.strokeRect(margin.left + 0.5, margin.top + 0.5, plotWidth - 1, plotHeight - 1);
drawRasterBands(context, chart, margin, plotWidth, plotHeight);
context.save();
context.strokeStyle = 'rgba(255,255,255,0.08)';
context.fillStyle = 'rgba(221, 227, 245, 0.72)';
context.font = '12px Segoe UI, sans-serif';
context.textAlign = 'right';
context.textBaseline = 'middle';
for (let hour = 0; hour <= 24; hour += 2) {
const y = margin.top + (hour / 24) * plotHeight;
context.beginPath();
context.moveTo(margin.left, y);
context.lineTo(margin.left + plotWidth, y);
context.stroke();
context.fillText(`${String(hour).padStart(2, '0')}:00`, margin.left - 10, y);
}
context.textAlign = 'center';
context.textBaseline = 'top';
chart.xAxisTicks.forEach((tick) => {
const x = margin.left + (tick.dayIndex / chart.totalDays) * plotWidth;
context.beginPath();
context.moveTo(x, margin.top);
context.lineTo(x, margin.top + plotHeight);
context.stroke();
const labelX = Math.min(margin.left + plotWidth - 24, Math.max(margin.left + 24, x));
context.fillText(tick.label, labelX, margin.top + plotHeight + 10);
});
if (chart.state.showLines) {
drawThresholdLines(context, chart, margin, plotWidth, plotHeight);
}
context.restore();
}
function drawRasterBands(context, chart, margin, plotWidth, plotHeight) {
context.save();
context.beginPath();
context.rect(margin.left, margin.top, plotWidth, plotHeight);
context.clip();
const sourceCanvas = chart.displayRaster && chart.displayRaster.canvas
? chart.displayRaster.canvas
: chart.imageCanvas;
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.drawImage(sourceCanvas, margin.left, margin.top, plotWidth, plotHeight);
context.restore();
}
function intervalToPoints(interval, x, margin, plotHeight) {
if (!interval || interval.empty || interval.start === null || interval.end === null) {
return null;
}
return {
x,
topY: margin.top + (interval.start / 24) * plotHeight,
bottomY: margin.top + (interval.end / 24) * plotHeight
};
}
function strokePointSeries(context, points) {
if (points.length === 0) {
return;
}
context.moveTo(points[0].x, points[0].y);
for (let index = 1; index < points.length; index += 1) {
context.lineTo(points[index].x, points[index].y);
}
}
function strokePointRuns(context, points, maxJumpPixels) {
if (points.length < 2) {
return;
}
let run = [points[0]];
for (let index = 1; index < points.length; index += 1) {
const point = points[index];
const previous = points[index - 1];
if (Math.abs(point.y - previous.y) > maxJumpPixels) {
if (run.length > 1) {
context.beginPath();
strokePointSeries(context, run);
context.stroke();
}
run = [point];
continue;
}
run.push(point);
}
if (run.length > 1) {
context.beginPath();
strokePointSeries(context, run);
context.stroke();
}
}
function fillPixelCurveSegment(context, points, fillStyle) {
if (points.length < 2) {
return;
}
context.beginPath();
context.moveTo(points[0].x, points[0].topY);
for (let index = 1; index < points.length; index += 1) {
context.lineTo(points[index].x, points[index].topY);
}
for (let index = points.length - 1; index >= 0; index -= 1) {
context.lineTo(points[index].x, points[index].bottomY);
}
context.closePath();
context.fillStyle = fillStyle;
context.fill();
}
function drawPixelCurveBands(context, chart, margin, plotWidth, plotHeight, bandKey, fillStyle, maxSegments) {
const curves = Array.isArray(chart.pixelCurves) && chart.pixelCurves.length > 1
? chart.pixelCurves
: chart.dayCurves;
const divisor = Math.max(1, curves.length - 1);
for (let segmentIndex = 0; segmentIndex < maxSegments; segmentIndex += 1) {
const points = [];
for (let curveIndex = 0; curveIndex < curves.length; curveIndex += 1) {
const bandSegments = curves[curveIndex].bands[bandKey] || [];
const band = bandSegments[segmentIndex] || null;
if (!band) {
fillPixelCurveSegment(context, points, fillStyle);
points.length = 0;
continue;
}
points.push({
x: margin.left + (curveIndex / divisor) * plotWidth,
topY: margin.top + (band[0] / 24) * plotHeight,
bottomY: margin.top + (band[1] / 24) * plotHeight
});
}
fillPixelCurveSegment(context, points, fillStyle);
}
}
function drawThresholdLines(context, chart, margin, plotWidth, plotHeight) {
const thresholds = [
{ phaseIndex: 4, color: 'rgba(255, 250, 220, 0.85)', width: 1.4 },
{ phaseIndex: 3, color: 'rgba(255, 184, 134, 0.55)', width: 1 },
{ phaseIndex: 2, color: 'rgba(165, 202, 255, 0.4)', width: 1 },
{ phaseIndex: 1, color: 'rgba(126, 162, 222, 0.3)', width: 1 }
];
const curves = Array.isArray(chart.pixelCurves) && chart.pixelCurves.length > 1
? chart.pixelCurves
: chart.dayCurves;
const curveKeyByPhaseIndex = {
4: 'day',
3: 'civil',
2: 'nautical',
1: 'astronomical'
};
const divisor = Math.max(1, curves.length - 1);
const maxJumpPixels = (1.5 / 24) * plotHeight;
thresholds.forEach((threshold) => {
context.strokeStyle = threshold.color;
context.lineWidth = threshold.width;
for (let segmentIndex = 0; segmentIndex < 2; segmentIndex += 1) {
const topPoints = [];
const bottomPoints = [];
for (let curveIndex = 0; curveIndex < curves.length; curveIndex += 1) {
const segments = intervalSegments(curves[curveIndex][curveKeyByPhaseIndex[threshold.phaseIndex]]);
const segment = segments[segmentIndex] || null;
if (!segment) {
strokePointRuns(context, topPoints, maxJumpPixels);
strokePointRuns(context, bottomPoints, maxJumpPixels);
topPoints.length = 0;
bottomPoints.length = 0;
continue;
}
const x = margin.left + (curveIndex / divisor) * plotWidth;
topPoints.push({
x,
y: margin.top + (segment[0] / 24) * plotHeight
});
bottomPoints.push({
x,
y: margin.top + (segment[1] / 24) * plotHeight
});
}
strokePointRuns(context, topPoints, maxJumpPixels);
strokePointRuns(context, bottomPoints, maxJumpPixels);
}
});
}
function updateSummary(chart) {
const longestDayDate = localDateFromDayIndex(chart.state.dayDates, chart.longestDayIndex);
const longestNightDate = localDateFromDayIndex(chart.state.dayDates, chart.longestNightIndex);
metaLocation.textContent = `${chart.state.name} · ${chart.state.latitude.toFixed(2)}°, ${chart.state.longitude.toFixed(2)}°`;
metaTimezone.textContent = chart.state.timeZone;
metaLongestDay.textContent = `${formatDateGerman(longestDayDate, chart.state.timeZone)} · ${formatDuration(chart.longestDayMinutes)}`;
metaLongestNight.textContent = `${formatDateGerman(longestNightDate, chart.state.timeZone)} · ${formatDuration(chart.longestNightMinutes)}`;
chartSubline.textContent = `${chart.state.name} · ${formatDateGerman(chart.state.dayDates[0], chart.state.timeZone)} bis ${formatDateGerman(chart.state.dayDates[chart.totalDays - 1], chart.state.timeZone)} · lokale Uhrzeit in ${chart.state.timeZone}`;
}
function phaseColorCss(phaseIndex, alpha) {
const color = PHASES[phaseIndex].color;
return `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${alpha})`;
}
function drawExportHeader(context, width, chart) {
const title = 'Dämmerungszeiten';
const subtitle = `${formatDateGerman(chart.state.dayDates[0], chart.state.timeZone)} bis ${formatDateGerman(chart.state.dayDates[chart.totalDays - 1], chart.state.timeZone)}`;
const locationLine = `Standort: ${chart.state.name} · ${chart.state.latitude.toFixed(4)}°, ${chart.state.longitude.toFixed(4)}°`;
context.save();
context.textAlign = 'center';
context.textBaseline = 'top';
context.fillStyle = '#f1d88a';
context.font = '700 24px Segoe UI, sans-serif';
context.fillText(title, width / 2, 18);
context.fillStyle = 'rgba(221, 227, 245, 0.80)';
context.font = '14px Segoe UI, sans-serif';
context.fillText(subtitle, width / 2, 50);
context.fillStyle = 'rgba(221, 227, 245, 0.68)';
context.font = '13px Segoe UI, sans-serif';
context.fillText(locationLine, width / 2, 72);
context.restore();
}
function drawExportLegend(context, width, startY) {
const items = PHASES.map((phase) => ({
label: phase.label,
color: `rgba(${phase.color[0]}, ${phase.color[1]}, ${phase.color[2]}, ${phase.color[3] / 255})`
}));
const paddingX = 20;
const rowGap = 14;
const itemGap = 18;
const swatchSize = 14;
const textGap = 8;
const lineHeight = 18;
context.save();
context.font = '14px Segoe UI, sans-serif';
context.textAlign = 'left';
context.textBaseline = 'middle';
let cursorX = paddingX;
let cursorY = startY + 16;
items.forEach((item) => {
const textWidth = context.measureText(item.label).width;
const itemWidth = swatchSize + textGap + textWidth;
if (cursorX + itemWidth > width - paddingX) {
cursorX = paddingX;
cursorY += lineHeight + rowGap;
}
context.fillStyle = item.color;
context.strokeStyle = 'rgba(255,255,255,0.14)';
context.lineWidth = 1;
context.beginPath();
context.roundRect(cursorX, cursorY - (swatchSize / 2), swatchSize, swatchSize, 3);
context.fill();
context.stroke();
context.fillStyle = 'rgba(221, 227, 245, 0.82)';
context.fillText(item.label, cursorX + swatchSize + textGap, cursorY);
cursorX += itemWidth + itemGap;
});
context.restore();
return cursorY + 16;
}
function downloadBlob(filename, blob) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function exportCurrentChartAsPng() {
if (!currentChart) {
alert('Es ist noch keine Grafik berechnet.');
return;
}
const scale = Math.max(1, Number(currentChart.state.pngScale) || 1);
const fileNameBase = `${currentChart.state.name}-${currentChart.state.startDateIso}-${currentChart.state.endDateIso}`
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const headerHeight = 98;
const legendHeight = 92;
const chartCanvas = document.createElement('canvas');
chartCanvas.width = Math.max(1, Math.floor(canvas.clientWidth * scale));
chartCanvas.height = Math.max(1, Math.floor(canvas.clientHeight * scale));
const chartContext = chartCanvas.getContext('2d');
const exportCanvas = document.createElement('canvas');
exportCanvas.width = Math.max(1, Math.floor(canvas.clientWidth * scale));
exportCanvas.height = Math.max(1, Math.floor((headerHeight + canvas.clientHeight + legendHeight) * scale));
const exportContext = exportCanvas.getContext('2d');
chartContext.scale(scale, scale);
drawChart(currentChart, chartContext, canvas.clientWidth, canvas.clientHeight, scale, '#121726');
exportContext.scale(scale, scale);
exportContext.fillStyle = '#121726';
exportContext.fillRect(0, 0, canvas.clientWidth, headerHeight + canvas.clientHeight + legendHeight);
drawExportHeader(exportContext, canvas.clientWidth, currentChart);
exportContext.drawImage(chartCanvas, 0, headerHeight, canvas.clientWidth, canvas.clientHeight);
drawExportLegend(exportContext, canvas.clientWidth, headerHeight + canvas.clientHeight + 10);
exportCanvas.toBlob((blob) => {
if (!blob) {
alert('PNG-Export konnte nicht erstellt werden.');
return;
}
downloadBlob(`${fileNameBase || 'daemmerungszeiten'}.png`, blob);
drawChart(currentChart);
}, 'image/png');
}
async function renderTwilightChart() {
let state;
try {
state = readFormState();
} catch (error) {
renderStatus.textContent = 'Eingabe prüfen';
alert(error.message);
return;
}
currentRenderToken += 1;
const token = currentRenderToken;
renderStatus.textContent = 'Berechnung startet …';
renderButton.disabled = true;
exportPngButton.disabled = true;
try {
const chart = await buildChartData(state, token);
if (!chart || token !== currentRenderToken) {
return;
}
const previewWidth = Math.max(320, canvas.clientWidth - 88);
const sampledPreviewWidth = Math.max(previewWidth, Math.round(previewWidth * PREVIEW_SUPERSAMPLE));
chart.pixelCurves = await buildPixelCurves(chart, sampledPreviewWidth);
if (token !== currentRenderToken) {
return;
}
chart.displayRaster = await buildDisplayRaster(chart, sampledPreviewWidth, 1);
if (token !== currentRenderToken) {
return;
}
currentChart = chart;
updateSummary(chart);
drawChart(chart);
renderStatus.textContent = 'Fertig berechnet';
} catch (error) {
renderStatus.textContent = 'Fehler bei der Berechnung';
alert(error instanceof Error ? error.message : 'Unbekannter Fehler bei der Berechnung.');
} finally {
if (token === currentRenderToken) {
renderButton.disabled = false;
exportPngButton.disabled = false;
}
}
}
function showTooltip(event) {
if (!currentChart || !chartMetrics) {
return;
}
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const { margin, plotWidth, plotHeight } = chartMetrics;
if (x < margin.left || x > margin.left + plotWidth || y < margin.top || y > margin.top + plotHeight) {
tooltip.classList.remove('is-visible');
return;
}
const normalizedX = (x - margin.left) / plotWidth;
const normalizedY = (y - margin.top) / plotHeight;
const dayIndex = Math.min(currentChart.totalDays - 1, Math.max(0, Math.floor(normalizedX * currentChart.totalDays)));
const rowIndex = Math.min(currentChart.rows - 1, Math.max(0, Math.floor(normalizedY * currentChart.rows)));
const phaseIndex = currentChart.phaseGrid[rowIndex * currentChart.totalDays + dayIndex];
const minutesOfDay = rowIndex * currentChart.state.stepMinutes;
const hour = Math.floor(minutesOfDay / 60);
const minute = minutesOfDay % 60;
const localDate = localDateFromDayIndex(currentChart.state.dayDates, dayIndex);
tooltip.innerHTML = `
<strong>${formatDateGerman(localDate, currentChart.state.timeZone)}</strong>
<div class="twilight-tooltip-line">${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')} Uhr lokal</div>
<div class="twilight-tooltip-line">${PHASES[phaseIndex].label}</div>
`;
tooltip.style.left = `${Math.min(rect.width - 240, Math.max(10, x + 10))}px`;
tooltip.style.top = `${Math.min(rect.height - 110, Math.max(10, y + 10))}px`;
tooltip.classList.add('is-visible');
}
function hideTooltip() {
tooltip.classList.remove('is-visible');
}
fieldPreset.addEventListener('change', () => {
const index = Number(fieldPreset.value);
if (Number.isInteger(index) && PRESET_LOCATIONS[index]) {
applyLocation(PRESET_LOCATIONS[index]);
}
});
renderButton.addEventListener('click', renderTwilightChart);
exportPngButton.addEventListener('click', exportCurrentChartAsPng);
canvas.addEventListener('mousemove', showTooltip);
canvas.addEventListener('mouseleave', hideTooltip);
window.addEventListener('resize', () => {
if (currentChart) {
drawChart(currentChart);
}
});
populatePresets();
fillLegend();
applyLocation(INITIAL_LOCATION);
renderTwilightChart();
</script>
<?php require __DIR__ . '/footer.php'; ?>