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 */}
);
}