Initial Commit
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
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 (
|
||||
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12, padding: 16 }}>
|
||||
No visibility curve available.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div style={{ width: '100%', height: 240 }}>
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={data} margin={{ top: 4, right: 8, bottom: 4, left: -10 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: 'var(--text-lo)', fontSize: 10, fontFamily: 'IBM Plex Mono' }}
|
||||
interval="preserveStartEnd"
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 90]}
|
||||
tick={{ fill: 'var(--text-lo)', fontSize: 10, fontFamily: 'IBM Plex Mono' }}
|
||||
tickLine={false}
|
||||
tickFormatter={v => `${v}°`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'var(--bg-panel)',
|
||||
border: '1px solid var(--border-hi)',
|
||||
borderRadius: 4,
|
||||
fontFamily: 'IBM Plex Mono',
|
||||
fontSize: 11,
|
||||
color: 'var(--text-hi)',
|
||||
}}
|
||||
formatter={(value: number, name: string) => {
|
||||
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 && (
|
||||
<ReferenceArea
|
||||
x1={fmtHour(trueDarkStart)}
|
||||
x2={fmtHour(trueDarkEnd)}
|
||||
fill="var(--amber-glow)"
|
||||
strokeOpacity={0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Moon-above-horizon shading — subtle blue tint; stronger orange if within 30° */}
|
||||
{moonWindows.map((w, i) => (
|
||||
<ReferenceArea
|
||||
key={i}
|
||||
x1={w.x1}
|
||||
x2={w.x2}
|
||||
fill={w.close ? 'rgba(232,131,42,0.10)' : 'rgba(77,157,224,0.08)'}
|
||||
strokeOpacity={0}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 15° line */}
|
||||
<ReferenceLine y={15} stroke="var(--muted)" strokeDasharray="4 4" />
|
||||
{/* 30° line */}
|
||||
<ReferenceLine y={30} stroke="var(--good)" strokeDasharray="4 4" strokeOpacity={0.4} />
|
||||
|
||||
{/* Meridian flip */}
|
||||
{meridianFlip && (
|
||||
<ReferenceLine
|
||||
x={fmtHour(meridianFlip)}
|
||||
stroke="var(--amber)"
|
||||
strokeDasharray="6 3"
|
||||
label={{ value: 'Flip', fill: 'var(--amber)', fontSize: 9, fontFamily: 'IBM Plex Mono' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Now marker */}
|
||||
{nowUtc >= dusk && nowUtc <= dawn && (
|
||||
<ReferenceLine
|
||||
x={fmtHour(nowUtc)}
|
||||
stroke="var(--amber)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Moon altitude curve — dimmed blue */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="moon"
|
||||
stroke="#4d9de0"
|
||||
strokeWidth={1}
|
||||
dot={false}
|
||||
activeDot={false}
|
||||
strokeOpacity={0.5}
|
||||
strokeDasharray="4 2"
|
||||
/>
|
||||
|
||||
{/* Altitude below custom horizon — greyed out */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="altBelowHorizon"
|
||||
stroke="var(--good)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={false}
|
||||
strokeOpacity={0.5}
|
||||
/>
|
||||
|
||||
{/* Custom horizon step-line — red dashed */}
|
||||
{horizonPoints && horizonPoints.length > 0 && (
|
||||
<Line
|
||||
type="stepAfter"
|
||||
dataKey="horizon"
|
||||
stroke="var(--danger)"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="3 3"
|
||||
dot={false}
|
||||
activeDot={false}
|
||||
strokeOpacity={0.7}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Object altitude curve */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="alt"
|
||||
stroke="var(--good)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: 'var(--amber)' }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
ComposedChart,
|
||||
Bar,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
|
||||
interface YearPoint {
|
||||
date: string;
|
||||
alt_at_midnight: number;
|
||||
transit_alt: number;
|
||||
usable_min: number;
|
||||
moon_illumination: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
points: YearPoint[];
|
||||
}
|
||||
|
||||
const MONTH_ABBR = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
function altColor(alt: number): string {
|
||||
if (alt >= 50) return 'var(--good)';
|
||||
if (alt >= 30) return '#2ab8a0';
|
||||
if (alt >= 15) return 'var(--warn)';
|
||||
return 'var(--muted)';
|
||||
}
|
||||
|
||||
export default function YearlyVisibility({ points }: Props) {
|
||||
if (!points.length) return null;
|
||||
|
||||
// Sample to ~52 weekly points for readability
|
||||
const stride = Math.max(1, Math.floor(points.length / 52));
|
||||
const sampled = points.filter((_, i) => i % stride === 0);
|
||||
|
||||
const data = sampled.map(p => {
|
||||
const d = new Date(p.date + 'T00:00:00Z');
|
||||
return {
|
||||
label: `${MONTH_ABBR[d.getUTCMonth()]} ${d.getUTCDate()}`,
|
||||
month: d.getUTCMonth(),
|
||||
alt: Math.round(p.alt_at_midnight * 10) / 10,
|
||||
transit_alt: Math.round(p.transit_alt),
|
||||
usable: Math.round(p.usable_min / 60 * 10) / 10,
|
||||
moon: Math.round(p.moon_illumination * 100),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', marginBottom: 6 }}>
|
||||
ALTITUDE AT MIDNIGHT — next 12 months (varies as transit shifts through seasons)
|
||||
</div>
|
||||
<div style={{ width: '100%', height: 160 }}>
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={data} margin={{ top: 4, right: 8, bottom: 4, left: -18 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }}
|
||||
interval={Math.floor(data.length / 12)}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="alt"
|
||||
domain={[0, 90]}
|
||||
tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }}
|
||||
tickLine={false}
|
||||
tickFormatter={v => `${v}°`}
|
||||
width={32}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="moon"
|
||||
orientation="right"
|
||||
domain={[0, 100]}
|
||||
tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }}
|
||||
tickLine={false}
|
||||
tickFormatter={v => `${v}%`}
|
||||
width={28}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'var(--bg-panel)',
|
||||
border: '1px solid var(--border-hi)',
|
||||
borderRadius: 4,
|
||||
fontFamily: 'IBM Plex Mono',
|
||||
fontSize: 11,
|
||||
color: 'var(--text-hi)',
|
||||
}}
|
||||
formatter={(value: number, name: string) => {
|
||||
if (name === 'alt') return [`${value}°`, 'Alt at midnight'];
|
||||
if (name === 'moon') return [`${value}%`, 'Moon'];
|
||||
return [value, name];
|
||||
}}
|
||||
/>
|
||||
<Bar yAxisId="alt" dataKey="alt" radius={[1, 1, 0, 0]} maxBarSize={12}>
|
||||
{data.map((entry, i) => (
|
||||
<Cell key={i} fill={altColor(entry.alt)} fillOpacity={0.7} />
|
||||
))}
|
||||
</Bar>
|
||||
<Line
|
||||
yAxisId="moon"
|
||||
type="monotone"
|
||||
dataKey="moon"
|
||||
stroke="#4d9de0"
|
||||
strokeWidth={1}
|
||||
dot={false}
|
||||
strokeOpacity={0.5}
|
||||
strokeDasharray="3 2"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16, marginTop: 6, flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ color: 'var(--good)', label: '≥50° excellent' },
|
||||
{ color: '#2ab8a0', label: '30–50° good' },
|
||||
{ color: 'var(--warn)', label: '15–30° marginal' },
|
||||
{ color: 'var(--muted)', label: '<15° poor' },
|
||||
{ color: '#4d9de0', label: 'Moon %' },
|
||||
].map(({ color, label }) => (
|
||||
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<div style={{ width: 10, height: 10, background: color, borderRadius: 2, opacity: 0.8 }} />
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user