import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ReferenceLine, ReferenceArea, ResponsiveContainer, Legend, } from 'recharts'; import type { CurvePoint } from '../../api/types'; interface Props { curve: CurvePoint[]; dusk: string; dawn: string; trueDarkStart?: string; trueDarkEnd?: string; meridianFlip?: string; transitUtc?: string; horizonPoints?: { az_deg: number; alt_deg: number }[]; moonSepDeg?: number; } function fmtHour(utc: string): string { return new Date(utc).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris', }); } /** Interpolate horizon alt at a given azimuth — mirrors backend horizon_alt() exactly. */ function horizonAlt(az: number, pts: { az_deg: number; alt_deg: number }[]): number { if (!pts.length) return 15; const norm = ((az % 360) + 360) % 360; const loIdx = Math.floor(norm) % 360; const hiIdx = (loIdx + 1) % 360; const frac = norm - Math.floor(norm); const loAlt = pts.find(p => p.az_deg === loIdx)?.alt_deg ?? 15; const hiAlt = pts.find(p => p.az_deg === hiIdx)?.alt_deg ?? 15; return loAlt + frac * (hiAlt - loAlt); } export default function AltitudeCurve({ curve, dusk, dawn, trueDarkStart, trueDarkEnd, meridianFlip, horizonPoints, moonSepDeg, }: Props) { if (!curve || curve.length === 0) { return (
No visibility curve available.
); } // Subsample to ~120 points max for rendering performance (1-min data = 480+ points) const stride = Math.max(1, Math.floor(curve.length / 120)); const sampled = curve.filter((_, i) => i % stride === 0); const data = sampled .filter(p => p.alt_deg > 0) // Only show above 0° altitude .map(p => { const horizonAltitude = horizonPoints?.length ? horizonAlt(p.az_deg, horizonPoints) : 15; const belowHorizon = p.alt_deg < horizonAltitude; return { time: p.utc, alt: belowHorizon ? null : Math.round(p.alt_deg * 10) / 10, altBelowHorizon: belowHorizon ? Math.round(p.alt_deg * 10) / 10 : null, // Only draw moon curve when above horizon moon: p.moon_alt_deg > 0 ? Math.round(p.moon_alt_deg * 10) / 10 : null, az: p.az_deg, label: fmtHour(p.utc), horizon: Math.round(horizonAltitude * 10) / 10, belowHorizon, // Flag for styling }; }); // Find contiguous windows where moon is above horizon — shade those periods in blue-warn // Also shade with a stronger tint if moonSepDeg < 30° (close approach) type MoonWindow = { x1: string; x2: string; close: boolean }; const moonWindows: MoonWindow[] = []; let winStart: { label: string; close: boolean } | null = null; for (let i = 0; i < data.length; i++) { const pt = data[i]; const moonUp = (pt.moon ?? 0) > 0; const close = moonSepDeg != null && moonSepDeg < 30 && moonUp; if (moonUp && !winStart) { winStart = { label: pt.label, close }; } else if (!moonUp && winStart) { moonWindows.push({ x1: winStart.label, x2: data[i - 1].label, close: winStart.close }); winStart = null; } } if (winStart && data.length > 0) { moonWindows.push({ x1: winStart.label, x2: data[data.length - 1].label, close: winStart.close }); } const nowUtc = new Date().toISOString(); return (
`${v}°`} /> { if (name === 'horizon') return [`${value}°`, 'Horizon']; if (name === 'moon') return [`${value}°`, 'Moon']; if (name === 'altBelowHorizon') return [`${value}°`, 'Altitude (below horizon)']; return [`${value}°`, 'Altitude']; }} labelStyle={{ color: 'var(--text-mid)' }} /> {/* True dark window shading */} {trueDarkStart && trueDarkEnd && ( )} {/* Moon-above-horizon shading — subtle blue tint; stronger orange if within 30° */} {moonWindows.map((w, i) => ( ))} {/* 15° line */} {/* 30° line */} {/* Meridian flip */} {meridianFlip && ( )} {/* Now marker */} {nowUtc >= dusk && nowUtc <= dawn && ( )} {/* Moon altitude curve — dimmed blue */} {/* Altitude below custom horizon — greyed out */} {/* Custom horizon step-line — red dashed */} {horizonPoints && horizonPoints.length > 0 && ( )} {/* Object altitude curve */}
); }