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

1615 lines
55 KiB
PHP

<?php
declare(strict_types=1);
session_start();
$pageTitle = 'Dämmerungszeiten über das Jahr - AstroTools';
$bodyClass = 'daemmerungszeiten-page';
$headerIntroPre = 'Astronomische Visualisierung';
$headerIntroTitle = 'Dämmerungszeiten über das Jahr';
$headerIntroSub = 'Jeder Tag des Jahres 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;
}
}
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,
];
}
?>
<script src="js/astronomy.browser.min.js"></script>
<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 Jahr</h2>
<p class="twilight-help">
Die Grafik zeigt jeden Tag des gewählten Jahres 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>
<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 class="twilight-field">
<label for="yearSelect">Jahr</label>
<input id="yearSelect" type="number" min="1900" max="2200" value="<?= htmlspecialchars((string) date('Y'), ENT_QUOTES, 'UTF-8') ?>">
</div>
</div>
<div class="twilight-inline-fields">
<div class="twilight-field">
<label for="resolutionInfo">Auswertungsraster</label>
<input id="resolutionInfo" type="text" value="1 Minute (fest)" readonly>
</div>
<div class="twilight-field">
<label for="canvasQualitySelect">Canvas-Qualität</label>
<select id="canvasQualitySelect">
<option value="1">1x</option>
<option value="2" selected>2x</option>
<option value="3">3x</option>
</select>
</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="geoButton" type="button">Browser-Standort</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 fieldYear = document.getElementById('yearSelect');
const fieldCanvasQuality = document.getElementById('canvasQualitySelect');
const fieldOverlay = document.getElementById('lineOverlay');
const fieldPngScale = document.getElementById('pngScaleSelect');
const renderButton = document.getElementById('renderButton');
const geoButton = document.getElementById('geoButton');
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 isLeapYear(year) {
return (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0);
}
function daysInYear(year) {
return isLeapYear(year) ? 366 : 365;
}
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 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 localDateFromDayIndex(year, dayIndex) {
return new Date(Date.UTC(year, 0, dayIndex + 1, 12, 0, 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 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 year = Number(fieldYear.value);
const stepMinutes = 1;
const timeZone = fieldTimezone.value.trim();
const name = fieldName.value.trim() || 'Eigener Standort';
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 (!Number.isInteger(year) || year < 1900 || year > 2200) {
throw new Error('Bitte ein gültiges Jahr zwischen 1900 und 2200 wählen.');
}
if (!validateTimeZone(timeZone)) {
throw new Error('Die angegebene Zeitzone ist ungültig. Beispiel: Europe/Berlin');
}
return {
name,
latitude,
longitude,
timeZone,
year,
stepMinutes,
canvasQuality: Number(fieldCanvasQuality.value) || 1,
pngScale: Number(fieldPngScale.value) || 1,
showLines: fieldOverlay.value === 'on'
};
}
function nextFrame() {
return new Promise((resolve) => requestAnimationFrame(resolve));
}
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 dayIndex = Math.max(0, Math.min(chart.totalDays - 1, Math.round(dayPosition)));
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 phaseIndex = sourceGrid[(sourceRowIndex * chart.totalDays) + dayIndex];
const color = PHASES[phaseIndex].color;
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) {
const totalDays = daysInYear(state.year);
const rows = Math.floor((24 * 60) / state.stepMinutes);
const observer = new Astronomy.Observer(state.latitude, state.longitude, 0);
const phaseGrid = new Uint8Array(totalDays * rows);
const dayCurves = [];
const imageCanvas = document.createElement('canvas');
imageCanvas.width = totalDays;
imageCanvas.height = rows;
const imageContext = imageCanvas.getContext('2d');
const image = imageContext.createImageData(totalDays, rows);
const monthBoundaries = [];
let longestDayMinutes = -1;
let longestDayIndex = 0;
let longestNightMinutes = -1;
let longestNightIndex = 0;
for (let dayIndex = 0; dayIndex < totalDays; dayIndex += 1) {
if (token !== currentRenderToken) {
return null;
}
const localDate = localDateFromDayIndex(state.year, dayIndex);
const localParts = getTimeZoneParts(localDate, state.timeZone);
if (localParts.day === 1) {
monthBoundaries.push({ dayIndex, label: formatMonthShort(localDate, state.timeZone) });
}
const dayCurve = buildThresholdIntervals(observer, localParts, state.timeZone);
dayCurves.push(dayCurve);
let dayMinutes = 0;
let astroNightMinutes = 0;
for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
const minutesOfDay = rowIndex * state.stepMinutes;
const phaseIndex = phaseIndexFromCurve(dayCurve, minutesOfDay);
const offset = rowIndex * totalDays + dayIndex;
const imageOffset = offset * 4;
const color = PHASES[phaseIndex].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 (phaseIndex === 4) {
dayMinutes += state.stepMinutes;
}
if (phaseIndex === 0) {
astroNightMinutes += state.stepMinutes;
}
}
if (dayMinutes > longestDayMinutes) {
longestDayMinutes = dayMinutes;
longestDayIndex = dayIndex;
}
if (astroNightMinutes > longestNightMinutes) {
longestNightMinutes = astroNightMinutes;
longestNightIndex = dayIndex;
}
if (dayIndex % 8 === 0) {
renderStatus.textContent = `Berechne ${dayIndex + 1} / ${totalDays} Tage …`;
await nextFrame();
}
}
imageContext.putImageData(image, 0, 0);
return {
state,
totalDays,
rows,
observer,
phaseGrid,
previewPhaseGrid: phaseGrid,
previewRows: rows,
previewStepMinutes: state.stepMinutes,
dayCurves,
imageCanvas,
monthBoundaries,
longestDayIndex,
longestDayMinutes,
longestNightIndex,
longestNightMinutes
};
}
function drawChart(chart, targetContext = null, targetWidth = null, targetHeight = null, targetScale = 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);
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.monthBoundaries.forEach((boundary, index) => {
const x = margin.left + (boundary.dayIndex / chart.totalDays) * plotWidth;
context.beginPath();
context.moveTo(x, margin.top);
context.lineTo(x, margin.top + plotHeight);
context.stroke();
const labelX = index === chart.monthBoundaries.length - 1
? margin.left + plotWidth - 10
: x + 8;
context.fillText(boundary.label.replace('.', ''), 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 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);
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) {
if (topPoints.length > 1) {
context.beginPath();
topPoints.forEach((point, index) => {
if (index === 0) {
context.moveTo(point.x, point.y);
} else {
context.lineTo(point.x, point.y);
}
});
context.stroke();
}
if (bottomPoints.length > 1) {
context.beginPath();
bottomPoints.forEach((point, index) => {
if (index === 0) {
context.moveTo(point.x, point.y);
} else {
context.lineTo(point.x, point.y);
}
});
context.stroke();
}
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
});
}
if (topPoints.length > 1) {
context.beginPath();
topPoints.forEach((point, index) => {
if (index === 0) {
context.moveTo(point.x, point.y);
} else {
context.lineTo(point.x, point.y);
}
});
context.stroke();
}
if (bottomPoints.length > 1) {
context.beginPath();
bottomPoints.forEach((point, index) => {
if (index === 0) {
context.moveTo(point.x, point.y);
} else {
context.lineTo(point.x, point.y);
}
});
context.stroke();
}
}
});
}
function updateSummary(chart) {
const longestDayDate = localDateFromDayIndex(chart.state.year, chart.longestDayIndex);
const longestNightDate = localDateFromDayIndex(chart.state.year, 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} · ${chart.state.year} · 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 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.year}`
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const exportCanvas = document.createElement('canvas');
exportCanvas.width = Math.max(1, Math.floor(canvas.clientWidth * scale));
exportCanvas.height = Math.max(1, Math.floor(canvas.clientHeight * scale));
const exportContext = exportCanvas.getContext('2d');
exportContext.scale(scale, scale);
drawChart(currentChart, exportContext, canvas.clientWidth, canvas.clientHeight, scale);
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;
geoButton.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;
geoButton.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.year, dayIndex);
const localParts = getTimeZoneParts(localDate, currentChart.state.timeZone);
const utcDate = zonedDateTimeToUtc(localParts.year, localParts.month, localParts.day, hour, minute, currentChart.state.timeZone);
const altitude = sunAltitude(currentChart.observer, utcDate);
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>
<div class="twilight-tooltip-line">Sonnenhöhe: ${altitude.toFixed(1)}°</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);
geoButton.addEventListener('click', () => {
if (!navigator.geolocation) {
alert('Die Standortbestimmung wird von diesem Browser nicht unterstützt.');
return;
}
geoButton.disabled = true;
renderStatus.textContent = 'Standort wird ermittelt …';
navigator.geolocation.getCurrentPosition(
(position) => {
selectedLocation = {
...selectedLocation,
latitude: Number(position.coords.latitude.toFixed(4)),
longitude: Number(position.coords.longitude.toFixed(4))
};
fieldPreset.value = '';
fieldName.value = 'Browser-Standort';
fieldTimezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone || 'Europe/Berlin';
geoButton.disabled = false;
renderTwilightChart();
},
(error) => {
geoButton.disabled = false;
renderStatus.textContent = 'Standort konnte nicht geladen werden';
alert(`Standort konnte nicht ermittelt werden: ${error.message}`);
},
{ enableHighAccuracy: true, timeout: 10000 }
);
});
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'; ?>