From 5aacd861de7a48bed600b3f4136945cb6051f284 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Mon, 13 Jul 2026 15:10:49 +0200 Subject: [PATCH] =?UTF-8?q?D=C3=A4mmerungsgrafik-Achsen=20und=20Overlay=20?= =?UTF-8?q?gl=C3=A4tten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/daemmerungszeiten.php | 192 +++++++++++++++++++++++------------ 1 file changed, 129 insertions(+), 63 deletions(-) diff --git a/public/daemmerungszeiten.php b/public/daemmerungszeiten.php index 0f7b7a9..5bef097 100644 --- a/public/daemmerungszeiten.php +++ b/public/daemmerungszeiten.php @@ -544,6 +544,22 @@ function formatMonthShort(date, timeZone) { }).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); @@ -649,6 +665,56 @@ 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'); @@ -857,6 +923,19 @@ function phaseIndexFromCurve(dayCurve, minutesOfDay) { 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]]) : []; } @@ -1056,7 +1135,9 @@ async function buildDisplayRaster(chart, columns, stepMinutes = 1) { 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))); + 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; @@ -1064,8 +1145,12 @@ async function buildDisplayRaster(chart, columns, stepMinutes = 1) { 0, Math.min(sourceRows - 1, Math.round(sampleMinutes / sourceStepMinutes)) ); - const phaseIndex = sourceGrid[(sourceRowIndex * chart.totalDays) + dayIndex]; - const color = PHASES[phaseIndex].color; + 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]; @@ -1107,7 +1192,7 @@ async function buildChartData(state, token) { imageCanvas.height = rows; const imageContext = imageCanvas.getContext('2d'); const image = imageContext.createImageData(totalDays, rows); - const monthBoundaries = []; + const xAxisTicks = buildXAxisTicks(state.dayDates, state.timeZone); let longestDayMinutes = -1; let longestDayIndex = 0; let longestNightMinutes = -1; @@ -1120,10 +1205,6 @@ async function buildChartData(state, token) { const localDate = localDateFromDayIndex(state.dayDates, dayIndex); const localParts = getTimeZoneParts(localDate, state.timeZone); - if (dayIndex === 0 || localParts.day === 1) { - monthBoundaries.push({ dayIndex, label: formatMonthShort(localDate, state.timeZone) }); - } - const dayCurve = buildThresholdIntervals(observer, localParts, state.timeZone); dayCurves.push(dayCurve); @@ -1181,7 +1262,7 @@ async function buildChartData(state, token) { previewStepMinutes: state.stepMinutes, dayCurves, imageCanvas, - monthBoundaries, + xAxisTicks, longestDayIndex, longestDayMinutes, longestNightIndex, @@ -1246,19 +1327,15 @@ function drawChart(chart, targetContext = null, targetWidth = null, targetHeight context.textAlign = 'center'; context.textBaseline = 'top'; - chart.monthBoundaries.forEach((boundary, index) => { - const x = margin.left + (boundary.dayIndex / chart.totalDays) * plotWidth; + 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 nextBoundary = chart.monthBoundaries[index + 1]; - const startDay = boundary.dayIndex; - const endDay = nextBoundary ? nextBoundary.dayIndex : chart.totalDays; - const midDay = startDay + ((endDay - startDay) / 2); - const labelX = margin.left + (midDay / chart.totalDays) * plotWidth; - context.fillText(boundary.label.replace('.', ''), labelX, margin.top + plotHeight + 10); + 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) { @@ -1305,6 +1382,36 @@ function strokePointSeries(context, points) { } } +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; @@ -1370,6 +1477,7 @@ function drawThresholdLines(context, chart, margin, plotWidth, plotHeight) { 1: 'astronomical' }; const divisor = Math.max(1, curves.length - 1); + const maxJumpPixels = (1.5 / 24) * plotHeight; thresholds.forEach((threshold) => { context.strokeStyle = threshold.color; @@ -1383,29 +1491,8 @@ function drawThresholdLines(context, chart, margin, plotWidth, plotHeight) { 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(); - } + strokePointRuns(context, topPoints, maxJumpPixels); + strokePointRuns(context, bottomPoints, maxJumpPixels); topPoints.length = 0; bottomPoints.length = 0; @@ -1423,29 +1510,8 @@ function drawThresholdLines(context, chart, margin, plotWidth, 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(); - } + strokePointRuns(context, topPoints, maxJumpPixels); + strokePointRuns(context, bottomPoints, maxJumpPixels); } }); }