diff --git a/public/css/style.css b/public/css/style.css index 9922c69..3a16d84 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -354,32 +354,37 @@ body.landing-page .redirect-link:hover { color: #9cf2a3; box-shadow: inset 0 0 0 1px rgba(156, 242, 163, 0.24); } -.satpass-visibility-sehrgut { - background: rgba(84, 163, 86, 0.18); - color: #8fe68d; - box-shadow: inset 0 0 0 1px rgba(143, 230, 141, 0.22); +.satpass-visibility-ausgezeichnet { + background: rgba(92, 186, 104, 0.2); + color: #9be89b; + box-shadow: inset 0 0 0 1px rgba(155, 232, 155, 0.22); +} +.satpass-visibility-sehr-gut { + background: rgba(109, 192, 106, 0.18); + color: #a6ec99; + box-shadow: inset 0 0 0 1px rgba(166, 236, 153, 0.2); } .satpass-visibility-gut { - background: rgba(116, 192, 104, 0.16); - color: #a5ec96; - box-shadow: inset 0 0 0 1px rgba(165, 236, 150, 0.2); + background: rgba(157, 199, 97, 0.16); + color: #c0ee9a; + box-shadow: inset 0 0 0 1px rgba(192, 238, 154, 0.2); } -.satpass-visibility-befriedigend { +.satpass-visibility-maessig { background: rgba(214, 166, 54, 0.16); color: #f0c96a; box-shadow: inset 0 0 0 1px rgba(240, 201, 106, 0.22); } -.satpass-visibility-ausreichend { +.satpass-visibility-schwach { background: rgba(212, 117, 66, 0.16); color: #ffb27f; box-shadow: inset 0 0 0 1px rgba(255, 178, 127, 0.22); } -.satpass-visibility-mangelhaft { +.satpass-visibility-kaum-sichtbar { background: rgba(198, 84, 58, 0.18); color: #ff9d86; box-shadow: inset 0 0 0 1px rgba(255, 157, 134, 0.22); } -.satpass-visibility-ungenuegend { +.satpass-visibility-nicht-zu-sehen { background: rgba(194, 59, 59, 0.18); color: #ff8f8f; box-shadow: inset 0 0 0 1px rgba(255, 143, 143, 0.24); diff --git a/public/satellitenhimmel.php b/public/satellitenhimmel.php index 979f960..3db7c32 100644 --- a/public/satellitenhimmel.php +++ b/public/satellitenhimmel.php @@ -321,6 +321,7 @@ $constellationLabelPayload = array_map(static function (array $row): array { // ── Python aufrufen ────────────────────────────────────────────────────────── +$satellitePassWindowDays = 7; $pythonResult = null; $pythonError = null; @@ -335,7 +336,7 @@ if (!empty($satellitePayload) && !empty($defaultLocation['latitude'])) { $tzObj = new DateTimeZone($tz); $nowLocal = new DateTimeImmutable('now', $tzObj); $dayStart = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $nowLocal->format('Y-m-d') . ' 00:00:00', $tzObj); - $dayEnd = $dayStart->modify('+7 days 23:59:59'); + $dayEnd = $dayStart->modify('+' . $satellitePassWindowDays . ' days 23:59:59'); $windowStart = $dayStart->setTimezone(new DateTimeZone('UTC'))->format(DateTime::ATOM); $windowEnd = $dayEnd->setTimezone(new DateTimeZone('UTC'))->format(DateTime::ATOM); @@ -470,6 +471,7 @@ if (!empty($currentLocation) && is_array($currentLocation)) { const PYTHON_RESULTS = = json_encode($pythonResult['results'] ?? [], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>; const WINDOW_START = = json_encode($pythonResult['window_start'] ?? null) ?>; const WINDOW_END = = json_encode($pythonResult['window_end'] ?? null) ?>; + const PASS_WINDOW_DAYS = = (int)$satellitePassWindowDays ?>; // Parse ISO times in each pass and path point to Date objects PYTHON_RESULTS.forEach(sat => { @@ -627,31 +629,136 @@ if (!empty($currentLocation) && is_array($currentLocation)) { return {key:'night', label:'Nacht'}; } - function classifyVisibility(pass) { - const sunAltDeg = getSunAltitudeDeg(pass.peakTime); - const sky = classifySkyBrightness(sunAltDeg); - const elevDeg = pass.peakElevation; - let sunBand = 5; - if (sunAltDeg > -4) sunBand = 0; - else if (sunAltDeg > -6) sunBand = 1; - else if (sunAltDeg > -8) sunBand = 2; - else if (sunAltDeg > -10) sunBand = 3; - else if (sunAltDeg > -12) sunBand = 4; - let elevBand = 0; - if (elevDeg >= 60) elevBand = 4; - else if (elevDeg >= 40) elevBand = 3; - else if (elevDeg >= 20) elevBand = 2; - else if (elevDeg >= 10) elevBand = 1; - const matrix = [ - ['ungenuegend','ungenuegend','mangelhaft','mangelhaft','ausreichend'], - ['ungenuegend','mangelhaft','ausreichend','befriedigend','gut'], - ['mangelhaft','ausreichend','gut','sehr gut','perfekt'], - ['mangelhaft','befriedigend','gut','sehr gut','perfekt'], - ['ausreichend','befriedigend','gut','sehr gut','perfekt'], - ['ausreichend','befriedigend','gut','gut','sehr gut'] + function clamp(value, min = 0, max = 1) { + return Math.min(max, Math.max(min, value)); + } + + function interpolatePiecewise(value, segments) { + if (!Number.isFinite(value) || !segments.length) return 0; + if (value <= segments[0].from) return clamp(segments[0].out_from); + for (const segment of segments) { + if (value <= segment.to) { + const span = segment.to - segment.from; + if (span <= 0) return clamp(segment.out_to); + const t = clamp((value - segment.from) / span, 0, 1); + return clamp(segment.out_from + (segment.out_to - segment.out_from) * t); + } + } + return clamp(segments[segments.length - 1].out_to); + } + + function normalizeVisibilityKey(label) { + return String(label || '') + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + } + + function getMoonPhaseScore(date) { + const synodicMonthDays = 29.530588853; + const knownNewMoonUtc = Date.UTC(2000, 0, 6, 18, 14, 0); + const ageDays = ((((date.getTime() - knownNewMoonUtc) / 86400000) % synodicMonthDays) + synodicMonthDays) % synodicMonthDays; + const phaseAngle = (ageDays / synodicMonthDays) * 2 * Math.PI; + const illumination = (1 - Math.cos(phaseAngle)) / 2; + if (illumination <= 0.10) return { key: 'new_moon', score: 1.00, label: 'Neumond', illumination }; + if (illumination <= 0.55) return { key: 'half_moon', score: 0.80, label: 'Halbmond', illumination }; + if (illumination <= 0.85) return { key: 'three_quarter', score: 0.50, label: 'Dreiviertelmond', illumination }; + return { key: 'full_moon', score: 0.30, label: 'Vollmond', illumination }; + } + + function getDarknessCondition(sunAltDeg, visiblePeak) { + if (sunAltDeg > -4) return { key: 'daylight', score: 0.05, label: 'Tageshimmel' }; + if (sunAltDeg >= -12) return { key: 'twilight', score: 0.65, label: 'Daemmerung' }; + if ((visiblePeak?.elevationDeg ?? -1) < 0) return { key: 'deep_night', score: 0.20, label: 'Nicht sichtbar / Erdschatten' }; + return { key: 'ideal', score: 1.00, label: 'Dunkler Himmel, Satellit sonnenbeschienen' }; + } + + function getVisibilityVerdict(score) { + const thresholds = [ + { min: 0.00, max: 0.05, label: 'Nicht zu sehen' }, + { min: 0.05, max: 0.18, label: 'Kaum sichtbar' }, + { min: 0.18, max: 0.35, label: 'Schwach' }, + { min: 0.35, max: 0.52, label: 'Maessig' }, + { min: 0.52, max: 0.68, label: 'Gut' }, + { min: 0.68, max: 0.82, label: 'Sehr gut' }, + { min: 0.82, max: 0.92, label: 'Ausgezeichnet' }, + { min: 0.92, max: 1.01, label: 'Perfekt' } ]; - const label = matrix[sunBand][elevBand]; - return {key: label.replace(/\s+/g,''), label, sky, sunAltDeg}; + const match = thresholds.find(entry => score >= entry.min && score < entry.max) || thresholds[0]; + return { key: normalizeVisibilityKey(match.label), label: match.label }; + } + + function classifyVisibility(pass) { + const visiblePhase = getVisiblePhase(pass); + const visiblePeak = getVisiblePeak(pass, visiblePhase); + const sunAltDeg = getSunAltitudeDeg(visiblePeak.time); + const sky = classifySkyBrightness(sunAltDeg); + const darkness = getDarknessCondition(sunAltDeg, visiblePeak); + const moonPhase = getMoonPhaseScore(visiblePeak.time); + const elevationScore = interpolatePiecewise(visiblePeak.elevationDeg ?? 0, [ + { from: 0, to: 5, out_from: 0.00, out_to: 0.00 }, + { from: 5, to: 10, out_from: 0.10, out_to: 0.20 }, + { from: 10, to: 20, out_from: 0.20, out_to: 0.40 }, + { from: 20, to: 30, out_from: 0.40, out_to: 0.60 }, + { from: 30, to: 60, out_from: 0.60, out_to: 0.90 }, + { from: 60, to: 90, out_from: 0.90, out_to: 1.00 } + ]); + const hasVisibleBrightnessPeak = isPeakVisible(pass); + const magnitudeValue = hasVisibleBrightnessPeak && Number.isFinite(pass.peakMag?.mag) ? pass.peakMag.mag : 3.5; + const magnitudeScore = interpolatePiecewise(magnitudeValue, [ + { from: -5.0, to: -4.0, out_from: 1.00, out_to: 1.00 }, + { from: -4.0, to: -2.0, out_from: 1.00, out_to: 0.85 }, + { from: -2.0, to: 0.0, out_from: 0.85, out_to: 0.60 }, + { from: 0.0, to: 2.0, out_from: 0.60, out_to: 0.30 }, + { from: 2.0, to: 3.0, out_from: 0.30, out_to: 0.10 }, + { from: 3.0, to: 4.0, out_from: 0.10, out_to: 0.05 } + ]); + const durationSec = Math.max(0, Math.round(visiblePhase.visibleDurationMs / 1000)); + const durationScore = interpolatePiecewise(durationSec, [ + { from: 0, to: 30, out_from: 0.00, out_to: 0.15 }, + { from: 30, to: 90, out_from: 0.15, out_to: 0.40 }, + { from: 90, to: 180, out_from: 0.40, out_to: 0.75 }, + { from: 180, to: 300, out_from: 0.75, out_to: 0.95 }, + { from: 300, to: 400, out_from: 0.95, out_to: 1.00 } + ]); + + const partials = { + elevation: clamp(elevationScore), + magnitude: clamp(magnitudeScore), + darkness: clamp(darkness.score), + duration: clamp(durationScore), + moonPhase: clamp(moonPhase.score) + }; + + let score = + partials.elevation * 0.36 + + partials.magnitude * 0.31 + + partials.darkness * 0.21 + + partials.duration * 0.09 + + partials.moonPhase * 0.03; + + if ((visiblePeak.elevationDeg ?? 0) < 5) score = 0.0; + if (darkness.key === 'daylight') score = Math.min(score, 0.05); + if (magnitudeValue > 3) score *= 0.4; + + score = clamp(score); + const verdict = getVisibilityVerdict(score); + return { + key: verdict.key, + label: verdict.label, + score, + scorePercent: Math.round(score * 100), + sky, + sunAltDeg, + darkness, + moonPhase, + durationSec, + visiblePeak, + usesVisiblePeakOnly: true, + partials + }; } function formatVisibilityBadge(v) { @@ -683,35 +790,66 @@ if (!empty($currentLocation) && is_array($currentLocation)) { } function getVisiblePhase(pass) { - const visibleEndTime = pass.shadowEntry && pass.shadowEntry.time < pass.losTime - ? pass.shadowEntry.time : pass.losTime; const visiblePath = pass.path - .filter(p => p.time <= visibleEndTime && p.elevationDeg >= 0) + .filter(p => p.elevationDeg >= 0 && !p.shadow) .map(p => ({...p})); + const visibleStartTime = visiblePath.length + ? visiblePath[0].time + : pass.aosTime; + const visibleEndTime = visiblePath.length + ? visiblePath[visiblePath.length - 1].time + : pass.aosTime; return { + visibleStartTime, visibleEndTime, - visibleDurationMs: Math.max(0, visibleEndTime - pass.aosTime), + visibleDurationMs: Math.max(0, visibleEndTime - visibleStartTime), visiblePath }; } + function getVisiblePeak(pass, visiblePhase = null) { + const phase = visiblePhase ?? getVisiblePhase(pass); + if (!phase.visiblePath.length) { + return { + time: pass.aosTime, + azimuthDeg: pass.aosAzimuth ?? 0, + elevationDeg: -1 + }; + } + let peakPoint = phase.visiblePath[0]; + for (const point of phase.visiblePath) { + if (point.elevationDeg > peakPoint.elevationDeg) peakPoint = point; + } + return { + time: peakPoint.time, + azimuthDeg: peakPoint.azimuthDeg, + elevationDeg: peakPoint.elevationDeg + }; + } + function isPeakVisible(pass) { if (!(pass.peakTime instanceof Date) || Number.isNaN(pass.peakTime.getTime())) return false; if ((pass.peakElevation ?? -1) < 0) return false; - return !pass.shadowEntry || pass.peakTime <= pass.shadowEntry.time; + const visiblePhase = getVisiblePhase(pass); + if (!visiblePhase.visiblePath.length) return false; + return visiblePhase.visiblePath.some(point => Math.abs(point.time - pass.peakTime) <= 5000); } function buildObservationSummary(satName, pass, visibility) { - if (visibility.sky.key === 'day') return `${satName} ist bei diesem Überflug tagsüber nicht zu sehen.`; + if (visibility.darkness.key === 'daylight') return `${satName} ist bei diesem Überflug am Tageshimmel praktisch nicht zu sehen.`; const visiblePhase = getVisiblePhase(pass); + if (!visiblePhase.visiblePath.length) { + return `${satName} liegt bei diesem Überflug waehrend der gesamten Passage im Erdschatten und ist deshalb nicht sichtbar.`; + } + const visiblePeak = visibility.visiblePeak ?? getVisiblePeak(pass, visiblePhase); const pathText = buildPathDescription(visiblePhase.visiblePath); const shadowText = pass.shadowEntry ? ` Sie tritt um ${formatTime(pass.shadowEntry.time)} Uhr in den Erdschatten ein.` : ''; const magText = pass.peakMag !== null ? ` Ihre maximale Helligkeit betraegt ${formatMag(pass.peakMag)} mag.` : ''; - return `Die Rahmenbedingungen für die Beobachtung von ${satName} am ${formatDateLocal(pass.aosTime)} (${formatWeekdayShort(pass.aosTime)}) um ${formatTime(pass.aosTime)} Uhr sind ${visibility.label}. ` + return `Die Beobachtungsqualitaet von ${satName} am ${formatDateLocal(pass.aosTime)} (${formatWeekdayShort(pass.aosTime)}) um ${formatTime(pass.aosTime)} Uhr ist ${visibility.label} (${visibility.scorePercent} von 100 Punkten). ` + `Der Überflug ist ${formatDurationLong(visiblePhase.visibleDurationMs)} lang und bis ${formatTime(visiblePhase.visibleEndTime)} Uhr zu sehen. ` - + `${pathText} Die maximale Horizonthoehe betraegt ${pass.peakElevation.toFixed(0)}°.` + + `${pathText} Die fuer die Bewertung relevante maximale sichtbare Horizonthoehe betraegt ${visiblePeak.elevationDeg.toFixed(0)}°.` + `${shadowText}${magText}`; } @@ -726,9 +864,9 @@ if (!empty($currentLocation) && is_array($currentLocation)) { function drawEndpointLabel(point, text, color, cx, cy, radius) { const offsetX = point.x < cx ? 10 : -10; const offsetY = point.y < cy ? -10 : 16; - const fontSize = 11, paddingX = 5, paddingY = 3; + const fontSize = 12, paddingX = 7, paddingY = 4; ctx.save(); - ctx.font = `${fontSize}px Segoe UI`; + ctx.font = `700 ${fontSize}px Segoe UI`; ctx.textBaseline = 'middle'; const textWidth = ctx.measureText(text).width; let labelX = point.x + offsetX; @@ -738,8 +876,15 @@ if (!empty($currentLocation) && is_array($currentLocation)) { const minY = cy - radius + fontSize + paddingY + 6, maxY = cy + radius - fontSize - paddingY - 6; labelX = Math.max(minX, Math.min(maxX, labelX)); labelY = Math.max(minY, Math.min(maxY, labelY)); - ctx.fillStyle = invertSky ? 'rgba(255,255,255,0.88)' : 'rgba(8,12,20,0.82)'; - ctx.fillRect(labelX-paddingX, labelY-(fontSize/2)-paddingY, textWidth+paddingX*2, fontSize+paddingY*2); + const boxX = labelX - paddingX; + const boxY = labelY - (fontSize / 2) - paddingY; + const boxW = textWidth + paddingX * 2; + const boxH = fontSize + paddingY * 2; + ctx.fillStyle = invertSky ? '#ffffff' : '#050814'; + ctx.strokeStyle = invertSky ? '#1b2230' : 'rgba(255,255,255,0.22)'; + ctx.lineWidth = 1.2; + ctx.fillRect(boxX, boxY, boxW, boxH); + ctx.strokeRect(boxX, boxY, boxW, boxH); ctx.fillStyle = color; ctx.textAlign = 'left'; ctx.fillText(text, labelX, labelY); @@ -945,11 +1090,16 @@ if (!empty($currentLocation) && is_array($currentLocation)) {
| Beginn | ${formatDateTime(pass.aosTime)} (${azToCardinal(pass.aosAzimuth ?? 0)}) |
| Maximum | ${formatDateTime(pass.peakTime)} / ${pass.peakElevation.toFixed(1)}° |
| Sichtbares Maximum | ${visibility.visiblePeak.elevationDeg >= 0 ? `${formatDateTime(visibility.visiblePeak.time)} / ${visibility.visiblePeak.elevationDeg.toFixed(1)}°` : '–'} |
| Ende | ${formatDateTime(pass.losTime)} (${azToCardinal(pass.losAzimuth ?? 0)}) |
| Dauer | ${formatDurationMs(pass.durationMs)} |
| Sichtdauer | ${formatDurationMs(visibility.durationSec * 1000)} |
| Erdschatten | ${formatDateTime(pass.shadowEntry.time)} (${azToCardinal(pass.shadowEntry.azimuthDeg)}) |
| Sichtbarkeit | ${formatVisibilityBadge(visibility)} |
| Score | ${visibility.score.toFixed(2)} (${visibility.scorePercent}/100) |
| Himmel | ${visibility.sky.label} |
| Dunkelheit | ${visibility.darkness.label} |
| Mondphase | ${visibility.moonPhase.label} |
| Helligkeit | ${formatMag(pass.peakMag)} |
${observationSummary}
`; @@ -972,7 +1122,7 @@ if (!empty($currentLocation) && is_array($currentLocation)) { const upcomingCount = sat.passes.filter(p => p.losTime > now).length; const opt = document.createElement('option'); opt.value = satIdx; - opt.textContent = `${sat.name} (${upcomingCount} in den naechsten 7 Tagen)`; + opt.textContent = `${sat.name} (${upcomingCount} in den nächsten ${PASS_WINDOW_DAYS} Tagen)`; satSelectEl.appendChild(opt); }); @@ -1007,7 +1157,7 @@ if (!empty($currentLocation) && is_array($currentLocation)) { if (!isNaN(satIdx) && !isNaN(passIdx)) selectPass(satIdx, passIdx); }; - statusEl.textContent = `Berechnet vom heutigen lokalen Tag 00:00 Uhr bis in 7 Tagen (${rangeStart} bis ${rangeEnd}).`; + statusEl.textContent = `Berechnet vom heutigen lokalen Tag 00:00 Uhr bis in ${PASS_WINDOW_DAYS} Tagen (${rangeStart} bis ${rangeEnd}).`; } // ── PNG Export ────────────────────────────────────────────────────