diff --git a/public/astronomical_conversions.php b/public/astronomical_conversions.php index 43e7e12..99d21df 100644 --- a/public/astronomical_conversions.php +++ b/public/astronomical_conversions.php @@ -53,6 +53,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string) ($_POST['ajax'] ?? '') === trim((string) ($_POST['sourceUnit'] ?? '')), trim((string) ($_POST['sourceValue'] ?? '')), ]); + } elseif ($kind === 'tools') { + $pythonResult = runPythonApi('astronomical_conversions', [ + 'tools', + trim((string) ($_POST['tool'] ?? '')), + trim((string) ($_POST['dateTimeLocal'] ?? '')), + trim((string) ($_POST['latitude'] ?? '')), + trim((string) ($_POST['longitude'] ?? '')), + trim((string) ($_POST['timeZone'] ?? '')), + ]); } else { echo json_encode([ 'ok' => false, @@ -79,6 +88,15 @@ $timeLocationName = !empty($currentLocation['name']) ? (string) $currentLocation $timeLocationLongitude = isset($currentLocation['longitude']) && is_numeric($currentLocation['longitude']) ? (float) $currentLocation['longitude'] : null; +$toolLocationName = !empty($currentLocation['name']) ? (string) $currentLocation['name'] : 'Standard-Standort'; +$toolLocationLatitude = isset($currentLocation['latitude']) && is_numeric($currentLocation['latitude']) + ? (float) $currentLocation['latitude'] + : 50.3597; +$toolLocationLongitude = isset($currentLocation['longitude']) && is_numeric($currentLocation['longitude']) + ? (float) $currentLocation['longitude'] + : 11.1907; +$toolLocationTimezone = !empty($currentLocation['timezone']) ? (string) $currentLocation['timezone'] : 'Europe/Berlin'; +$toolDefaultLocalDateTime = (new DateTimeImmutable('now', new DateTimeZone($toolLocationTimezone)))->format('Y-m-d\TH:i'); ?> @@ -228,6 +246,7 @@ $timeLocationLongitude = isset($currentLocation['longitude']) && is_numeric($cur +
@@ -497,6 +516,58 @@ $timeLocationLongitude = isset($currentLocation['longitude']) && is_numeric($cur
+ +
+

Diverse Tools

+

Sonnenstand für den Standard-Standort aus dem Header berechnen. Datum und Uhrzeit werden in der lokalen Zeitzone des Standorts interpretiert.

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
@@ -509,6 +580,12 @@ $timeLocationLongitude = isset($currentLocation['longitude']) && is_numeric($cur name: , longitude: }; + const USER_TOOL_LOCATION = { + name: , + latitude: , + longitude: , + timeZone: + }; const SPEED_OF_LIGHT_KM_S = 299792.458; const H0 = 70; const OMEGA_M = 0.3; @@ -570,6 +647,16 @@ $timeLocationLongitude = isset($currentLocation['longitude']) && is_numeric($cur arcsec: document.getElementById('angleArcsec'), rad: document.getElementById('angleRad') }; + const toolFields = { + dateTimeLocal: document.getElementById('toolsDateTimeLocal'), + locationName: document.getElementById('toolsLocationName'), + timeZone: document.getElementById('toolsTimeZone'), + latitude: document.getElementById('toolsLatitude'), + longitude: document.getElementById('toolsLongitude'), + dateTimeUtc: document.getElementById('toolsDateTimeUtc'), + altitude: document.getElementById('toolsSunAltitude'), + azimuth: document.getElementById('toolsSunAzimuth') + }; let isSyncingTimePicker = false; let timeUtcPicker = null; let distanceRequestId = 0; @@ -577,6 +664,7 @@ $timeLocationLongitude = isset($currentLocation['longitude']) && is_numeric($cur let timeRequestId = 0; let coordinateRequestId = 0; let angleRequestId = 0; + let toolRequestId = 0; const DECIMAL_DIVISION_PRECISION = 32; const pow10Cache = [1n]; @@ -1573,6 +1661,71 @@ $timeLocationLongitude = isset($currentLocation['longitude']) && is_numeric($cur updateAngleFrom('arcsec'); }); + const clearToolOutputs = function () { + toolFields.dateTimeUtc.value = ''; + toolFields.altitude.value = ''; + toolFields.azimuth.value = ''; + updateTooltip(toolFields.dateTimeUtc); + updateTooltip(toolFields.altitude); + updateTooltip(toolFields.azimuth); + }; + + const updateSolarTool = async function () { + const localDateTime = toolFields.dateTimeLocal.value.trim(); + if (localDateTime === '') { + clearToolOutputs(); + return; + } + + const requestId = ++toolRequestId; + try { + const result = await requestConversion({ + kind: 'tools', + tool: 'solar_altaz', + dateTimeLocal: localDateTime, + latitude: USER_TOOL_LOCATION.latitude, + longitude: USER_TOOL_LOCATION.longitude, + timeZone: USER_TOOL_LOCATION.timeZone + }); + + if (requestId !== toolRequestId || !result.valid) { + return; + } + + toolFields.dateTimeUtc.value = typeof result.fields.utc === 'string' ? result.fields.utc : ''; + toolFields.altitude.value = typeof result.fields.altitude === 'string' ? result.fields.altitude : ''; + toolFields.azimuth.value = typeof result.fields.azimuth === 'string' ? result.fields.azimuth : ''; + updateTooltip(toolFields.dateTimeUtc); + updateTooltip(toolFields.altitude); + updateTooltip(toolFields.azimuth); + } catch (error) { + clearToolOutputs(); + } + }; + + toolFields.dateTimeLocal.addEventListener('input', updateSolarTool); + + document.getElementById('toolsClear').addEventListener('click', function () { + toolFields.dateTimeLocal.value = ''; + clearToolOutputs(); + toolFields.dateTimeLocal.focus(); + }); + + document.getElementById('toolsNow').addEventListener('click', function () { + const now = new Date(); + const formatter = new Intl.DateTimeFormat('sv-SE', { + timeZone: USER_TOOL_LOCATION.timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23' + }); + toolFields.dateTimeLocal.value = formatter.format(now).replace(' ', 'T'); + updateSolarTool(); + }); + const tabs = Array.from(document.querySelectorAll('.astro-conv-tab')); const tabPanels = Array.from(document.querySelectorAll('.astro-conv-tab-panel')); @@ -1599,6 +1752,7 @@ $timeLocationLongitude = isset($currentLocation['longitude']) && is_numeric($cur updateTimeFrom('utc'); updateCoordinatesFrom('equatorial'); updateAngleFrom('deg'); + updateSolarTool(); })(); diff --git a/public/daemmerungszeiten.php b/public/daemmerungszeiten.php index 5bef097..50a8b95 100644 --- a/public/daemmerungszeiten.php +++ b/public/daemmerungszeiten.php @@ -363,6 +363,8 @@ body.daemmerungszeiten-page main > .container { + +
@@ -392,7 +394,6 @@ body.daemmerungszeiten-page main > .container {
-
@@ -491,7 +492,6 @@ const fieldEndDate = document.getElementById('endDate'); 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; @@ -1532,6 +1532,76 @@ function phaseColorCss(phaseIndex, alpha) { 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'); @@ -1554,13 +1624,26 @@ function exportCurrentChartAsPng() { .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(canvas.clientHeight * 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); - drawChart(currentChart, exportContext, canvas.clientWidth, canvas.clientHeight, scale, '#121726'); + 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) { @@ -1588,7 +1671,6 @@ async function renderTwilightChart() { renderStatus.textContent = 'Berechnung startet …'; renderButton.disabled = true; - geoButton.disabled = true; exportPngButton.disabled = true; try { @@ -1619,7 +1701,6 @@ async function renderTwilightChart() { } finally { if (token === currentRenderToken) { renderButton.disabled = false; - geoButton.disabled = false; exportPngButton.disabled = false; } } @@ -1679,37 +1760,6 @@ fieldPreset.addEventListener('change', () => { 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', () => { diff --git a/public/py/api.py b/public/py/api.py index 464c4c0..05e100c 100644 --- a/public/py/api.py +++ b/public/py/api.py @@ -258,6 +258,17 @@ def action_astronomical_conversions(args: list[str]) -> dict: ) payload["sourceUnit"] = args[1] payload["sourceValue"] = args[2] + elif kind == "tools": + if len(args) != 6: + fail( + "Aktion astronomical_conversions fuer tools erwartet 6 Argumente: kind tool dateTimeLocal latitude longitude timeZone", + extra={"argv": args}, + ) + payload["tool"] = args[1] + payload["dateTimeLocal"] = args[2] + payload["latitude"] = args[3] + payload["longitude"] = args[4] + payload["timeZone"] = args[5] else: fail( "Unbekannter Umrechnungsbereich fuer astronomical_conversions.", diff --git a/public/py/astronomical_conversions.py b/public/py/astronomical_conversions.py index 2076020..eb691ec 100644 --- a/public/py/astronomical_conversions.py +++ b/public/py/astronomical_conversions.py @@ -2,6 +2,7 @@ import math from datetime import datetime, timedelta, timezone from decimal import Decimal, InvalidOperation, ROUND_HALF_UP, localcontext +from zoneinfo import ZoneInfo import astronomy try: @@ -57,6 +58,7 @@ REDSHIFT_ORDER = [ TIME_ORDER = ["utc", "ut1", "tai", "jd", "mjd", "unix", "trueSolar", "sidereal"] COORDINATE_ORDER = ["ra", "dec", "galLon", "galLat"] ANGLE_ORDER = ["deg", "arcmin", "arcsec", "rad"] +TOOLS_ORDER = ["utc", "altitude", "azimuth"] ANGLE_TO_DEGREES = { "deg": Decimal("1"), "arcmin": Decimal("0.01666666666666666666666666667"), @@ -215,6 +217,27 @@ def parse_utc_datetime(value: str) -> datetime | None: return None +def parse_local_datetime(value: str, time_zone_name: str) -> datetime | None: + text = str(value or "").strip() + if text == "": + return None + + normalized = text.replace(" ", "T") + try: + zone = ZoneInfo(time_zone_name) + except Exception: + return None + + for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"): + try: + parsed = datetime.strptime(normalized, fmt) + return parsed.replace(tzinfo=zone) + except ValueError: + continue + + return None + + def dt_to_time(dt_utc: datetime) -> astronomy.Time: dt_utc = dt_utc.astimezone(timezone.utc) return astronomy.Time.Make( @@ -509,6 +532,44 @@ def convert_angle(payload: dict) -> dict: return {"valid": True, "fields": fields} +def convert_tools(payload: dict) -> dict: + tool = str(payload.get("tool") or "").strip().lower() + if tool != "solar_altaz": + raise ValueError("Unbekanntes Tool im Bereich Diverse Tools.") + + local_dt = parse_local_datetime(str(payload.get("dateTimeLocal") or ""), str(payload.get("timeZone") or "")) + latitude_decimal = parse_decimal_input(payload.get("latitude", "")) + longitude_decimal = parse_decimal_input(payload.get("longitude", "")) + + if local_dt is None or latitude_decimal is None or longitude_decimal is None: + return {"valid": False, "fields": {key: "" for key in TOOLS_ORDER}} + + latitude = float(latitude_decimal) + longitude = float(longitude_decimal) + if ( + not math.isfinite(latitude) + or not math.isfinite(longitude) + or latitude < -90.0 + or latitude > 90.0 + or longitude < -180.0 + or longitude > 180.0 + ): + return {"valid": False, "fields": {key: "" for key in TOOLS_ORDER}} + + dt_utc = local_dt.astimezone(timezone.utc) + time_value = dt_to_time(dt_utc) + observer = astronomy.Observer(latitude, longitude, 0.0) + equator = astronomy.Equator(astronomy.Body.Sun, time_value, observer, True, True) + horizon = astronomy.Horizon(time_value, observer, equator.ra, equator.dec, astronomy.Refraction.Normal) + + fields = { + "utc": format_utc_datetime(dt_utc), + "altitude": format_decimal_german(horizon.altitude, max_fraction_digits=4), + "azimuth": format_decimal_german(normalize_angle_degrees(horizon.azimuth), max_fraction_digits=4), + } + return {"valid": True, "fields": fields} + + def handle_request(payload: dict) -> dict: kind = str(payload.get("kind") or "").strip().lower() if kind == "distance": @@ -521,6 +582,8 @@ def handle_request(payload: dict) -> dict: result = convert_coordinates(payload) elif kind == "angle": result = convert_angle(payload) + elif kind == "tools": + result = convert_tools(payload) else: raise ValueError("Unbekannter Umrechnungsbereich.")