diff --git a/public/satellitenhimmel.php b/public/satellitenhimmel.php index 90878e0..e06b156 100644 --- a/public/satellitenhimmel.php +++ b/public/satellitenhimmel.php @@ -451,7 +451,9 @@ if (!empty($currentLocation) && is_array($currentLocation)) {
+ +

@@ -505,7 +507,9 @@ if (!empty($currentLocation) && is_array($currentLocation)) { const statusEl = document.getElementById('satpassStatus'); const canvas = document.getElementById('satSkyCanvas'); const exportBtn = document.getElementById('satpassExportBtn'); + const exportSvgBtn = document.getElementById('satpassExportSvgBtn'); const invertBtn = document.getElementById('satpassInvertBtn'); + const mirrorBtn = document.getElementById('satpassMirrorBtn'); const skyTimeEl = document.getElementById('satpassSkyTime'); const satSelectEl = document.getElementById('satSelect'); const passSelectEl = document.getElementById('passSelect'); @@ -513,7 +517,9 @@ if (!empty($currentLocation) && is_array($currentLocation)) { const ctx = canvas.getContext('2d'); const exportLogoImg = new Image(); exportLogoImg.src = 'images/logoAM_sw.png'; + const EXPORT_BACKGROUND_COLOR = '#f3efe6'; let invertSky = false; + let mirrorEastWest = true; let exportOpaqueSky = false; let lastReferenceTime = null; let lastShowStars = false; @@ -521,6 +527,20 @@ if (!empty($currentLocation) && is_array($currentLocation)) { let lastSelectedPass = null; let lastSkyData = []; + function getDisplayedAzimuthDeg(azimuthDeg) { + const normalizedAzimuth = ((Number(azimuthDeg) % 360) + 360) % 360; + if (!mirrorEastWest) return normalizedAzimuth; + return (360 - normalizedAzimuth) % 360; + } + + function updateMirrorButton() { + if (!mirrorBtn) return; + mirrorBtn.textContent = mirrorEastWest + ? 'Ost links / West rechts' + : 'West links / Ost rechts'; + mirrorBtn.classList.toggle('is-active', mirrorEastWest); + } + function deg2rad(deg) { return deg * Math.PI / 180; } function rad2deg(rad) { return rad * 180 / Math.PI; } @@ -594,13 +614,15 @@ if (!empty($currentLocation) && is_array($currentLocation)) { } function azToCardinal(az) { - const dirs = ['N','NO','O','SO','S','SW','W','NW']; - return dirs[Math.round(az/45) % 8]; + const displayAz = getDisplayedAzimuthDeg(az); + const dirs = ['N','NW','W','SW','S','SO','O','NO']; + return dirs[Math.round(displayAz/45) % 8]; } function azToDirectionName(az) { - const dirs = ['Norden','Nordosten','Osten','Suedosten','Sueden','Suedwesten','Westen','Nordwesten']; - return dirs[Math.round(az/45) % 8]; + const displayAz = getDisplayedAzimuthDeg(az); + const dirs = ['Norden','Nordwesten','Westen','Suedwesten','Sueden','Suedosten','Osten','Nordosten']; + return dirs[Math.round(displayAz/45) % 8]; } function altitudeToPhrase(el) { @@ -857,10 +879,84 @@ if (!empty($currentLocation) && is_array($currentLocation)) { function projectPoint(azimuthDeg, elevationDeg, radius, cx, cy) { const r = ((90 - elevationDeg) / 90) * radius; - const a = deg2rad(azimuthDeg); + const a = deg2rad(getDisplayedAzimuthDeg(azimuthDeg)); return { x: cx + r*Math.sin(a), y: cy - r*Math.cos(a) }; } + function getSkyRenderMetrics() { + const box = canvas.parentElement.getBoundingClientRect(); + const size = Math.max(320, Math.floor(Math.min(box.width, 820))); + return { size, cx: size / 2, cy: size / 2, radius: size * 0.43 }; + } + + function getSkyPalette() { + return invertSky ? { + skyFill: exportOpaqueSky ? 'rgb(252,252,248)' : 'rgba(252,252,248,0.98)', + grid:'rgba(60,64,76,0.26)', gridText:'rgba(40,44,56,0.90)', cardinal:'#171b24', + constellationLine:'rgba(88,96,118,0.76)', constellationLabel:'rgba(44,50,66,0.92)', + star: a=>`rgba(10,12,18,${Math.max(0.72,a).toFixed(2)})`, + starLabel:'rgba(18,22,32,0.94)', shadowLine:'rgba(150,154,166,0.82)', + shadowMarker:'rgba(118,124,138,0.95)', shadowMarkerStroke:'rgba(24,28,36,0.95)', + shadowLabel:'#111722' + } : { + skyFill: exportOpaqueSky ? 'rgb(4,6,20)' : 'rgba(4,6,20,0.55)', + grid:'rgba(201,168,76,0.18)', gridText:'rgba(136,153,187,0.85)', cardinal:'#f0d990', + constellationLine:'rgba(132,152,214,0.58)', constellationLabel:'rgba(120,136,186,0.68)', + star: a=>`rgba(220,230,255,${a.toFixed(2)})`, + starLabel:'rgba(180,200,255,0.70)', shadowLine:'rgba(140,140,150,0.5)', + shadowMarker:'rgba(155,165,190,0.95)', shadowMarkerStroke:'rgba(15,20,32,0.95)', + shadowLabel:'#d7dcec' + }; + } + + function estimateTextWidth(text, fontSize, weight = 400) { + ctx.save(); + ctx.font = `${weight} ${fontSize}px Segoe UI`; + const width = ctx.measureText(String(text)).width; + ctx.restore(); + return width; + } + + function escapeXml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function toSvgNumber(value) { + return Number(value).toFixed(2); + } + + function buildSvgEndpointLabel(point, text, color, cx, cy, radius) { + const offsetX = point.x < cx ? 10 : -10; + const offsetY = point.y < cy ? -10 : 16; + const fontSize = 12; + const paddingX = 7; + const paddingY = 4; + const textWidth = estimateTextWidth(text, fontSize, 700); + let labelX = point.x + offsetX; + let labelY = point.y + offsetY; + if (offsetX < 0) labelX -= textWidth; + const minX = cx - radius + 6; + const maxX = cx + radius - textWidth - paddingX * 2 - 6; + const minY = cy - radius + fontSize + paddingY + 6; + const maxY = cy + radius - fontSize - paddingY - 6; + labelX = Math.max(minX, Math.min(maxX, labelX)); + labelY = Math.max(minY, Math.min(maxY, labelY)); + const boxX = labelX - paddingX; + const boxY = labelY - (fontSize / 2) - paddingY; + const boxW = textWidth + paddingX * 2; + const boxH = fontSize + paddingY * 2; + const fill = invertSky ? '#ffffff' : '#050814'; + const stroke = invertSky ? '#1b2230' : 'rgba(255,255,255,0.22)'; + return ` + + ${escapeXml(text)}`; + } + function drawEndpointLabel(point, text, color, cx, cy, radius) { const offsetX = point.x < cx ? 10 : -10; const offsetY = point.y < cy ? -10 : 16; @@ -927,8 +1023,7 @@ if (!empty($currentLocation) && is_array($currentLocation)) { function drawSky(passes, referenceTime=null, showStars=true) { lastReferenceTime = referenceTime; lastShowStars = showStars; - const box = canvas.parentElement.getBoundingClientRect(); - const size = Math.max(320, Math.floor(Math.min(box.width, 820))); + const { size, cx, cy, radius } = getSkyRenderMetrics(); const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = size * dpr; canvas.height = size * dpr; @@ -936,25 +1031,7 @@ if (!empty($currentLocation) && is_array($currentLocation)) { canvas.style.height = `${size}px`; ctx.setTransform(dpr,0,0,dpr,0,0); ctx.clearRect(0,0,size,size); - const cx=size/2, cy=size/2, radius=size*0.43; - - const palette = invertSky ? { - skyFill: exportOpaqueSky ? 'rgb(252,252,248)' : 'rgba(252,252,248,0.98)', - grid:'rgba(60,64,76,0.26)', gridText:'rgba(40,44,56,0.90)', cardinal:'#171b24', - constellationLine:'rgba(88,96,118,0.76)', constellationLabel:'rgba(44,50,66,0.92)', - star: a=>`rgba(10,12,18,${Math.max(0.72,a).toFixed(2)})`, - starLabel:'rgba(18,22,32,0.94)', shadowLine:'rgba(150,154,166,0.82)', - shadowMarker:'rgba(118,124,138,0.95)', shadowMarkerStroke:'rgba(24,28,36,0.95)', - shadowLabel:'#111722' - } : { - skyFill: exportOpaqueSky ? 'rgb(4,6,20)' : 'rgba(4,6,20,0.55)', - grid:'rgba(201,168,76,0.18)', gridText:'rgba(136,153,187,0.85)', cardinal:'#f0d990', - constellationLine:'rgba(132,152,214,0.58)', constellationLabel:'rgba(120,136,186,0.68)', - star: a=>`rgba(220,230,255,${a.toFixed(2)})`, - starLabel:'rgba(180,200,255,0.70)', shadowLine:'rgba(140,140,150,0.5)', - shadowMarker:'rgba(155,165,190,0.95)', shadowMarkerStroke:'rgba(15,20,32,0.95)', - shadowLabel:'#d7dcec' - }; + const palette = getSkyPalette(); ctx.fillStyle = palette.skyFill; ctx.beginPath(); ctx.arc(cx,cy,radius,0,Math.PI*2); ctx.fill(); @@ -973,7 +1050,8 @@ if (!empty($currentLocation) && is_array($currentLocation)) { ctx.fillStyle = palette.cardinal; ctx.font = '700 14px Segoe UI'; ctx.textAlign = 'center'; ctx.fillText('N', cx, cy-radius+18); ctx.fillText('S', cx, cy+radius-10); - ctx.fillText('W', cx-radius+14, cy+5); ctx.fillText('O', cx+radius-14, cy+5); + ctx.fillText(mirrorEastWest ? 'O' : 'W', cx-radius+14, cy+5); + ctx.fillText(mirrorEastWest ? 'W' : 'O', cx+radius-14, cy+5); ctx.fillText('Zenit', cx, cy-8); const lat = Number(SITE_LOCATION.latitude), lon = Number(SITE_LOCATION.longitude); @@ -1178,11 +1256,143 @@ if (!empty($currentLocation) && is_array($currentLocation)) { }); } - function createSatpassExportFileName() { + function createSatpassExportFileName(extension = 'png') { const date = lastSelectedPass?.aosTime instanceof Date ? lastSelectedPass.aosTime : new Date(); const parts = getTzParts(date, SITE_LOCATION.timezone); const name = (lastSelectedSatelliteName || 'satellit').toLowerCase().replace(/[^a-z0-9]+/gi,'_').replace(/^_+|_+$/g,'') || 'satellit'; - return `satellitenhimmel_${name}_${parts.year}-${padNumber(parts.month)}-${padNumber(parts.day)}_${padNumber(parts.hour)}-${padNumber(parts.minute)}.png`; + return `satellitenhimmel_${name}_${parts.year}-${padNumber(parts.month)}-${padNumber(parts.day)}_${padNumber(parts.hour)}-${padNumber(parts.minute)}.${extension}`; + } + + function generateSatpassSvgMarkup() { + const prevExport = exportOpaqueSky; + exportOpaqueSky = true; + try { + const { size, cx, cy, radius } = getSkyRenderMetrics(); + const palette = getSkyPalette(); + const lat = Number(SITE_LOCATION.latitude); + const lon = Number(SITE_LOCATION.longitude); + const starTime = lastReferenceTime ?? new Date(); + const parts = []; + parts.push(``); + parts.push(``); + parts.push(``); + parts.push(``); + + [radius, radius*(2/3), radius*(1/3)].forEach((r, i) => { + parts.push(``); + parts.push(`${i===0?0:i===1?30:60}°`); + }); + parts.push(``); + parts.push(``); + parts.push(`N`); + parts.push(`S`); + parts.push(`${mirrorEastWest ? 'O' : 'W'}`); + parts.push(`${mirrorEastWest ? 'W' : 'O'}`); + parts.push(`Zenit`); + + for (const line of CONSTELLATION_LINES) { + if (!Array.isArray(line) || line.length < 2) continue; + let segment = []; + for (const point of line) { + const pos = equatorialToHorizontal(point.ra, point.dec, lat, lon, starTime); + if (pos.altDeg < 0) { + if (segment.length > 1) { + parts.push(``); + } + segment = []; + continue; + } + segment.push(projectPoint(pos.azimuthDeg, pos.altDeg, radius, cx, cy)); + } + if (segment.length > 1) { + parts.push(``); + } + } + + if (lastShowStars) { + const occupiedLabelRects = []; + for (const star of BRIGHT_STARS) { + const pos = equatorialToHorizontal(star.ra, star.dec, lat, lon, starTime); + if (pos.altDeg < 0) continue; + const pt = projectPoint(pos.azimuthDeg, pos.altDeg, radius, cx, cy); + const starRadius = Math.max(1.0, Math.min(5.0, 4.5 - star.mag*1.0)); + const alpha = Math.max(0.5, Math.min(0.95, 0.95 - star.mag*0.12)); + parts.push(``); + if (star.name && star.mag <= 2.0) { + const lx = pt.x + starRadius + 3; + const ly = pt.y + 3; + const lw = estimateTextWidth(star.name, 10); + parts.push(`${escapeXml(star.name)}`); + occupiedLabelRects.push(buildTextRect(lx, ly, lw, 10)); + } + } + for (const con of CONSTELLATION_LABELS) { + const pos = equatorialToHorizontal(con.ra, con.dec, lat, lon, starTime); + if (pos.altDeg < 0) continue; + const pt = projectPoint(pos.azimuthDeg, pos.altDeg, radius, cx, cy); + const lw = estimateTextWidth(con.label, 11); + const pl = findFreeLabelPlacement(pt.x, pt.y, lw, 11, occupiedLabelRects, cx, cy, radius, 'center'); + if (!pl) continue; + parts.push(`${escapeXml(con.label)}`); + occupiedLabelRects.push(pl.rect); + } + } + + for (const item of lastSkyData.filter(entry => entry?.pass)) { + const pass = item.pass; + const pts = pass.path.filter(p => p.elevationDeg >= 0); + if (pts.length < 2) continue; + const trackColor = invertSky ? 'rgba(8,10,14,0.96)' : (PYTHON_RESULTS[0]?.color ?? '#f0d990'); + for (let i = 0; i < pts.length - 1; i++) { + const p0 = projectPoint(pts[i].azimuthDeg, pts[i].elevationDeg, radius, cx, cy); + const p1 = projectPoint(pts[i + 1].azimuthDeg, pts[i + 1].elevationDeg, radius, cx, cy); + if (pts[i].shadow) { + parts.push(``); + } else { + parts.push(``); + } + } + const startPt = projectPoint(pts[0].azimuthDeg, pts[0].elevationDeg, radius, cx, cy); + const endPt = projectPoint(pts[pts.length - 1].azimuthDeg, pts[pts.length - 1].elevationDeg, radius, cx, cy); + parts.push(``); + parts.push(``); + parts.push(buildSvgEndpointLabel(startPt, formatTime(pass.aosTime), trackColor, cx, cy, radius)); + parts.push(buildSvgEndpointLabel(endPt, formatTime(pass.losTime), trackColor, cx, cy, radius)); + if (isPeakVisible(pass)) { + const peakPt = projectPoint(pass.peakAzimuth, pass.peakElevation, radius, cx, cy); + parts.push(``); + parts.push(buildSvgEndpointLabel(peakPt, formatTime(pass.peakTime), trackColor, cx, cy, radius)); + } + if (pass.shadowEntry) { + const sp = projectPoint(pass.shadowEntry.azimuthDeg, pass.shadowEntry.elevationDeg, radius, cx, cy); + parts.push(``); + parts.push(buildSvgEndpointLabel(sp, `ES ${formatTime(pass.shadowEntry.time)}`, palette.shadowLabel, cx, cy, radius)); + } + } + + parts.push(``); + return parts.join(''); + } finally { + exportOpaqueSky = prevExport; + } + } + + async function exportSatpassSvg() { + try { + const markup = generateSatpassSvgMarkup(); + const blob = new Blob([markup], { type: 'image/svg+xml;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = createSatpassExportFileName('svg'); + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 2000); + } catch (err) { + console.error(err); + statusEl.textContent = 'SVG-Export fehlgeschlagen.'; + } } async function exportSatpassPng() { @@ -1193,7 +1403,8 @@ if (!empty($currentLocation) && is_array($currentLocation)) { const ec = document.createElement('canvas'); ec.width = canvas.width; ec.height = canvas.height; const ectx = ec.getContext('2d'); - ectx.fillStyle = '#ffffff'; ectx.fillRect(0,0,ec.width,ec.height); + ectx.fillStyle = EXPORT_BACKGROUND_COLOR; + ectx.fillRect(0,0,ec.width,ec.height); ectx.drawImage(canvas, 0, 0); try { await ensureExportLogoLoaded(); @@ -1204,7 +1415,7 @@ if (!empty($currentLocation) && is_array($currentLocation)) { const blob = await canvasToBlob(ec, 'image/png'); const url = URL.createObjectURL(blob); const a = document.createElement('a'); - a.href = url; a.download = createSatpassExportFileName(); + a.href = url; a.download = createSatpassExportFileName('png'); document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 2000); } catch (err) { @@ -1224,10 +1435,19 @@ if (!empty($currentLocation) && is_array($currentLocation)) { invertBtn.classList.toggle('is-active', invertSky); drawSky(lastSkyData, lastReferenceTime, lastShowStars); }); + mirrorBtn?.addEventListener('click', () => { + mirrorEastWest = !mirrorEastWest; + updateMirrorButton(); + drawSky(lastSkyData, lastReferenceTime, lastShowStars); + }); exportBtn?.addEventListener('click', () => exportSatpassPng().catch(err => { console.error(err); statusEl.textContent = 'PNG-Export fehlgeschlagen.'; })); + exportSvgBtn?.addEventListener('click', () => exportSatpassSvg().catch(err => { + console.error(err); statusEl.textContent = 'SVG-Export fehlgeschlagen.'; + })); + updateMirrorButton(); buildDropdowns(); drawSky([], null, false);