Files
Astronome/frontend/src/pages/Calendar.tsx
T
2026-04-10 00:02:40 +02:00

459 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import { useCalendar, useCalendarDate } from '../hooks/useCalendar';
import { useQuery } from '@tanstack/react-query';
import { api } from '../api';
import MoonPhaseIcon from '../components/sky/MoonPhaseIcon';
import { format, startOfMonth, endOfMonth, eachDayOfInterval } from 'date-fns';
const FILTER_LABELS: Record<string, string> = {
sv220: 'HaOIII', c2: 'SII/OIII', sv260: 'LP', uvir: 'UV/IR',
};
function fmtTime(utc?: string): string {
if (!utc) return '—';
return new Date(utc).toLocaleTimeString('fr-FR', {
hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris',
});
}
type CalDay = {
date: string;
moon_illumination?: number;
max_usable_min?: number;
avg_max_alt?: number;
};
/** 12-month horizontal lunar cycle timeline */
function NewMoonTimeline({ days }: { days: CalDay[] }) {
const { data: nmData } = useQuery({
queryKey: ['new-moon-windows'],
queryFn: () => api.calendar.getNewMoonWindows(),
staleTime: 24 * 60 * 60_000,
});
// Build a map of new moon date → top targets
const nmTargetMap = new Map<string, { id: string; name: string; common_name?: string; max_alt_deg?: number; recommended_filter?: string }[]>();
for (const w of nmData?.windows ?? []) {
nmTargetMap.set(w.date, w.top_targets);
}
if (days.length === 0) {
return <div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>Loading</div>;
}
// Chunk into lunar cycles: whenever illumination crosses a local minimum (new moon ≈ < 5%)
// For display, just show month-by-month rows with a continuous illumination bar.
const today = format(new Date(), 'yyyy-MM-dd');
// Group days into months
const monthMap = new Map<string, CalDay[]>();
for (const d of days) {
const month = d.date.slice(0, 7); // "2026-04"
if (!monthMap.has(month)) monthMap.set(month, []);
monthMap.get(month)!.push(d);
}
return (
<div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 12, fontSize: 10, fontFamily: 'var(--font-mono)', color: 'var(--text-lo)' }}>
<span><span style={{ display: 'inline-block', width: 12, height: 8, background: 'rgba(61,186,114,0.35)', borderRadius: 2, marginRight: 4 }} />{'< 20% moon — prime narrowband'}</span>
<span><span style={{ display: 'inline-block', width: 12, height: 8, background: 'rgba(232,131,42,0.3)', borderRadius: 2, marginRight: 4 }} />{'2050% — broadband OK'}</span>
<span><span style={{ display: 'inline-block', width: 12, height: 8, background: 'rgba(224,82,82,0.25)', borderRadius: 2, marginRight: 4 }} />{'> 50% — challenging'}</span>
</div>
</div>
{Array.from(monthMap.entries()).map(([month, mDays]) => {
// Find new moon dates in this month (local illumination minimum, < 5%)
const newMoonDates = mDays.filter((d, i) => {
const illum = d.moon_illumination ?? 0.5;
const prev = mDays[i - 1]?.moon_illumination ?? illum;
const next = mDays[i + 1]?.moon_illumination ?? illum;
return illum < 0.05 && illum <= prev && illum <= next;
});
// Days in this month ordered
const sorted = [...mDays].sort((a, b) => a.date.localeCompare(b.date));
const daysInMonth = sorted.length;
return (
<div key={month} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{
fontFamily: 'var(--font-mono)',
fontSize: 11,
color: 'var(--text-mid)',
width: 80,
flexShrink: 0,
}}>
{format(new Date(month + '-01'), 'MMM yyyy')}
</span>
{/* Moon illumination bar */}
<div style={{ flex: 1, position: 'relative', height: 28, display: 'flex' }}>
{sorted.map(d => {
const illum = d.moon_illumination ?? 0;
const isToday = d.date === today;
const isNewMoon = newMoonDates.some(nm => nm.date === d.date);
let bg: string;
if (illum < 0.2) bg = 'rgba(61,186,114,0.35)';
else if (illum < 0.5) bg = 'rgba(232,131,42,0.30)';
else bg = 'rgba(224,82,82,0.25)';
return (
<div key={d.date} title={`${d.date}${Math.round(illum * 100)}%`}
style={{
flex: 1,
background: bg,
borderLeft: isToday ? '2px solid var(--amber)' : '1px solid transparent',
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
{/* New moon marker */}
{isNewMoon && (
<div style={{
width: 4, height: 4,
borderRadius: '50%',
background: 'var(--text-hi)',
position: 'absolute',
top: 2,
}} />
)}
{/* Full moon marker */}
{illum > 0.95 && (
<div style={{
width: 4, height: 4,
borderRadius: '50%',
background: 'rgba(255,255,255,0.5)',
border: '1px solid rgba(255,255,255,0.7)',
position: 'absolute',
top: 2,
}} />
)}
{/* Illumination curve as height */}
<div style={{
position: 'absolute',
bottom: 0, left: 0, right: 0,
height: `${illum * 100}%`,
background: 'rgba(255,255,255,0.08)',
}} />
</div>
);
})}
{/* Day count axis: show 1, 8, 15, 22, 28 */}
{sorted.filter((_, i) => [0, 7, 14, 21, 27].includes(i)).map(d => {
const idx = sorted.indexOf(d);
const pct = (idx / daysInMonth) * 100;
return (
<div key={`lbl-${d.date}`} style={{
position: 'absolute',
left: `${pct}%`,
bottom: -13,
fontFamily: 'var(--font-mono)',
fontSize: 8,
color: 'var(--text-lo)',
transform: 'translateX(-50%)',
}}>
{parseInt(d.date.slice(8))}
</div>
);
})}
</div>
{/* New moon windows: date + top 3 emission targets */}
<div style={{ width: 200, flexShrink: 0 }}>
{newMoonDates.map(d => {
const targets = nmTargetMap.get(d.date) ?? [];
return (
<div key={d.date} style={{ marginBottom: 4 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--amber)', marginBottom: 2 }}>
{d.date.slice(5)}
</div>
{targets.map(t => (
<div key={t.id} style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--text-lo)', paddingLeft: 8 }}>
{t.common_name ?? t.name}
{t.max_alt_deg != null && (
<span style={{ color: 'var(--text-lo)', marginLeft: 4 }}>{t.max_alt_deg.toFixed(0)}°</span>
)}
</div>
))}
</div>
);
})}
</div>
</div>
</div>
);
})}
<div style={{ marginTop: 12, fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
= new moon &nbsp;·&nbsp; vertical line = today &nbsp;·&nbsp; hover a day for date + illumination %
</div>
</div>
);
}
export default function Calendar() {
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [newMoonView, setNewMoonView] = useState(false);
const { data: calData3 } = useCalendar(3);
const { data: calData12 } = useCalendar(12);
const { data: dateData } = useCalendarDate(selectedDate ?? '');
const calData = newMoonView ? calData12 : calData3;
const today = new Date();
const dayMap = new Map<string, CalDay>(
(calData?.days ?? []).map(d => [d.date, d])
);
const months = [today, new Date(today.getFullYear(), today.getMonth() + 1), new Date(today.getFullYear(), today.getMonth() + 2)];
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}>
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 22 }}>Calendar</h1>
<button
onClick={() => { setNewMoonView(v => !v); setSelectedDate(null); }}
style={{
padding: '4px 12px',
borderRadius: 10,
border: `1px solid ${newMoonView ? 'var(--amber)' : 'var(--border)'}`,
background: newMoonView ? 'var(--amber-glow)' : 'var(--bg-panel)',
color: newMoonView ? 'var(--amber)' : 'var(--text-mid)',
fontFamily: 'var(--font-mono)',
fontSize: 11,
cursor: 'pointer',
}}
>
New Moon View
</button>
</div>
{newMoonView ? (
<NewMoonTimeline days={calData12?.days ?? []} />
) : (
<div style={{ display: 'grid', gridTemplateColumns: selectedDate ? '2fr 1fr' : '1fr', gap: 20 }}>
<div>
{months.map(month => {
const monthStart = startOfMonth(month);
const monthEnd = endOfMonth(month);
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
const firstDow = monthStart.getDay();
return (
<div key={month.toISOString()} style={{ marginBottom: 28 }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 16, fontWeight: 700, marginBottom: 10, color: 'var(--text-hi)' }}>
{format(month, 'MMMM yyyy')}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 3 }}>
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(d => (
<div key={d} style={{ textAlign: 'center', fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--text-lo)', paddingBottom: 4 }}>
{d}
</div>
))}
{Array.from({ length: (firstDow + 6) % 7 }).map((_, i) => (
<div key={`e${i}`} />
))}
{days.map(day => {
const dateStr = format(day, 'yyyy-MM-dd');
const info = dayMap.get(dateStr);
const usable = info?.max_usable_min ?? 0;
const moonIllum = info?.moon_illumination;
const isSelected = selectedDate === dateStr;
const isToday = dateStr === format(today, 'yyyy-MM-dd');
const isNarrowbandNight = moonIllum != null && moonIllum < 0.2;
let bg = 'var(--bg-panel)';
if (usable > 240) bg = 'rgba(42,184,160,0.2)';
else if (usable > 60) bg = 'rgba(232,131,42,0.15)';
return (
<div
key={dateStr}
onClick={() => setSelectedDate(isSelected ? null : dateStr)}
style={{
background: isSelected ? 'var(--amber-glow)' : bg,
border: `1px solid ${isSelected ? 'var(--amber)' : isNarrowbandNight ? 'var(--amber)' : isToday ? 'var(--text-lo)' : 'var(--border)'}`,
borderRadius: 3,
padding: '4px',
cursor: 'pointer',
minHeight: 56,
position: 'relative',
}}
>
<div style={{
fontFamily: 'var(--font-mono)',
fontSize: 11,
color: isToday ? 'var(--amber)' : 'var(--text-mid)',
marginBottom: 2,
fontWeight: isToday ? 700 : 400,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span>{format(day, 'd')}</span>
{moonIllum != null && (
<MoonPhaseIcon illumination={moonIllum} size={12} />
)}
</div>
{usable > 0 && (
<div style={{
height: 3,
background: usable > 240 ? 'var(--teal)' : 'var(--amber)',
borderRadius: 2,
width: `${Math.min(100, (usable / 480) * 100)}%`,
marginBottom: 2,
}} />
)}
{moonIllum != null && (
<div style={{
fontFamily: 'var(--font-mono)',
fontSize: 9,
color: isNarrowbandNight ? 'var(--amber)' : 'var(--text-lo)',
}}>
{Math.round(moonIllum * 100)}%
</div>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
{/* Side panel for selected date */}
{selectedDate && (
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: 16, height: 'fit-content', position: 'sticky', top: 8 }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 16, fontWeight: 700, marginBottom: 12 }}>
{selectedDate}
</div>
{/* Moon + dark window */}
{(() => {
const illum = dateData?.moon_illumination ?? dayMap.get(selectedDate)?.moon_illumination;
const info = dayMap.get(selectedDate);
return illum != null ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<MoonPhaseIcon illumination={illum} size={28} />
<div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text-hi)', fontWeight: 700 }}>
{Math.round(illum * 100)}%
</div>
{illum < 0.2 && (
<div style={{ fontSize: 10, color: 'var(--amber)', fontFamily: 'var(--font-mono)' }}>
Prime narrowband night
</div>
)}
</div>
{info?.max_usable_min ? (
<div style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--teal)' }}>
{Math.floor(info.max_usable_min / 60)}h {info.max_usable_min % 60}m dark
</div>
) : null}
</div>
) : null;
})()}
{/* Tonight summary */}
{dateData?.tonight && (
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 10px', marginBottom: 10 }}>
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
<tbody>
{[
['Dusk', dateData.tonight.astro_dusk_utc ? fmtTime(dateData.tonight.astro_dusk_utc) : '—'],
['Dawn', dateData.tonight.astro_dawn_utc ? fmtTime(dateData.tonight.astro_dawn_utc) : '—'],
['True dark', dateData.tonight.true_dark_start_utc && dateData.tonight.true_dark_end_utc
? `${fmtTime(dateData.tonight.true_dark_start_utc)} ${fmtTime(dateData.tonight.true_dark_end_utc)}`
: '—'],
['Moon', dateData.tonight.moon_phase_name ?? '—'],
].map(([label, val]) => (
<tr key={label}>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', paddingBottom: 3, width: 70 }}>{label}</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-hi)' }}>{val}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Weather */}
{dateData?.weather && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10, padding: '6px 10px', background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 4 }}>
{dateData.weather.go_nogo && (
<span style={{
fontFamily: 'var(--font-mono)',
fontSize: 11,
fontWeight: 700,
color: dateData.weather.go_nogo === 'go' ? 'var(--good)' : dateData.weather.go_nogo === 'marginal' ? 'var(--warn)' : 'var(--danger)',
}}>
{dateData.weather.go_nogo.toUpperCase()}
</span>
)}
{dateData.weather.temp_c != null && (
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-mid)' }}>
{dateData.weather.temp_c.toFixed(0)}°C
</span>
)}
{dateData.weather.cloudcover != null && (
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
Cloud {dateData.weather.cloudcover}/9
</span>
)}
{dateData.weather.seeing != null && (
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
Seeing {dateData.weather.seeing}/8
</span>
)}
</div>
)}
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)', marginBottom: 8, letterSpacing: '0.06em', textTransform: 'uppercase' }}>
Top Targets
</div>
{!dateData && (
<div style={{ color: 'var(--text-lo)', fontSize: 12 }}>No precomputed data for this date.</div>
)}
{dateData?.top_targets?.length === 0 && (
<div style={{ color: 'var(--text-lo)', fontSize: 12 }}>No visible targets.</div>
)}
{dateData?.top_targets?.map((t, i) => (
<div key={t.id} style={{
display: 'flex',
gap: 8,
alignItems: 'center',
padding: '5px 0',
borderBottom: '1px solid var(--border)',
}}>
<span style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 10, width: 16 }}>{i + 1}</span>
<div style={{ flex: 1 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-hi)' }}>
{t.common_name ?? t.name}
</div>
<div style={{ fontSize: 10, color: 'var(--text-lo)' }}>{t.name}</div>
</div>
{t.recommended_filter && (
<span className={`filter-pill ${t.recommended_filter}`} style={{ fontSize: 9 }}>
{FILTER_LABELS[t.recommended_filter] ?? ''}
</span>
)}
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--good)' }}>
{t.max_alt_deg?.toFixed(0)}°
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}