Dämmerungsgrafik auf Zeiträume erweitern

This commit is contained in:
2026-07-13 14:45:25 +02:00
parent 368d10f1e7
commit d02a621a4e
+85 -30
View File
@@ -3,11 +3,11 @@ declare(strict_types=1);
session_start();
$pageTitle = 'Dämmerungszeiten über das Jahr - AstroTools';
$pageTitle = 'Dämmerungszeiten im Zeitraum - 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.';
$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;
@@ -76,6 +76,9 @@ if (!$availableLocations) {
'is_default' => true,
];
}
$defaultStartDate = date('Y-01-01');
$defaultEndDate = date('Y-12-31');
?>
<script src="js/astronomy.browser.min.js"></script>
@@ -340,9 +343,9 @@ body.daemmerungszeiten-page main > .container {
<div class="twilight-year-layout">
<section class="card twilight-controls">
<div class="twilight-control-block">
<h2>Standort und Jahr</h2>
<h2>Standort und Zeitraum</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.
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>
<div class="twilight-form-grid">
<div class="twilight-field">
@@ -361,8 +364,12 @@ body.daemmerungszeiten-page main > .container {
<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') ?>">
<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">
@@ -479,7 +486,8 @@ 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 fieldStartDate = document.getElementById('startDate');
const fieldEndDate = document.getElementById('endDate');
const fieldOverlay = document.getElementById('lineOverlay');
const fieldPngScale = document.getElementById('pngScaleSelect');
const renderButton = document.getElementById('renderButton');
@@ -520,14 +528,6 @@ function validateTimeZone(timeZone) {
}
}
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,
@@ -602,8 +602,51 @@ function zonedDateTimeToUtc(year, month, day, hour, minute, timeZone, second = 0
return new Date(utcMillis);
}
function localDateFromDayIndex(year, dayIndex) {
return new Date(Date.UTC(year, 0, dayIndex + 1, 12, 0, 0));
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 sunAltitude(observer, utcDate) {
@@ -918,10 +961,11 @@ function fillLegend() {
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';
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.');
@@ -931,8 +975,17 @@ function readFormState() {
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 (!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)) {
@@ -944,7 +997,9 @@ function readFormState() {
latitude,
longitude,
timeZone,
year,
startDateIso: formatIsoDate(startDate),
endDateIso: formatIsoDate(endDate),
dayDates: buildDateRange(startDate, endDate),
stepMinutes,
canvasQuality: 3,
pngScale: Number(fieldPngScale.value) || 1,
@@ -1042,7 +1097,7 @@ async function buildDisplayRaster(chart, columns, stepMinutes = 1) {
}
async function buildChartData(state, token) {
const totalDays = daysInYear(state.year);
const totalDays = state.dayDates.length;
const rows = Math.floor((24 * 60) / state.stepMinutes);
const observer = new Astronomy.Observer(state.latitude, state.longitude, 0);
const phaseGrid = new Uint8Array(totalDays * rows);
@@ -1063,9 +1118,9 @@ async function buildChartData(state, token) {
return null;
}
const localDate = localDateFromDayIndex(state.year, dayIndex);
const localDate = localDateFromDayIndex(state.dayDates, dayIndex);
const localParts = getTimeZoneParts(localDate, state.timeZone);
if (localParts.day === 1) {
if (dayIndex === 0 || localParts.day === 1) {
monthBoundaries.push({ dayIndex, label: formatMonthShort(localDate, state.timeZone) });
}
@@ -1396,14 +1451,14 @@ function drawThresholdLines(context, chart, margin, plotWidth, plotHeight) {
}
function updateSummary(chart) {
const longestDayDate = localDateFromDayIndex(chart.state.year, chart.longestDayIndex);
const longestNightDate = localDateFromDayIndex(chart.state.year, chart.longestNightIndex);
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} · ${chart.state.year} · lokale Uhrzeit in ${chart.state.timeZone}`;
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) {
@@ -1429,7 +1484,7 @@ function exportCurrentChartAsPng() {
}
const scale = Math.max(1, Number(currentChart.state.pngScale) || 1);
const fileNameBase = `${currentChart.state.name}-${currentChart.state.year}`
const fileNameBase = `${currentChart.state.name}-${currentChart.state.startDateIso}-${currentChart.state.endDateIso}`
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
@@ -1527,7 +1582,7 @@ function showTooltip(event) {
const minutesOfDay = rowIndex * currentChart.state.stepMinutes;
const hour = Math.floor(minutesOfDay / 60);
const minute = minutesOfDay % 60;
const localDate = localDateFromDayIndex(currentChart.state.year, dayIndex);
const localDate = localDateFromDayIndex(currentChart.state.dayDates, 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);