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(); session_start();
$pageTitle = 'Dämmerungszeiten über das Jahr - AstroTools'; $pageTitle = 'Dämmerungszeiten im Zeitraum - AstroTools';
$bodyClass = 'daemmerungszeiten-page'; $bodyClass = 'daemmerungszeiten-page';
$headerIntroPre = 'Astronomische Visualisierung'; $headerIntroPre = 'Astronomische Visualisierung';
$headerIntroTitle = 'Dämmerungszeiten über das Jahr'; $headerIntroTitle = 'Dämmerungszeiten im Zeitraum';
$headerIntroSub = 'Jeder Tag des Jahres als eigene Spur mit Nacht, astronomischer, nautischer und bürgerlicher Dämmerung sowie Sonnenstand.'; $headerIntroSub = 'Jeder Tag des gewählten Zeitraums als eigene Spur mit Nacht, astronomischer, nautischer und bürgerlicher Dämmerung sowie Sonnenstand.';
$availableLocations = []; $availableLocations = [];
$pdo = null; $pdo = null;
@@ -76,6 +76,9 @@ if (!$availableLocations) {
'is_default' => true, 'is_default' => true,
]; ];
} }
$defaultStartDate = date('Y-01-01');
$defaultEndDate = date('Y-12-31');
?> ?>
<script src="js/astronomy.browser.min.js"></script> <script src="js/astronomy.browser.min.js"></script>
@@ -340,9 +343,9 @@ body.daemmerungszeiten-page main > .container {
<div class="twilight-year-layout"> <div class="twilight-year-layout">
<section class="card twilight-controls"> <section class="card twilight-controls">
<div class="twilight-control-block"> <div class="twilight-control-block">
<h2>Standort und Jahr</h2> <h2>Standort und Zeitraum</h2>
<p class="twilight-help"> <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> </p>
<div class="twilight-form-grid"> <div class="twilight-form-grid">
<div class="twilight-field"> <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') ?>"> <input id="timezone" type="text" list="timezoneSuggestions" value="<?= htmlspecialchars($initialLocation['timezone'], ENT_QUOTES, 'UTF-8') ?>">
</div> </div>
<div class="twilight-field"> <div class="twilight-field">
<label for="yearSelect">Jahr</label> <label for="startDate">Von</label>
<input id="yearSelect" type="number" min="1900" max="2200" value="<?= htmlspecialchars((string) date('Y'), ENT_QUOTES, 'UTF-8') ?>"> <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> </div>
<div class="twilight-inline-fields"> <div class="twilight-inline-fields">
@@ -479,7 +486,8 @@ const metaLongestNight = document.getElementById('metaLongestNight');
const fieldPreset = document.getElementById('presetLocation'); const fieldPreset = document.getElementById('presetLocation');
const fieldName = document.getElementById('locationName'); const fieldName = document.getElementById('locationName');
const fieldTimezone = document.getElementById('timezone'); 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 fieldOverlay = document.getElementById('lineOverlay');
const fieldPngScale = document.getElementById('pngScaleSelect'); const fieldPngScale = document.getElementById('pngScaleSelect');
const renderButton = document.getElementById('renderButton'); 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) { function formatDateGerman(date, timeZone) {
return new Intl.DateTimeFormat('de-DE', { return new Intl.DateTimeFormat('de-DE', {
timeZone, timeZone,
@@ -602,8 +602,51 @@ function zonedDateTimeToUtc(year, month, day, hour, minute, timeZone, second = 0
return new Date(utcMillis); return new Date(utcMillis);
} }
function localDateFromDayIndex(year, dayIndex) { function parseIsoDate(isoDate) {
return new Date(Date.UTC(year, 0, dayIndex + 1, 12, 0, 0)); 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) { function sunAltitude(observer, utcDate) {
@@ -918,10 +961,11 @@ function fillLegend() {
function readFormState() { function readFormState() {
const latitude = Number(selectedLocation.latitude); const latitude = Number(selectedLocation.latitude);
const longitude = Number(selectedLocation.longitude); const longitude = Number(selectedLocation.longitude);
const year = Number(fieldYear.value);
const stepMinutes = 1; const stepMinutes = 1;
const timeZone = fieldTimezone.value.trim(); const timeZone = fieldTimezone.value.trim();
const name = fieldName.value.trim() || 'Eigener Standort'; 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) { if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
throw new Error('Bitte einen gültigen Breitengrad zwischen -90 und 90 eingeben.'); 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.'); throw new Error('Bitte einen gültigen Längengrad zwischen -180 und 180 eingeben.');
} }
if (!Number.isInteger(year) || year < 1900 || year > 2200) { if (!startDate || !endDate) {
throw new Error('Bitte ein gültiges Jahr zwischen 1900 und 2200 wählen.'); 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)) { if (!validateTimeZone(timeZone)) {
@@ -944,7 +997,9 @@ function readFormState() {
latitude, latitude,
longitude, longitude,
timeZone, timeZone,
year, startDateIso: formatIsoDate(startDate),
endDateIso: formatIsoDate(endDate),
dayDates: buildDateRange(startDate, endDate),
stepMinutes, stepMinutes,
canvasQuality: 3, canvasQuality: 3,
pngScale: Number(fieldPngScale.value) || 1, pngScale: Number(fieldPngScale.value) || 1,
@@ -1042,7 +1097,7 @@ async function buildDisplayRaster(chart, columns, stepMinutes = 1) {
} }
async function buildChartData(state, token) { async function buildChartData(state, token) {
const totalDays = daysInYear(state.year); const totalDays = state.dayDates.length;
const rows = Math.floor((24 * 60) / state.stepMinutes); const rows = Math.floor((24 * 60) / state.stepMinutes);
const observer = new Astronomy.Observer(state.latitude, state.longitude, 0); const observer = new Astronomy.Observer(state.latitude, state.longitude, 0);
const phaseGrid = new Uint8Array(totalDays * rows); const phaseGrid = new Uint8Array(totalDays * rows);
@@ -1063,9 +1118,9 @@ async function buildChartData(state, token) {
return null; return null;
} }
const localDate = localDateFromDayIndex(state.year, dayIndex); const localDate = localDateFromDayIndex(state.dayDates, dayIndex);
const localParts = getTimeZoneParts(localDate, state.timeZone); const localParts = getTimeZoneParts(localDate, state.timeZone);
if (localParts.day === 1) { if (dayIndex === 0 || localParts.day === 1) {
monthBoundaries.push({ dayIndex, label: formatMonthShort(localDate, state.timeZone) }); monthBoundaries.push({ dayIndex, label: formatMonthShort(localDate, state.timeZone) });
} }
@@ -1396,14 +1451,14 @@ function drawThresholdLines(context, chart, margin, plotWidth, plotHeight) {
} }
function updateSummary(chart) { function updateSummary(chart) {
const longestDayDate = localDateFromDayIndex(chart.state.year, chart.longestDayIndex); const longestDayDate = localDateFromDayIndex(chart.state.dayDates, chart.longestDayIndex);
const longestNightDate = localDateFromDayIndex(chart.state.year, chart.longestNightIndex); const longestNightDate = localDateFromDayIndex(chart.state.dayDates, chart.longestNightIndex);
metaLocation.textContent = `${chart.state.name} · ${chart.state.latitude.toFixed(2)}°, ${chart.state.longitude.toFixed(2)}°`; metaLocation.textContent = `${chart.state.name} · ${chart.state.latitude.toFixed(2)}°, ${chart.state.longitude.toFixed(2)}°`;
metaTimezone.textContent = chart.state.timeZone; metaTimezone.textContent = chart.state.timeZone;
metaLongestDay.textContent = `${formatDateGerman(longestDayDate, chart.state.timeZone)} · ${formatDuration(chart.longestDayMinutes)}`; metaLongestDay.textContent = `${formatDateGerman(longestDayDate, chart.state.timeZone)} · ${formatDuration(chart.longestDayMinutes)}`;
metaLongestNight.textContent = `${formatDateGerman(longestNightDate, chart.state.timeZone)} · ${formatDuration(chart.longestNightMinutes)}`; 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) { function phaseColorCss(phaseIndex, alpha) {
@@ -1429,7 +1484,7 @@ function exportCurrentChartAsPng() {
} }
const scale = Math.max(1, Number(currentChart.state.pngScale) || 1); 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() .toLowerCase()
.replace(/[^a-z0-9]+/g, '-') .replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, ''); .replace(/^-+|-+$/g, '');
@@ -1527,7 +1582,7 @@ function showTooltip(event) {
const minutesOfDay = rowIndex * currentChart.state.stepMinutes; const minutesOfDay = rowIndex * currentChart.state.stepMinutes;
const hour = Math.floor(minutesOfDay / 60); const hour = Math.floor(minutesOfDay / 60);
const minute = 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 localParts = getTimeZoneParts(localDate, currentChart.state.timeZone);
const utcDate = zonedDateTimeToUtc(localParts.year, localParts.month, localParts.day, hour, minute, currentChart.state.timeZone); const utcDate = zonedDateTimeToUtc(localParts.year, localParts.month, localParts.day, hour, minute, currentChart.state.timeZone);
const altitude = sunAltitude(currentChart.observer, utcDate); const altitude = sunAltitude(currentChart.observer, utcDate);