Initial Commit
This commit is contained in:
@@ -0,0 +1,458 @@
|
||||
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 }} />{'20–50% — 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 · vertical line = today · 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user