Add target comparison modal, integration goal progress, and session planning + full catalog expansion
Features added this session: - Target comparison: side-by-side overlay (CompareModal) from Targets page via ⊕ button on each row; shows altitude curves, key times, filter recommendations and per-filter integration progress for two targets simultaneously - Integration goal progress dashboard card: per-target keeper minutes vs goal hours (from CLAUDE.md §16.3) broken down by filter, with color-coded progress bars; powered by new stats.integration_goals backend query - Session planning timeline: Gantt-style "Plan Tonight" section on Dashboard (PlanningTimeline component) — search targets, set durations, sequential scheduling from dusk, overrun warnings, clipboard export - Slew-optimized run order toggle (nearest-neighbor sort by RA/Dec angular distance) - Best Nights 14-day card + Monthly Highlights card on Dashboard Catalog expansions: - Sharpless (Sh2), VdB, LDN, Barnard dark nebulae, LBN, Melotte, Collinder, Gum, RCW, Abell PN, Abell GC, PGC bright subset - Caldwell/Arp/Melotte/Collinder number columns + cross-reference maps - Weather score multiplier applied to composite sort - galaxy_cluster type (ACO badge) throughout TypeBadge, CSS, filter chips Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ export default function App() {
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/targets" element={<Targets />} />
|
||||
<Route path="/targets/:targetId" element={<Targets />} />
|
||||
<Route path="/calendar" element={<Calendar />} />
|
||||
<Route path="/stats" element={<Stats />} />
|
||||
<Route path="/gallery" element={<Gallery />} />
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
HorizonPoint,
|
||||
LogEntry,
|
||||
Phd2Log,
|
||||
SimilarTarget,
|
||||
Stats,
|
||||
Target,
|
||||
TargetNotes,
|
||||
@@ -66,6 +67,7 @@ export interface TargetsParams {
|
||||
min_usable_min?: number;
|
||||
mosaic_only?: boolean;
|
||||
not_imaged?: boolean;
|
||||
show_custom?: boolean;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
@@ -84,6 +86,7 @@ export const api = {
|
||||
if (params.min_usable_min !== undefined) q.set('min_usable_min', String(params.min_usable_min));
|
||||
if (params.mosaic_only) q.set('mosaic_only', 'true');
|
||||
if (params.not_imaged) q.set('not_imaged', 'true');
|
||||
if (params.show_custom === false) q.set('show_custom', 'false');
|
||||
return get<TargetsResponse>(`/targets?${q}`);
|
||||
},
|
||||
get: (id: string): Promise<Target> => get(`/targets/${id}`),
|
||||
@@ -94,6 +97,7 @@ export const api = {
|
||||
yearly: (id: string): Promise<{ catalog_id: string; points: { date: string; alt_at_midnight: number; transit_alt: number; usable_min: number; moon_illumination: number }[] }> => get(`/targets/${id}/yearly`),
|
||||
getNotes: (id: string): Promise<TargetNotes> => get(`/targets/${id}/notes`),
|
||||
putNotes: (id: string, notes: string): Promise<{ status: string }> => put(`/targets/${id}/notes`, { notes }),
|
||||
similar: (id: string): Promise<{ similar: SimilarTarget[]; target_transit?: string }> => get(`/targets/${id}/similar`),
|
||||
},
|
||||
|
||||
tonight: {
|
||||
@@ -107,6 +111,10 @@ export const api = {
|
||||
get(`/calendar/${date}`),
|
||||
getNewMoonWindows: (): Promise<{ windows: { date: string; illumination: number; top_targets: { id: string; name: string; common_name?: string; max_alt_deg?: number; recommended_filter?: string }[] }[] }> =>
|
||||
get('/calendar/new-moon-windows'),
|
||||
getBestNights: (): Promise<{ nights: { date: string; score: number; moon_illumination: number; visible_count: number; avg_usable_min: number; top_targets: { id: string; name: string; common_name?: string; obj_type: string; max_alt_deg?: number; usable_min?: number; recommended_filter?: string }[] }[] }> =>
|
||||
get('/calendar/best-nights'),
|
||||
getMonthlyHighlights: (): Promise<{ month: string; highlights: { id: string; name: string; common_name?: string; obj_type: string; constellation?: string; peak_alt?: number; best_usable_min?: number; recommended_filter?: string; keeper_count: number }[] }> =>
|
||||
get('/calendar/monthly-highlights'),
|
||||
},
|
||||
|
||||
weather: {
|
||||
@@ -134,7 +142,11 @@ export const api = {
|
||||
delete: (id: number): Promise<{ status: string; id: number }> => del(`/phd2/${id}`),
|
||||
upload: (formData: FormData): Promise<{ id: number; duplicate: boolean; duplicate_id?: number; analysis: unknown; message?: string }> => {
|
||||
return fetch(`${base}/phd2/upload`, { method: 'POST', body: formData })
|
||||
.then(r => r.json() as Promise<{ id: number; duplicate: boolean; duplicate_id?: number; analysis: unknown; message?: string }>);
|
||||
.then(async r => {
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error((data as { error?: string }).error ?? `HTTP ${r.status}`);
|
||||
return data as { id: number; duplicate: boolean; duplicate_id?: number; analysis: unknown; message?: string };
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -146,7 +158,11 @@ export const api = {
|
||||
delete: (id: number): Promise<{ id: number }> => del(`/gallery/item/${id}`),
|
||||
upload: (catalogId: string, formData: FormData): Promise<{ id: number; url: string }> => {
|
||||
return fetch(`${base}/gallery/${catalogId}`, { method: 'POST', body: formData })
|
||||
.then(r => r.json() as Promise<{ id: number; url: string }>);
|
||||
.then(async r => {
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error((data as { error?: string }).error ?? `HTTP ${r.status}`);
|
||||
return data as { id: number; url: string };
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface Target {
|
||||
surface_brightness?: number;
|
||||
hubble_type?: string;
|
||||
messier_num?: number;
|
||||
caldwell_num?: number;
|
||||
arp_num?: number;
|
||||
is_highlight: boolean;
|
||||
fov_fill_pct?: number;
|
||||
mosaic_flag: boolean;
|
||||
@@ -32,6 +34,8 @@ export interface Target {
|
||||
moon_sep_deg?: number;
|
||||
is_visible_tonight?: boolean;
|
||||
total_integration_min?: number;
|
||||
is_custom?: boolean;
|
||||
urgency?: 'peak' | 'rising' | 'declining' | null;
|
||||
}
|
||||
|
||||
export interface TargetsResponse {
|
||||
@@ -164,6 +168,31 @@ export interface HorizonPoint {
|
||||
alt_deg: number;
|
||||
}
|
||||
|
||||
export interface IntegrationGap {
|
||||
catalog_id: string;
|
||||
name: string;
|
||||
common_name?: string;
|
||||
obj_type: string;
|
||||
sv220_min: number;
|
||||
c2_min: number;
|
||||
uvir_min: number;
|
||||
sv260_min: number;
|
||||
missing_filters: string[];
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
date: string;
|
||||
catalog_id: string;
|
||||
name: string;
|
||||
common_name?: string;
|
||||
obj_type?: string;
|
||||
filter_id: string;
|
||||
integration_min: number;
|
||||
quality: string;
|
||||
notes?: string;
|
||||
gallery_url?: string;
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
total_sessions: number;
|
||||
total_integration_min: number;
|
||||
@@ -174,6 +203,10 @@ export interface Stats {
|
||||
quality: { quality: string; count: number }[];
|
||||
top_targets: { id: string; name: string; common_name?: string; obj_type: string; sessions: number; total_min: number }[];
|
||||
guiding: { date: string; rms_total?: number; rms_ra?: number; rms_dec?: number }[];
|
||||
integration_gaps: IntegrationGap[];
|
||||
history: HistoryEntry[];
|
||||
catalogue_completion: { name: string; total: number; keepers: number; pct: number }[];
|
||||
integration_goals: IntegrationGoal[];
|
||||
}
|
||||
|
||||
export interface Workflow {
|
||||
@@ -230,8 +263,32 @@ export interface TargetNotes {
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface SimilarTarget {
|
||||
id: string;
|
||||
name: string;
|
||||
common_name?: string;
|
||||
obj_type: string;
|
||||
size_arcmin_maj?: number;
|
||||
fov_fill_pct?: number;
|
||||
messier_num?: number;
|
||||
max_alt_deg?: number;
|
||||
transit_utc?: string;
|
||||
recommended_filter?: string;
|
||||
}
|
||||
|
||||
export interface FilterBreakdownItem {
|
||||
filter_id: string;
|
||||
total_min: number;
|
||||
sessions: number;
|
||||
}
|
||||
|
||||
export interface IntegrationGoal {
|
||||
id: string;
|
||||
name: string;
|
||||
common_name?: string;
|
||||
obj_type: string;
|
||||
sv220_min: number;
|
||||
c2_min: number;
|
||||
uvir_min: number;
|
||||
sv260_min: number;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
import {
|
||||
ComposedChart,
|
||||
Bar,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface YearPoint {
|
||||
date: string;
|
||||
@@ -24,107 +14,145 @@ interface Props {
|
||||
|
||||
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)';
|
||||
function altColorHex(alt: number): string {
|
||||
if (alt >= 50) return '#3dba72';
|
||||
if (alt >= 30) return '#2ab8a0';
|
||||
if (alt >= 15) return 'var(--warn)';
|
||||
return 'var(--muted)';
|
||||
if (alt >= 15) return '#e8c030';
|
||||
if (alt > 0) return '#3a4258';
|
||||
return '#1a1f2e';
|
||||
}
|
||||
|
||||
/** Calendar heatmap: 12 rows × 31 cols, one cell per day. */
|
||||
function CalendarHeatmap({ points }: { points: YearPoint[] }) {
|
||||
const [tooltip, setTooltip] = useState<{ x: number; y: number; point: YearPoint } | null>(null);
|
||||
|
||||
// Map date string → point
|
||||
const byDate = new Map(points.map(p => [p.date, p]));
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// Build a 12-row × 31-col grid from the first point's date
|
||||
const startDate = points[0]?.date ? new Date(points[0].date + 'T00:00:00Z') : new Date();
|
||||
const startMonth = startDate.getUTCMonth();
|
||||
const startYear = startDate.getUTCFullYear();
|
||||
|
||||
const CELL = 13;
|
||||
const GAP = 2;
|
||||
const LABEL_W = 28;
|
||||
const rows: { month: number; year: number; days: (YearPoint | null)[] }[] = [];
|
||||
|
||||
for (let m = 0; m < 12; m++) {
|
||||
const month = (startMonth + m) % 12;
|
||||
const year = startYear + Math.floor((startMonth + m) / 12);
|
||||
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
||||
const days: (YearPoint | null)[] = [];
|
||||
for (let d = 1; d <= 31; d++) {
|
||||
if (d > daysInMonth) {
|
||||
days.push(null);
|
||||
} else {
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
days.push(byDate.get(dateStr) ?? null);
|
||||
}
|
||||
}
|
||||
rows.push({ month, year, days });
|
||||
}
|
||||
|
||||
const svgW = LABEL_W + 31 * (CELL + GAP);
|
||||
const svgH = 12 * (CELL + GAP);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<svg width={svgW} height={svgH} style={{ display: 'block', overflow: 'visible' }}>
|
||||
{rows.map((row, ri) => (
|
||||
<g key={ri} transform={`translate(0, ${ri * (CELL + GAP)})`}>
|
||||
<text
|
||||
x={LABEL_W - 4}
|
||||
y={CELL - 2}
|
||||
textAnchor="end"
|
||||
fill="var(--text-lo)"
|
||||
fontSize={9}
|
||||
fontFamily="IBM Plex Mono"
|
||||
>
|
||||
{MONTH_ABBR[row.month]}
|
||||
</text>
|
||||
{row.days.map((pt, di) => {
|
||||
const x = LABEL_W + di * (CELL + GAP);
|
||||
const isToday = pt?.date === today;
|
||||
if (!pt) {
|
||||
return (
|
||||
<rect key={di} x={x} y={0} width={CELL} height={CELL}
|
||||
fill="#111520" rx={2} />
|
||||
);
|
||||
}
|
||||
const color = altColorHex(pt.alt_at_midnight);
|
||||
const moonAlpha = Math.round(pt.moon_illumination * 60);
|
||||
return (
|
||||
<g key={di}>
|
||||
<rect x={x} y={0} width={CELL} height={CELL} fill={color} rx={2} opacity={0.85} />
|
||||
{/* Moon overlay — blue tint proportional to illumination */}
|
||||
<rect x={x} y={0} width={CELL} height={CELL}
|
||||
fill={`rgba(77,157,224,${(moonAlpha / 255).toFixed(2)})`} rx={2} />
|
||||
{isToday && (
|
||||
<rect x={x} y={0} width={CELL} height={CELL}
|
||||
fill="none" stroke="var(--amber)" strokeWidth={1.5} rx={2} />
|
||||
)}
|
||||
<rect x={x} y={0} width={CELL} height={CELL} fill="transparent" rx={2}
|
||||
onMouseEnter={e => setTooltip({ x: x + CELL + 4, y: ri * (CELL + GAP), point: pt })}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
{tooltip && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: tooltip.x,
|
||||
top: tooltip.y,
|
||||
background: 'var(--bg-panel)',
|
||||
border: '1px solid var(--border-hi)',
|
||||
borderRadius: 4,
|
||||
padding: '5px 8px',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 10,
|
||||
color: 'var(--text-hi)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<div style={{ color: 'var(--text-mid)', marginBottom: 2 }}>{tooltip.point.date}</div>
|
||||
<div>Alt: <span style={{ color: altColorHex(tooltip.point.alt_at_midnight) }}>{tooltip.point.alt_at_midnight.toFixed(1)}°</span></div>
|
||||
<div>Usable: {(tooltip.point.usable_min / 60).toFixed(1)}h</div>
|
||||
<div>Moon: {Math.round(tooltip.point.moon_illumination * 100)}%</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 style={{ fontSize: 11, color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', marginBottom: 8 }}>
|
||||
SEASONAL VISIBILITY — next 12 months · altitude at local midnight
|
||||
</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' }}>
|
||||
|
||||
<CalendarHeatmap points={points} />
|
||||
|
||||
<div style={{ display: 'flex', gap: 14, marginTop: 8, flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ color: 'var(--good)', label: '≥50° excellent' },
|
||||
{ color: '#3dba72', 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 %' },
|
||||
{ color: '#e8c030', label: '15–30° marginal' },
|
||||
{ color: '#3a4258', label: '<15° poor' },
|
||||
{ color: 'rgba(77,157,224,0.6)', label: 'Moon overlay' },
|
||||
].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 }} />
|
||||
<div style={{ width: 10, height: 10, background: color, borderRadius: 2 }} />
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import type { Target } from '../../api/types';
|
||||
|
||||
interface PlanEntry {
|
||||
target: Target;
|
||||
durationMin: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
targets: Target[]; // tonight's visible targets
|
||||
dusk: string;
|
||||
dawn: string;
|
||||
}
|
||||
|
||||
const FILTER_LABELS: Record<string, string> = {
|
||||
sv220: 'Ha+OIII', c2: 'SII+OIII', sv260: 'LP', uvir: 'UV/IR',
|
||||
};
|
||||
const FILTER_COLORS: Record<string, string> = {
|
||||
sv220: 'var(--good)', c2: 'var(--teal)', sv260: 'var(--blue)', uvir: 'var(--amber)',
|
||||
};
|
||||
|
||||
function fmtTime(utc: string): string {
|
||||
return new Date(utc).toLocaleTimeString('fr-FR', {
|
||||
hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris',
|
||||
});
|
||||
}
|
||||
|
||||
function fmtUtcOffset(ms: number, baseMs: number): string {
|
||||
const d = new Date(baseMs + ms);
|
||||
return d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
|
||||
}
|
||||
|
||||
export default function PlanningTimeline({ targets, dusk, dawn }: Props) {
|
||||
const [plan, setPlan] = useState<PlanEntry[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [exported, setExported] = useState(false);
|
||||
|
||||
const duskMs = new Date(dusk).getTime();
|
||||
const dawnMs = new Date(dawn).getTime();
|
||||
const nightMs = dawnMs - duskMs;
|
||||
|
||||
const filtered = useMemo(() =>
|
||||
targets.filter(t =>
|
||||
t.best_start_utc && t.best_end_utc &&
|
||||
!plan.find(p => p.target.id === t.id) &&
|
||||
(search === '' ||
|
||||
(t.common_name ?? t.name).toLowerCase().includes(search.toLowerCase()) ||
|
||||
t.name.toLowerCase().includes(search.toLowerCase()))
|
||||
).slice(0, 20),
|
||||
[targets, plan, search]);
|
||||
|
||||
const addTarget = (t: Target, durationMin = 120) => {
|
||||
setPlan(p => [...p, { target: t, durationMin }]);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
const removeTarget = (id: string) => setPlan(p => p.filter(e => e.target.id !== id));
|
||||
const moveUp = (i: number) => setPlan(p => { const n = [...p]; [n[i-1], n[i]] = [n[i], n[i-1]]; return n; });
|
||||
const moveDown = (i: number) => setPlan(p => { const n = [...p]; [n[i], n[i+1]] = [n[i+1], n[i]]; return n; });
|
||||
const updateDuration = (i: number, min: number) => setPlan(p => p.map((e, j) => j === i ? { ...e, durationMin: min } : e));
|
||||
|
||||
// Compute start times
|
||||
const schedule = useMemo(() => {
|
||||
let cursor = duskMs;
|
||||
return plan.map(entry => {
|
||||
const start = cursor;
|
||||
const end = cursor + entry.durationMin * 60_000;
|
||||
cursor = end;
|
||||
return { entry, startMs: start, endMs: end };
|
||||
});
|
||||
}, [plan, duskMs]);
|
||||
|
||||
const totalMinutes = plan.reduce((s, e) => s + e.durationMin, 0);
|
||||
const nightMinutes = Math.round(nightMs / 60_000);
|
||||
const overrun = totalMinutes > nightMinutes;
|
||||
|
||||
const exportText = () => {
|
||||
const lines = schedule.map(({ entry, startMs, endMs }) =>
|
||||
`${fmtUtcOffset(startMs - duskMs, duskMs)} ${entry.target.common_name ?? entry.target.name} (${entry.target.name}) · ${entry.durationMin}min [${FILTER_LABELS[entry.target.recommended_filter ?? ''] ?? '—'}] → ${fmtUtcOffset(endMs - duskMs, duskMs)}`
|
||||
);
|
||||
const text = lines.join('\n');
|
||||
void navigator.clipboard.writeText(text).then(() => {
|
||||
setExported(true);
|
||||
setTimeout(() => setExported(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Add targets search bar */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search targets to add to plan…"
|
||||
style={{
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
background: 'var(--bg-deep)', border: '1px solid var(--border)',
|
||||
borderRadius: 4, color: 'var(--text-hi)',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12, padding: '7px 12px',
|
||||
}}
|
||||
/>
|
||||
{search && filtered.length > 0 && (
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 4, marginTop: 2, maxHeight: 200, overflowY: 'auto' }}>
|
||||
{filtered.map(t => (
|
||||
<div key={t.id}
|
||||
onClick={() => addTarget(t)}
|
||||
style={{ padding: '6px 12px', cursor: 'pointer', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 10 }}
|
||||
>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--amber)', flex: 1 }}>
|
||||
{t.common_name ?? t.name}
|
||||
{t.common_name && <span style={{ color: 'var(--text-lo)', marginLeft: 6, fontSize: 10 }}>{t.name}</span>}
|
||||
</span>
|
||||
{t.max_alt_deg != null && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--good)' }}>{t.max_alt_deg.toFixed(0)}°</span>
|
||||
)}
|
||||
{t.recommended_filter && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: FILTER_COLORS[t.recommended_filter] ?? 'var(--muted)' }}>
|
||||
{FILTER_LABELS[t.recommended_filter] ?? t.recommended_filter}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Plan list + Gantt */}
|
||||
{plan.length === 0 ? (
|
||||
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12, padding: '16px 0' }}>
|
||||
Search and add targets above to build your plan.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Total / overrun warning */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 12 }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: overrun ? 'var(--danger)' : 'var(--good)' }}>
|
||||
{overrun ? '⚠ ' : ''}Total: {Math.floor(totalMinutes / 60)}h {totalMinutes % 60}m / {Math.floor(nightMinutes / 60)}h {nightMinutes % 60}m night
|
||||
</span>
|
||||
<button
|
||||
onClick={exportText}
|
||||
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: exported ? 'var(--good)' : 'var(--text-lo)', background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 3, padding: '3px 10px', cursor: 'pointer' }}
|
||||
>
|
||||
{exported ? '✓ Copied' : '↓ Copy run order'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Timeline header */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 80px 32px 32px', gap: 8, marginBottom: 4 }}>
|
||||
<div style={{ position: 'relative', height: 14 }}>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map(frac => (
|
||||
<span key={frac} style={{ position: 'absolute', left: `${frac * 100}%`, transform: 'translateX(-50%)', fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--text-lo)' }}>
|
||||
{new Date(duskMs + frac * nightMs).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' })}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
|
||||
{/* Plan rows */}
|
||||
{schedule.map(({ entry, startMs, endMs }, i) => {
|
||||
const { target, durationMin } = entry;
|
||||
const left = Math.max(0, Math.min(100, ((startMs - duskMs) / nightMs) * 100));
|
||||
const width = Math.max(1, Math.min(100 - left, ((endMs - startMs) / nightMs) * 100));
|
||||
const filterColor = FILTER_COLORS[target.recommended_filter ?? ''] ?? 'var(--muted)';
|
||||
// Warn if block extends past dawn or past target's best window
|
||||
const pastDawn = endMs > dawnMs;
|
||||
const pastWindow = target.best_end_utc && endMs > new Date(target.best_end_utc).getTime();
|
||||
const warn = pastDawn || pastWindow;
|
||||
|
||||
return (
|
||||
<div key={target.id} style={{ display: 'grid', gridTemplateColumns: '1fr 80px 32px 32px', gap: 8, marginBottom: 8, alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: warn ? 'var(--warn)' : 'var(--text-mid)', marginBottom: 3 }}>
|
||||
{target.common_name ?? target.name}
|
||||
{warn && <span style={{ marginLeft: 6, fontSize: 10, color: 'var(--warn)' }}>
|
||||
{pastDawn ? '⚠ past dawn' : '⚠ past window'}
|
||||
</span>}
|
||||
</div>
|
||||
<div style={{ position: 'relative', height: 16, background: 'var(--bg-deep)', borderRadius: 3 }}>
|
||||
{/* Target's visibility window */}
|
||||
{target.best_start_utc && target.best_end_utc && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: `${Math.max(0, (new Date(target.best_start_utc).getTime() - duskMs) / nightMs * 100)}%`,
|
||||
width: `${Math.min(100, (new Date(target.best_end_utc).getTime() - new Date(target.best_start_utc).getTime()) / nightMs * 100)}%`,
|
||||
height: '100%',
|
||||
background: filterColor,
|
||||
opacity: 0.15,
|
||||
borderRadius: 3,
|
||||
}} />
|
||||
)}
|
||||
{/* Planned block */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: `${left}%`,
|
||||
width: `${width}%`,
|
||||
height: '100%',
|
||||
background: warn ? 'var(--danger)' : filterColor,
|
||||
borderRadius: 3,
|
||||
opacity: 0.8,
|
||||
}} />
|
||||
{/* Start/end times */}
|
||||
<div style={{ position: 'absolute', left: `${left + 0.5}%`, top: 1, fontFamily: 'var(--font-mono)', fontSize: 8, color: '#fff', zIndex: 1 }}>
|
||||
{fmtTime(new Date(startMs).toISOString())}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Duration input */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<input
|
||||
type="number"
|
||||
min={15}
|
||||
max={480}
|
||||
step={15}
|
||||
value={durationMin}
|
||||
onChange={e => updateDuration(i, parseInt(e.target.value) || 60)}
|
||||
style={{ width: 48, background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 3, color: 'var(--text-hi)', fontFamily: 'var(--font-mono)', fontSize: 11, padding: '2px 4px', textAlign: 'right' }}
|
||||
/>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>m</span>
|
||||
</div>
|
||||
{/* Move up/down */}
|
||||
<button onClick={() => moveUp(i)} disabled={i === 0} style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)', background: 'none', border: '1px solid var(--border)', borderRadius: 3, padding: '2px 4px', cursor: 'pointer', opacity: i === 0 ? 0.3 : 1 }}>↑</button>
|
||||
{/* Remove */}
|
||||
<button onClick={() => removeTarget(target.id)} style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--danger)', background: 'none', border: '1px solid var(--border)', borderRadius: 3, padding: '2px 4px', cursor: 'pointer' }}>✕</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
const ITEMS = [
|
||||
{ id: 'polar', label: 'Polar alignment verified' },
|
||||
{ id: 'focus', label: 'Focus achieved (Bahtinov / auto-focus)' },
|
||||
{ id: 'guiding', label: 'Guiding RMS < 1″' },
|
||||
{ id: 'dew', label: 'Dew heater powered on' },
|
||||
{ id: 'battery', label: 'Battery / power supply checked' },
|
||||
{ id: 'cap', label: 'Lens cap removed' },
|
||||
];
|
||||
|
||||
const LS_KEY = 'astronome_session_checklist_v1';
|
||||
|
||||
interface ChecklistState {
|
||||
date: string;
|
||||
checked: string[];
|
||||
}
|
||||
|
||||
function loadState(duskDate: string): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (raw) {
|
||||
const state = JSON.parse(raw) as ChecklistState;
|
||||
if (state.date === duskDate) return state.checked;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
function saveState(duskDate: string, checked: string[]) {
|
||||
const state: ChecklistState = { date: duskDate, checked };
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
duskDate: string; // "2026-04-17" — auto-reset key
|
||||
}
|
||||
|
||||
export default function SessionChecklist({ duskDate }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [checked, setChecked] = useState<string[]>(() => loadState(duskDate));
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setChecked(prev => {
|
||||
const next = prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id];
|
||||
saveState(duskDate, next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const done = checked.length;
|
||||
const total = ITEMS.length;
|
||||
const allDone = done === total;
|
||||
|
||||
return (
|
||||
<div style={{ background: 'var(--bg-panel)', border: `1px solid ${allDone ? 'var(--good)' : 'var(--border)'}`, borderRadius: 6, overflow: 'hidden', transition: 'border-color 0.2s' }}>
|
||||
<div
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '10px 14px', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', flex: 1 }}>
|
||||
Pre-session Checklist
|
||||
</span>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 11,
|
||||
color: allDone ? 'var(--good)' : done > 0 ? 'var(--warn)' : 'var(--text-lo)',
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
{allDone ? '✓ Ready' : `${done}/${total}`}
|
||||
</span>
|
||||
<div style={{ width: 60, height: 4, background: 'var(--bg-hover)', borderRadius: 2, overflow: 'hidden' }}>
|
||||
<div style={{ width: `${(done / total) * 100}%`, height: '100%', background: allDone ? 'var(--good)' : 'var(--warn)', borderRadius: 2, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
<span style={{ color: 'var(--text-lo)', fontSize: 11 }}>{expanded ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div style={{ borderTop: '1px solid var(--border)', padding: '10px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{ITEMS.map(item => {
|
||||
const isChecked = checked.includes(item.id);
|
||||
return (
|
||||
<label
|
||||
key={item.id}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer' }}
|
||||
>
|
||||
<div
|
||||
onClick={() => toggle(item.id)}
|
||||
style={{
|
||||
width: 16, height: 16, borderRadius: 3, flexShrink: 0,
|
||||
border: `1.5px solid ${isChecked ? 'var(--good)' : 'var(--border-hi)'}`,
|
||||
background: isChecked ? 'var(--good)' : 'var(--bg-deep)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'background 0.15s, border-color 0.15s',
|
||||
}}
|
||||
>
|
||||
{isChecked && (
|
||||
<svg width="10" height="8" viewBox="0 0 10 8" fill="none">
|
||||
<path d="M1 4L3.5 6.5L9 1" stroke="#080a0f" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12,
|
||||
color: isChecked ? 'var(--text-mid)' : 'var(--text-hi)',
|
||||
textDecoration: isChecked ? 'line-through' : 'none',
|
||||
transition: 'color 0.15s',
|
||||
}}>
|
||||
{item.label}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => { setChecked([]); saveState(duskDate, []); }}
|
||||
style={{
|
||||
marginTop: 4, alignSelf: 'flex-start',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10,
|
||||
color: 'var(--text-lo)', background: 'none',
|
||||
border: '1px solid var(--border)', borderRadius: 3,
|
||||
padding: '2px 8px', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../../api';
|
||||
import { useTonight } from '../../hooks/useTonight';
|
||||
import AltitudeCurve from '../charts/AltitudeCurve';
|
||||
import TypeBadge from './TypeBadge';
|
||||
import type { Target } from '../../api/types';
|
||||
|
||||
const GOAL_HOURS: Record<string, Record<string, number>> = {
|
||||
galaxy: { uvir: 4.0, sv260: 6.0 },
|
||||
emission_nebula: { sv220: 3.0, c2: 4.0, sv260: 8.0, uvir: 12.0 },
|
||||
reflection_nebula: { uvir: 3.0, sv260: 5.0 },
|
||||
planetary_nebula: { sv220: 2.0, c2: 3.0 },
|
||||
snr: { sv220: 5.0, c2: 6.0 },
|
||||
open_cluster: { uvir: 1.0 },
|
||||
globular_cluster: { uvir: 1.5 },
|
||||
dark_nebula: { uvir: 3.0 },
|
||||
};
|
||||
|
||||
const FILTER_LABELS: Record<string, string> = {
|
||||
sv220: 'Ha+OIII', c2: 'SII+OIII', sv260: 'LP', uvir: 'UV/IR',
|
||||
};
|
||||
|
||||
const SUITABILITY_COLOR: Record<string, string> = {
|
||||
ideal: 'var(--good)', good: 'var(--teal)', marginal: 'var(--warn)', unsuitable: 'var(--muted)',
|
||||
};
|
||||
|
||||
function fmtTime(utc?: string): string {
|
||||
if (!utc) return '—';
|
||||
return new Date(utc).toLocaleTimeString('fr-FR', {
|
||||
hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris',
|
||||
});
|
||||
}
|
||||
|
||||
function fmtMin(min?: number): string {
|
||||
if (min == null) return '—';
|
||||
if (min < 60) return `${min}m`;
|
||||
return `${Math.floor(min / 60)}h ${min % 60}m`;
|
||||
}
|
||||
|
||||
function IntegrationBar({ obj_type, filter, keeperMin }: {
|
||||
obj_type: string; filter?: string; keeperMin: number;
|
||||
}) {
|
||||
const goals = GOAL_HOURS[obj_type];
|
||||
if (!goals) return null;
|
||||
const goalMin = (filter && goals[filter] ? goals[filter] : Object.values(goals)[0]) * 60;
|
||||
const pct = Math.min((keeperMin / goalMin) * 100, 100);
|
||||
const color = pct >= 100 ? 'var(--good)' : pct >= 60 ? 'var(--warn)' : 'var(--danger)';
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
|
||||
{FILTER_LABELS[filter ?? ''] ?? 'Primary filter'} goal
|
||||
</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color }}>
|
||||
{fmtMin(keeperMin)} / {fmtMin(goalMin)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: 5, background: 'var(--bg-deep)', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', width: `${pct}%`, background: color, borderRadius: 3, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetColumn({ target }: { target: Target }) {
|
||||
const { data: tonight } = useTonight();
|
||||
const { data: curveData } = useQuery({
|
||||
queryKey: ['curve', target.id],
|
||||
queryFn: () => api.targets.curve(target.id),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const { data: logData } = useQuery({
|
||||
queryKey: ['log', target.id],
|
||||
queryFn: () => api.log.forTarget(target.id),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const { data: filterData } = useQuery({
|
||||
queryKey: ['filters', target.id],
|
||||
queryFn: () => api.targets.filters(target.id),
|
||||
staleTime: 10 * 60_000,
|
||||
});
|
||||
|
||||
const dusk = tonight?.astro_dusk_utc ?? '';
|
||||
const dawn = tonight?.astro_dawn_utc ?? '';
|
||||
|
||||
const topFilters = filterData?.recommendations.filter(r => r.suitability !== 'unsuitable').slice(0, 3) ?? [];
|
||||
const primaryFilter = topFilters[0]?.filter_id;
|
||||
|
||||
const keeperMin = logData?.filter_breakdown
|
||||
.filter(fb => fb.filter_id === primaryFilter)
|
||||
.reduce((s, fb) => s + fb.total_min, 0) ?? 0;
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<TypeBadge type={target.obj_type} />
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 14, fontWeight: 700, color: 'var(--amber)' }}>
|
||||
{target.common_name ?? target.name}
|
||||
</span>
|
||||
</div>
|
||||
{target.common_name && (
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)' }}>{target.name}</div>
|
||||
)}
|
||||
{target.constellation && (
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', marginTop: 2 }}>
|
||||
{target.constellation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Altitude curve */}
|
||||
{dusk && dawn && curveData?.curve.length ? (
|
||||
<AltitudeCurve
|
||||
curve={curveData.curve}
|
||||
dusk={dusk}
|
||||
dawn={dawn}
|
||||
trueDarkStart={tonight?.true_dark_start_utc}
|
||||
trueDarkEnd={tonight?.true_dark_end_utc}
|
||||
moonSepDeg={target.moon_sep_deg}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ height: 240, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>
|
||||
No curve available
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key stats */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 12px', margin: '12px 0', fontFamily: 'var(--font-mono)', fontSize: 11 }}>
|
||||
{[
|
||||
['Max Alt', `${target.max_alt_deg?.toFixed(0) ?? '—'}°`],
|
||||
['Usable', fmtMin(target.usable_min)],
|
||||
['Transit', fmtTime(target.transit_utc)],
|
||||
['Best start', fmtTime(target.best_start_utc)],
|
||||
['Moon sep', `${target.moon_sep_deg?.toFixed(0) ?? '—'}°`],
|
||||
['Size', target.size_arcmin_maj ? `${target.size_arcmin_maj.toFixed(1)}′` : '—'],
|
||||
].map(([label, val]) => (
|
||||
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0', borderBottom: '1px solid var(--border)' }}>
|
||||
<span style={{ color: 'var(--text-lo)' }}>{label}</span>
|
||||
<span style={{ color: 'var(--text-hi)' }}>{val}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filter recommendations */}
|
||||
{topFilters.length > 0 && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 6 }}>
|
||||
Filters tonight
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{topFilters.map(f => (
|
||||
<div key={f.filter_id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10,
|
||||
background: SUITABILITY_COLOR[f.suitability] + '22',
|
||||
color: SUITABILITY_COLOR[f.suitability],
|
||||
border: `1px solid ${SUITABILITY_COLOR[f.suitability]}44`,
|
||||
borderRadius: 3, padding: '1px 6px',
|
||||
minWidth: 50, textAlign: 'center',
|
||||
}}>
|
||||
{f.suitability}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)' }}>
|
||||
{FILTER_LABELS[f.filter_id] ?? f.filter_id}
|
||||
</span>
|
||||
{f.est_integration_hours && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', marginLeft: 'auto' }}>
|
||||
{f.est_integration_hours}h goal
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Integration progress */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 8 }}>
|
||||
Integration (keepers)
|
||||
</div>
|
||||
{logData?.filter_breakdown.length ? (
|
||||
<>
|
||||
{logData.filter_breakdown.map(fb => (
|
||||
<div key={fb.filter_id} style={{ marginBottom: 8 }}>
|
||||
<IntegrationBar
|
||||
obj_type={target.obj_type}
|
||||
filter={fb.filter_id}
|
||||
keeperMin={fb.total_min}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)' }}>
|
||||
Not yet imaged
|
||||
</div>
|
||||
)}
|
||||
{keeperMin > 0 && primaryFilter && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<IntegrationBar
|
||||
obj_type={target.obj_type}
|
||||
filter={primaryFilter}
|
||||
keeperMin={keeperMin}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
targets: [Target, Target];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function CompareModal({ targets, onClose }: Props) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
background: 'rgba(8,10,15,0.85)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 24,
|
||||
}}
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div style={{
|
||||
background: 'var(--bg-panel)', border: '1px solid var(--border-hi)',
|
||||
borderRadius: 8, width: '100%', maxWidth: 1100, maxHeight: '90vh',
|
||||
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '12px 20px', borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontSize: 13, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase' }}>
|
||||
Target Comparison
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text-lo)', background: 'none', border: 'none', cursor: 'pointer', padding: '2px 6px' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ display: 'flex', gap: 0, overflow: 'auto', flex: 1 }}>
|
||||
<div style={{ flex: 1, padding: 20, overflow: 'auto', borderRight: '1px solid var(--border)' }}>
|
||||
<TargetColumn target={targets[0]} />
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: 20, overflow: 'auto' }}>
|
||||
<TargetColumn target={targets[1]} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import type { Target, Workflow } from '../../api/types';
|
||||
import { useTargetCurve, useTargetFilters, useTargetVisibility, useTargetWorkflow, useTargetYearly } from '../../hooks/useTargets';
|
||||
import { useTargetCurve, useTargetFilters, useTargetSimilar, useTargetVisibility, useTargetWorkflow, useTargetYearly } from '../../hooks/useTargets';
|
||||
import { useTargetLog } from '../../hooks/useLog';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../../api';
|
||||
@@ -18,7 +18,7 @@ interface Props {
|
||||
target: Target;
|
||||
}
|
||||
|
||||
const TABS = ['Tonight', 'Target', 'Filters & Workflow', 'Log & Gallery', 'Yearly'];
|
||||
const TABS = ['Overview', 'Filters & Workflow', 'Log & Gallery', 'Yearly'];
|
||||
|
||||
const WORKFLOW_SHORT: Record<string, string> = {
|
||||
'HA+OIII Dual Narrowband (SV220)': 'HaOIII',
|
||||
@@ -72,6 +72,147 @@ function fmtTime(utc?: string): string {
|
||||
return new Date(utc).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
|
||||
}
|
||||
|
||||
// Observer longitude from config (Villevieille, France)
|
||||
const OBS_LON = 4.1167;
|
||||
|
||||
/** Compute current hour angle (degrees) for a target at the given RA (degrees). */
|
||||
function currentHourAngle(ra_deg: number): number {
|
||||
const now = Date.now() / 1000; // unix seconds
|
||||
const jd = 2440587.5 + now / 86400.0;
|
||||
const t = (jd - 2451545.0) / 36525.0;
|
||||
const gmst = (280.46061837 + 360.98564736629 * (jd - 2451545.0) + 0.000387933 * t * t) % 360;
|
||||
const lst = ((gmst + OBS_LON) % 360 + 360) % 360;
|
||||
let ha = ((lst - ra_deg) % 360 + 360) % 360;
|
||||
if (ha > 180) ha -= 360; // range -180..+180
|
||||
return ha;
|
||||
}
|
||||
|
||||
function fmtHa(ha_deg: number): string {
|
||||
const sign = ha_deg >= 0 ? '+' : '-';
|
||||
const abs = Math.abs(ha_deg);
|
||||
const h = Math.floor(abs / 15);
|
||||
const m = Math.floor((abs % 15) * 4);
|
||||
const s = Math.round(((abs % 15) * 4 - m) * 60);
|
||||
return `${sign}${h}h ${m < 10 ? '0' : ''}${m}m ${s < 10 ? '0' : ''}${s}s`;
|
||||
}
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = () => {
|
||||
void navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<button
|
||||
onClick={copy}
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10,
|
||||
color: copied ? 'var(--good)' : 'var(--text-lo)',
|
||||
background: 'var(--bg-void)', border: '1px solid var(--border)',
|
||||
borderRadius: 3, padding: '2px 6px', cursor: 'pointer',
|
||||
marginLeft: 6, transition: 'color 0.15s',
|
||||
}}
|
||||
>
|
||||
{copied ? '✓' : 'copy'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Sensor constants for ToupTek ATR2600C / IMX571 at f/6.9, Bortle 5
|
||||
const READ_NOISE_E = 3.5;
|
||||
const DARK_E_PER_S = 0.002;
|
||||
const SUB_SEC = 180; // 3-min subs
|
||||
|
||||
// Sky background e-/px/s by filter (empirical for Bortle 5, AT71)
|
||||
const SKY_BG: Record<string, number> = {
|
||||
uvir: 3.0,
|
||||
sv260: 1.8,
|
||||
sv220: 0.04,
|
||||
c2: 0.03,
|
||||
};
|
||||
|
||||
/** Source signal e-/px/s from surface brightness (mag/arcsec²), Bortle 5 calibration. */
|
||||
function signalFromSB(sb: number): number {
|
||||
// Reference: SB=21 → ~0.001 e-/px/s at this aperture+scale
|
||||
return 0.001 * Math.pow(10, (21 - sb) / 2.5);
|
||||
}
|
||||
|
||||
function subsNeeded(signal_e_s: number, sky_e_s: number, targetSnr: number): number {
|
||||
const s = signal_e_s * SUB_SEC; // signal per sub
|
||||
const b = sky_e_s * SUB_SEC; // sky per sub
|
||||
const d = DARK_E_PER_S * SUB_SEC; // dark per sub
|
||||
const r2 = READ_NOISE_E * READ_NOISE_E;
|
||||
if (s <= 0) return 999;
|
||||
const noise_per_sub = s + b + d + r2;
|
||||
return Math.ceil((targetSnr * targetSnr * noise_per_sub) / (s * s));
|
||||
}
|
||||
|
||||
function ImagingCalculator({ target, filterId }: { target: Target; filterId: string }) {
|
||||
const [targetSnr, setTargetSnr] = useState(20);
|
||||
const sb = target.surface_brightness;
|
||||
const sky = SKY_BG[filterId] ?? 1.0;
|
||||
|
||||
const signal = sb != null ? signalFromSB(sb) : null;
|
||||
const n = signal != null ? subsNeeded(signal, sky, targetSnr) : null;
|
||||
const totalMin = n != null ? n * (SUB_SEC / 60) : null;
|
||||
const totalH = totalMin != null ? (totalMin / 60).toFixed(1) : null;
|
||||
|
||||
return (
|
||||
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 4, padding: '12px 14px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 10 }}>
|
||||
SNR Calculator — 3-min subs · IMX571 · f/6.9 · Bortle 5
|
||||
</div>
|
||||
{sb == null ? (
|
||||
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>
|
||||
Surface brightness not available for this object.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: 24, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', marginBottom: 4 }}>
|
||||
Target SNR: <strong style={{ color: 'var(--amber)' }}>{targetSnr}</strong>
|
||||
</div>
|
||||
<input
|
||||
type="range" min={10} max={50} step={5}
|
||||
value={targetSnr}
|
||||
onChange={e => setTargetSnr(parseInt(e.target.value))}
|
||||
style={{ accentColor: 'var(--amber)', width: 140 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>Subs needed</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 20, fontWeight: 700, color: n != null && n < 100 ? 'var(--good)' : 'var(--warn)' }}>
|
||||
{n != null ? (n > 500 ? '500+' : n) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>Total time</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 20, fontWeight: 700, color: 'var(--amber)' }}>
|
||||
{totalH != null ? `${totalH}h` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>Sessions ~2h</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 20, fontWeight: 700, color: 'var(--blue)' }}>
|
||||
{totalH != null ? `×${Math.ceil(parseFloat(totalH) / 2)}` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>SB source</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 14, color: 'var(--text-mid)' }}>
|
||||
{sb.toFixed(1)} mag/″²
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DetailDrawer({ target }: Props) {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [selectedFilter, setSelectedFilter] = useState('sv220');
|
||||
@@ -85,16 +226,17 @@ export default function DetailDrawer({ target }: Props) {
|
||||
const { data: workflowData } = useTargetWorkflow(target.id, selectedFilter);
|
||||
const { data: logData } = useTargetLog(target.id);
|
||||
const { data: horizonData } = useHorizon();
|
||||
const { data: yearlyData } = useTargetYearly(target.id, tab === 4);
|
||||
const { data: yearlyData } = useTargetYearly(target.id, tab === 3);
|
||||
const { data: similarData } = useTargetSimilar(target.id);
|
||||
const { data: galleryData } = useQuery({
|
||||
queryKey: ['gallery', target.id],
|
||||
queryFn: () => api.gallery.list(target.id),
|
||||
enabled: tab === 3,
|
||||
enabled: tab === 2,
|
||||
});
|
||||
const { data: notesData } = useQuery({
|
||||
queryKey: ['target-notes', target.id],
|
||||
queryFn: () => api.targets.getNotes(target.id),
|
||||
enabled: tab === 3,
|
||||
enabled: tab === 2,
|
||||
});
|
||||
const saveNotesMutation = useMutation({
|
||||
mutationFn: (text: string) => api.targets.putNotes(target.id, text),
|
||||
@@ -128,125 +270,210 @@ export default function DetailDrawer({ target }: Props) {
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '16px 20px' }}>
|
||||
{/* Tab 1: Tonight */}
|
||||
{/* Tab 1: Overview — altitude curve + metadata side by side */}
|
||||
{tab === 0 && (
|
||||
<div>
|
||||
{curveData?.curve && curveData.curve.length > 0 ? (
|
||||
<AltitudeCurve
|
||||
curve={curveData.curve}
|
||||
dusk={tonight?.astro_dusk_utc ?? ''}
|
||||
dawn={tonight?.astro_dawn_utc ?? ''}
|
||||
trueDarkStart={tonight?.true_dark_start_utc}
|
||||
trueDarkEnd={tonight?.true_dark_end_utc}
|
||||
meridianFlip={visData?.meridian_flip_utc}
|
||||
horizonPoints={horizonData?.points}
|
||||
moonSepDeg={visData?.moon_sep_deg}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ color: 'var(--text-lo)', fontSize: 12, fontFamily: 'var(--font-mono)', marginBottom: 12 }}>
|
||||
Curve data loading…
|
||||
{/* Top section: metadata left + altitude curve right */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '220px 1fr', gap: 20, marginBottom: 20 }}>
|
||||
{/* Left: DSS image + metadata */}
|
||||
<div>
|
||||
<img
|
||||
src={dssUrl}
|
||||
alt={`DSS ${target.name}`}
|
||||
style={{ width: '100%', borderRadius: 3, background: '#000', marginBottom: 6 }}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div style={{ fontSize: 10, color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', marginBottom: 10 }}>
|
||||
DSS Digitized Sky Survey
|
||||
</div>
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
|
||||
<tbody>
|
||||
{[
|
||||
['Type', target.obj_type],
|
||||
['Constellation', target.constellation ?? '—'],
|
||||
['RA', target.ra_h],
|
||||
['Dec', target.dec_dms],
|
||||
['Size', target.size_arcmin_maj ? `${target.size_arcmin_maj.toFixed(1)}′ × ${(target.size_arcmin_min ?? target.size_arcmin_maj).toFixed(1)}′` : '—'],
|
||||
['Magnitude', target.mag_v?.toFixed(1) ?? '—'],
|
||||
['SB', target.surface_brightness ? `${target.surface_brightness.toFixed(1)} mag/″²` : '—'],
|
||||
['FOV fill', target.fov_fill_pct != null ? `${target.fov_fill_pct.toFixed(0)}%` : '—'],
|
||||
['Guide stars', target.guide_star_density ?? '—'],
|
||||
].map(([label, value]) => (
|
||||
<tr key={label}>
|
||||
<td style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 10, paddingBottom: 3, width: 80 }}>{label}</td>
|
||||
<td style={{ color: 'var(--text-hi)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>{value as string}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%', marginTop: 12 }}>
|
||||
<tbody>
|
||||
{[
|
||||
['Rise', fmtTime(visData?.rise_utc)],
|
||||
['Transit', fmtTime(visData?.transit_utc)],
|
||||
['Set', fmtTime(visData?.set_utc)],
|
||||
['Best window', visData?.best_start_utc && visData?.best_end_utc
|
||||
? `${fmtTime(visData.best_start_utc)} – ${fmtTime(visData.best_end_utc)}`
|
||||
: '—'],
|
||||
['Usable time', visData?.usable_min ? `${visData.usable_min} min` : '—'],
|
||||
['Meridian flip', fmtTime(visData?.meridian_flip_utc)],
|
||||
['Moon sep', visData?.moon_sep_deg != null ? `${visData.moon_sep_deg.toFixed(1)}°` : '—'],
|
||||
['Airmass @transit', visData?.airmass_at_transit?.toFixed(2) ?? '—'],
|
||||
['Extinction', visData?.extinction_mag != null ? `${visData.extinction_mag.toFixed(2)} mag` : '—'],
|
||||
].map(([label, value]) => (
|
||||
<tr key={label}>
|
||||
<td style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11, paddingBottom: 4, width: 140 }}>{label}</td>
|
||||
<td style={{ color: 'var(--text-hi)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>{value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 2: Target */}
|
||||
{tab === 1 && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '180px 1fr', gap: 16 }}>
|
||||
<div>
|
||||
<img
|
||||
src={dssUrl}
|
||||
alt={`DSS ${target.name}`}
|
||||
style={{ width: '100%', borderRadius: 3, background: '#000' }}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text-lo)', fontFamily: 'var(--font-mono)' }}>
|
||||
DSS Digitized Sky Survey
|
||||
{/* Right: altitude curve + key times */}
|
||||
<div>
|
||||
{curveData?.curve && curveData.curve.length > 0 ? (
|
||||
<AltitudeCurve
|
||||
curve={curveData.curve}
|
||||
dusk={tonight?.astro_dusk_utc ?? ''}
|
||||
dawn={tonight?.astro_dawn_utc ?? ''}
|
||||
trueDarkStart={tonight?.true_dark_start_utc}
|
||||
trueDarkEnd={tonight?.true_dark_end_utc}
|
||||
meridianFlip={visData?.meridian_flip_utc}
|
||||
horizonPoints={horizonData?.points}
|
||||
moonSepDeg={visData?.moon_sep_deg}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ color: 'var(--text-lo)', fontSize: 12, fontFamily: 'var(--font-mono)', marginBottom: 12 }}>
|
||||
Curve data loading…
|
||||
</div>
|
||||
)}
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%', marginTop: 12 }}>
|
||||
<tbody>
|
||||
{[
|
||||
['Rise', fmtTime(visData?.rise_utc)],
|
||||
['Transit', fmtTime(visData?.transit_utc)],
|
||||
['Set', fmtTime(visData?.set_utc)],
|
||||
['Best window', visData?.best_start_utc && visData?.best_end_utc
|
||||
? `${fmtTime(visData.best_start_utc)} – ${fmtTime(visData.best_end_utc)}`
|
||||
: '—'],
|
||||
['Usable time', visData?.usable_min ? `${visData.usable_min} min` : '—'],
|
||||
['Meridian flip', fmtTime(visData?.meridian_flip_utc)],
|
||||
['Moon sep', visData?.moon_sep_deg != null ? `${visData.moon_sep_deg.toFixed(1)}°` : '—'],
|
||||
['Airmass @transit', visData?.airmass_at_transit?.toFixed(2) ?? '—'],
|
||||
['Extinction', visData?.extinction_mag != null ? `${visData.extinction_mag.toFixed(2)} mag` : '—'],
|
||||
].map(([label, value]) => (
|
||||
<tr key={label}>
|
||||
<td style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11, paddingBottom: 4, width: 130 }}>{label}</td>
|
||||
<td style={{ color: 'var(--text-hi)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>{value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%', marginBottom: 16 }}>
|
||||
<tbody>
|
||||
{[
|
||||
['Type', target.obj_type],
|
||||
['Constellation', target.constellation ?? '—'],
|
||||
['RA', target.ra_h],
|
||||
['Dec', target.dec_dms],
|
||||
['Size', target.size_arcmin_maj ? `${target.size_arcmin_maj.toFixed(1)}′ × ${(target.size_arcmin_min ?? target.size_arcmin_maj).toFixed(1)}′` : '—'],
|
||||
['Magnitude', target.mag_v?.toFixed(1) ?? '—'],
|
||||
['Surface brightness', target.surface_brightness ? `${target.surface_brightness.toFixed(1)} mag/arcsec²` : '—'],
|
||||
['Hubble type', target.hubble_type ?? '—'],
|
||||
['FOV fill', target.fov_fill_pct != null ? `${target.fov_fill_pct.toFixed(0)}%` : '—'],
|
||||
['Guide stars', target.guide_star_density ?? '—'],
|
||||
].map(([label, value]) => (
|
||||
<tr key={label}>
|
||||
<td style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11, paddingBottom: 4, width: 140 }}>{label}</td>
|
||||
<td style={{ color: 'var(--text-hi)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>{value as string}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* Guiding context badge */}
|
||||
{target.guide_star_density && (() => {
|
||||
const density = target.guide_star_density;
|
||||
const msgs: Record<string, { color: string; text: string; note: string }> = {
|
||||
sparse: { color: 'var(--warn)', text: 'Sparse guide field', note: 'OAG may struggle — consider a bright guide star or off-axis offset' },
|
||||
moderate: { color: 'var(--blue)', text: 'Moderate guide field', note: 'OAG should work with careful star selection' },
|
||||
rich: { color: 'var(--good)', text: 'Rich guide field', note: 'Plenty of guide stars for OAG or guidescope' },
|
||||
};
|
||||
const m = msgs[density];
|
||||
if (!m) return null;
|
||||
|
||||
{/* Below the fold: GoTo card + guiding badge + links + Aladin */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
|
||||
{/* GoTo mount coordinates */}
|
||||
{(() => {
|
||||
const ha = currentHourAngle(target.ra_deg);
|
||||
const side = ha < 0 ? 'East (pre-meridian)' : 'West (post-meridian)';
|
||||
const sideColor = ha < 0 ? 'var(--teal)' : 'var(--amber)';
|
||||
return (
|
||||
<div style={{
|
||||
background: 'var(--bg-deep)',
|
||||
border: `1px solid ${m.color}`,
|
||||
borderRadius: 4,
|
||||
padding: '6px 10px',
|
||||
marginBottom: 12,
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'flex-start',
|
||||
background: 'var(--bg-deep)', border: '1px solid var(--border)',
|
||||
borderRadius: 4, padding: '8px 12px',
|
||||
}}>
|
||||
<span style={{ color: m.color, fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap' }}>
|
||||
◉ {m.text}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-mid)', fontSize: 11, fontStyle: 'italic' }}>{m.note}</span>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 6 }}>
|
||||
GoTo Coordinates (J2000)
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)', width: 30 }}>RA</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-hi)' }}>{target.ra_h}</span>
|
||||
<CopyButton text={target.ra_h} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)', width: 30 }}>Dec</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-hi)' }}>{target.dec_dms}</span>
|
||||
<CopyButton text={target.dec_dms} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginTop: 2 }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)', width: 30 }}>HA</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-hi)' }}>{fmtHa(ha)}</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: sideColor, marginLeft: 8 }}>
|
||||
{side}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<AladinEmbed
|
||||
ra={target.ra_deg}
|
||||
dec={target.dec_deg}
|
||||
mosaic={target.mosaic_flag ? { panels_w: target.mosaic_panels_w, panels_h: target.mosaic_panels_h } : undefined}
|
||||
/>
|
||||
|
||||
{/* Guiding context + external links */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{target.guide_star_density && (() => {
|
||||
const density = target.guide_star_density;
|
||||
const msgs: Record<string, { color: string; text: string; note: string }> = {
|
||||
sparse: { color: 'var(--warn)', text: 'Sparse guide field', note: 'OAG may struggle' },
|
||||
moderate: { color: 'var(--blue)', text: 'Moderate guide field', note: 'OAG with careful star selection' },
|
||||
rich: { color: 'var(--good)', text: 'Rich guide field', note: 'Plenty of guide stars' },
|
||||
};
|
||||
const m = msgs[density];
|
||||
if (!m) return null;
|
||||
return (
|
||||
<div style={{
|
||||
background: 'var(--bg-deep)', border: `1px solid ${m.color}`,
|
||||
borderRadius: 4, padding: '6px 10px', display: 'flex', gap: 8, alignItems: 'flex-start',
|
||||
}}>
|
||||
<span style={{ color: m.color, fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap' }}>◉ {m.text}</span>
|
||||
<span style={{ color: 'var(--text-mid)', fontSize: 11, fontStyle: 'italic' }}>{m.note}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<a
|
||||
href={`https://www.astrobin.com/search/?q=${encodeURIComponent(target.common_name ?? target.name)}`}
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--blue)', background: 'var(--bg-deep)', border: '1px solid var(--blue-dim)', borderRadius: 3, padding: '4px 10px', textDecoration: 'none' }}
|
||||
>
|
||||
Astrobin ↗
|
||||
</a>
|
||||
<a
|
||||
href={`https://www.astrobin.com/search/?q=${encodeURIComponent(target.name)}`}
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)', background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 3, padding: '4px 10px', textDecoration: 'none' }}
|
||||
>
|
||||
Astrobin {target.name} ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Similar targets nearby */}
|
||||
{(similarData?.similar?.length ?? 0) > 0 && (
|
||||
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', marginBottom: 16 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 8 }}>
|
||||
Similar Targets Nearby (same type · same constellation)
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
{similarData!.similar.slice(0, 3).map(s => (
|
||||
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{s.messier_num != null && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)', fontWeight: 700, width: 28 }}>
|
||||
M{s.messier_num}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-hi)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{s.common_name ?? s.name}
|
||||
{s.common_name && <span style={{ color: 'var(--text-lo)', marginLeft: 5, fontSize: 10 }}>{s.name}</span>}
|
||||
</span>
|
||||
{s.size_arcmin_maj != null && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>{s.size_arcmin_maj.toFixed(1)}′</span>
|
||||
)}
|
||||
{s.max_alt_deg != null && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: (s.max_alt_deg ?? 0) >= 30 ? 'var(--good)' : 'var(--warn)' }}>{s.max_alt_deg.toFixed(0)}°</span>
|
||||
)}
|
||||
{s.transit_utc && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
|
||||
{new Date(s.transit_utc).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AladinEmbed
|
||||
ra={target.ra_deg}
|
||||
dec={target.dec_deg}
|
||||
mosaic={target.mosaic_flag ? { panels_w: target.mosaic_panels_w, panels_h: target.mosaic_panels_h } : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 3: Filters & Workflow */}
|
||||
{tab === 2 && (
|
||||
{/* Tab 2: Filters & Workflow */}
|
||||
{tab === 1 && (
|
||||
<div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: 20 }}>
|
||||
<thead>
|
||||
@@ -296,11 +523,15 @@ export default function DetailDrawer({ target }: Props) {
|
||||
</table>
|
||||
|
||||
{workflowData && <WorkflowCard workflow={workflowData} />}
|
||||
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<ImagingCalculator target={target} filterId={selectedFilter} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 5: Yearly */}
|
||||
{tab === 4 && (
|
||||
{/* Tab 4: Yearly */}
|
||||
{tab === 3 && (
|
||||
<div>
|
||||
{yearlyData?.points ? (
|
||||
<YearlyVisibility points={yearlyData.points} />
|
||||
@@ -312,8 +543,8 @@ export default function DetailDrawer({ target }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 4: Log & Gallery */}
|
||||
{tab === 3 && (
|
||||
{/* Tab 3: Log & Gallery */}
|
||||
{tab === 2 && (
|
||||
<div>
|
||||
{/* Filter breakdown + planning notes row */}
|
||||
{((logData?.filter_breakdown && logData.filter_breakdown.length > 0) || true) && (
|
||||
|
||||
@@ -10,6 +10,8 @@ interface Props {
|
||||
target: Target;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
inCompare?: boolean;
|
||||
onCompare?: (t: Target) => void;
|
||||
}
|
||||
|
||||
// Display labels for filter IDs
|
||||
@@ -84,7 +86,7 @@ function difficultyDots(d?: number) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function TargetRow({ target, expanded, onToggle }: Props) {
|
||||
export default function TargetRow({ target, expanded, onToggle, inCompare, onCompare }: Props) {
|
||||
const { data: tonight } = useTonight();
|
||||
const { data: horizonData } = useHorizon();
|
||||
const alt = fmtAlt(target.max_alt_deg);
|
||||
@@ -131,15 +133,42 @@ export default function TargetRow({ target, expanded, onToggle }: Props) {
|
||||
M{target.messier_num}
|
||||
</span>
|
||||
)}
|
||||
{target.caldwell_num != null && target.messier_num == null && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--blue)', fontWeight: 700 }}>
|
||||
C{target.caldwell_num}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: target.messier_num != null ? 'var(--text-mid)' : 'var(--text-hi)' }}>
|
||||
{target.name}
|
||||
</span>
|
||||
{target.is_custom && (
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 9, fontWeight: 700,
|
||||
background: 'rgba(42,184,160,0.15)', border: '1px solid var(--teal)',
|
||||
color: 'var(--teal)', padding: '1px 5px', borderRadius: 3, letterSpacing: '0.06em',
|
||||
}}>
|
||||
CUSTOM
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{target.common_name && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-mid)' }}>
|
||||
{target.common_name}
|
||||
</div>
|
||||
)}
|
||||
{target.urgency && (
|
||||
<div style={{ marginTop: 2 }}>
|
||||
{target.urgency === 'peak' && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--good)', letterSpacing: '0.04em' }}>▲ peak</span>
|
||||
)}
|
||||
{target.urgency === 'rising' && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--teal)', letterSpacing: '0.04em' }}>↗ rising</span>
|
||||
)}
|
||||
{target.urgency === 'declining' && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--warn)', letterSpacing: '0.04em' }}>↘ declining</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '7px 8px', width: 80, fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)' }}>
|
||||
{target.size_arcmin_maj
|
||||
@@ -212,6 +241,23 @@ export default function TargetRow({ target, expanded, onToggle }: Props) {
|
||||
total_min={target.total_integration_min}
|
||||
/>
|
||||
</td>
|
||||
{onCompare && (
|
||||
<td style={{ padding: '7px 8px', width: 28 }}>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onCompare(target); }}
|
||||
title={inCompare ? 'Remove from compare' : 'Add to compare'}
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 11,
|
||||
color: inCompare ? 'var(--amber)' : 'var(--text-lo)',
|
||||
background: inCompare ? 'var(--amber-glow)' : 'none',
|
||||
border: `1px solid ${inCompare ? 'var(--amber-dim)' : 'var(--border)'}`,
|
||||
borderRadius: 3, padding: '1px 4px', cursor: 'pointer', lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
⊕
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,11 @@ const LABELS: Record<string, string> = {
|
||||
dark_nebula: 'DN',
|
||||
nebula: 'NB',
|
||||
galaxy_group: 'GG',
|
||||
galaxy_cluster: 'ACO',
|
||||
interacting_galaxy: 'IG',
|
||||
custom: 'USR',
|
||||
satellite: 'SAT',
|
||||
comet: 'CMT',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface Props {
|
||||
level?: 'warning' | 'critical';
|
||||
temp?: number;
|
||||
@@ -5,6 +7,31 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function DewAlert({ level, temp, dewPoint }: Props) {
|
||||
const notifiedRef = useRef<string | null>(null);
|
||||
|
||||
// Trigger browser notification when dew alert level appears or escalates
|
||||
useEffect(() => {
|
||||
if (!level) return;
|
||||
const key = level;
|
||||
if (notifiedRef.current === key) return;
|
||||
notifiedRef.current = key;
|
||||
|
||||
if (!('Notification' in window)) return;
|
||||
const send = () => {
|
||||
const margin = temp != null && dewPoint != null ? `Margin: ${(temp - dewPoint).toFixed(1)}°C. ` : '';
|
||||
const body = level === 'critical'
|
||||
? `${margin}Condensation imminent — protect optics immediately.`
|
||||
: `${margin}Enable dew heaters now.`;
|
||||
new Notification('Astronome — Dew Alert', { body, tag: 'dew-alert' });
|
||||
};
|
||||
|
||||
if (Notification.permission === 'granted') {
|
||||
send();
|
||||
} else if (Notification.permission !== 'denied') {
|
||||
void Notification.requestPermission().then(p => { if (p === 'granted') send(); });
|
||||
}
|
||||
}, [level, temp, dewPoint]);
|
||||
|
||||
if (!level) return null;
|
||||
|
||||
const margin = temp != null && dewPoint != null ? (temp - dewPoint).toFixed(1) : null;
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../api';
|
||||
|
||||
export function useBestNights() {
|
||||
return useQuery({
|
||||
queryKey: ['best-nights'],
|
||||
queryFn: () => api.calendar.getBestNights(),
|
||||
staleTime: 60 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMonthlyHighlights() {
|
||||
return useQuery({
|
||||
queryKey: ['monthly-highlights'],
|
||||
queryFn: () => api.calendar.getMonthlyHighlights(),
|
||||
staleTime: 60 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCalendar(months?: number) {
|
||||
return useQuery({
|
||||
queryKey: ['calendar', months],
|
||||
|
||||
@@ -62,3 +62,12 @@ export function useTargetYearly(id: string, enabled = false) {
|
||||
staleTime: 60 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTargetSimilar(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['target-similar', id],
|
||||
queryFn: () => api.targets.similar(id),
|
||||
enabled: !!id,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,17 +1,73 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTonight } from '../hooks/useTonight';
|
||||
import { useWeather, useForecast } from '../hooks/useWeather';
|
||||
import { useTargets } from '../hooks/useTargets';
|
||||
import { useStats } from '../hooks/useStats';
|
||||
import { useBestNights, useMonthlyHighlights } from '../hooks/useCalendar';
|
||||
import GoNogo from '../components/weather/GoNogo';
|
||||
import DewAlert from '../components/weather/DewAlert';
|
||||
import MoonPhaseIcon from '../components/sky/MoonPhaseIcon';
|
||||
import DetailDrawer from '../components/targets/DetailDrawer';
|
||||
import type { Target } from '../api/types';
|
||||
import SessionChecklist from '../components/session/SessionChecklist';
|
||||
import PlanningTimeline from '../components/session/PlanningTimeline';
|
||||
import type { IntegrationGap, Target } from '../api/types';
|
||||
|
||||
const FILTER_LABELS: Record<string, string> = {
|
||||
sv220: 'HaOIII', c2: 'SII/OIII', sv260: 'LP', uvir: 'UV/IR',
|
||||
sv220: 'Ha+OIII', c2: 'SII+OIII', sv260: 'LP', uvir: 'UV/IR',
|
||||
};
|
||||
|
||||
function fmtMin(min: number): string {
|
||||
if (min < 60) return `${min}m`;
|
||||
return `${(min / 60).toFixed(1)}h`;
|
||||
}
|
||||
|
||||
function GapCard({ gap }: { gap: IntegrationGap }) {
|
||||
const have: { label: string; min: number }[] = [];
|
||||
const missing: string[] = gap.missing_filters;
|
||||
|
||||
if (gap.sv220_min > 0) have.push({ label: 'Ha+OIII', min: gap.sv220_min });
|
||||
if (gap.c2_min > 0) have.push({ label: 'SII+OIII', min: gap.c2_min });
|
||||
if (gap.uvir_min > 0) have.push({ label: 'UV/IR', min: gap.uvir_min });
|
||||
if (gap.sv260_min > 0) have.push({ label: 'LP', min: gap.sv260_min });
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '7px 12px', borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--amber)' }}>
|
||||
{gap.common_name ?? gap.name}
|
||||
</span>
|
||||
{gap.common_name && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', marginLeft: 6 }}>
|
||||
{gap.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
{have.map(h => (
|
||||
<span key={h.label} style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10,
|
||||
color: 'var(--good)', background: 'rgba(61,186,114,0.1)',
|
||||
padding: '2px 6px', borderRadius: 3,
|
||||
}}>
|
||||
{h.label} {fmtMin(h.min)}
|
||||
</span>
|
||||
))}
|
||||
{missing.map(f => (
|
||||
<span key={f} style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10,
|
||||
color: 'var(--warn)', background: 'rgba(232,192,48,0.1)',
|
||||
border: '1px dashed var(--warn)', padding: '2px 6px', borderRadius: 3,
|
||||
}}>
|
||||
{FILTER_LABELS[f] ?? f} 0h
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const CC_LABELS: Record<number, string> = {
|
||||
1: 'Clear', 2: 'Clear', 3: 'Mostly clear', 4: 'Partly cloudy',
|
||||
5: 'Partly cloudy', 6: 'Cloudy', 7: 'Mostly cloudy', 8: 'Overcast', 9: 'Overcast',
|
||||
@@ -34,18 +90,332 @@ function fmtIntTotal(min: number): string {
|
||||
return `${h} h`;
|
||||
}
|
||||
|
||||
const FILTER_COLORS: Record<string, string> = {
|
||||
sv220: 'var(--good)', c2: 'var(--teal)', sv260: 'var(--blue)', uvir: 'var(--amber)',
|
||||
};
|
||||
|
||||
const TYPE_ABBR: Record<string, string> = {
|
||||
galaxy: 'GX', emission_nebula: 'EN', reflection_nebula: 'RN',
|
||||
planetary_nebula: 'PN', snr: 'SNR', open_cluster: 'OC',
|
||||
globular_cluster: 'GC', dark_nebula: 'DN',
|
||||
};
|
||||
|
||||
function moonBar(illum: number): string {
|
||||
if (illum < 0.2) return 'var(--good)';
|
||||
if (illum < 0.5) return 'var(--warn)';
|
||||
return 'var(--danger)';
|
||||
}
|
||||
|
||||
function BestNightsCard() {
|
||||
const { data } = useBestNights();
|
||||
const nights = data?.nights ?? [];
|
||||
if (!nights.length) return null;
|
||||
// Show top 14 nights in date order
|
||||
const sorted = [...nights].sort((a, b) => a.date.localeCompare(b.date));
|
||||
return (
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
|
||||
<div style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase' }}>
|
||||
14-Night Forecast
|
||||
</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
|
||||
Best score: {Math.max(...nights.map(n => n.score))}
|
||||
</span>
|
||||
</div>
|
||||
{sorted.map(night => {
|
||||
const maxScore = Math.max(...nights.map(n => n.score));
|
||||
const barWidth = maxScore > 0 ? (night.score / maxScore) * 100 : 0;
|
||||
const barColor = night.score >= 70 ? 'var(--good)' : night.score >= 40 ? 'var(--warn)' : 'var(--danger)';
|
||||
const dateObj = new Date(night.date + 'T12:00:00');
|
||||
const dayLabel = dateObj.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' });
|
||||
return (
|
||||
<div key={night.date} style={{ display: 'grid', gridTemplateColumns: '120px 40px 1fr 80px', gap: 8, padding: '6px 14px', borderBottom: '1px solid var(--border)', alignItems: 'center' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)' }}>{dayLabel}</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600, color: barColor, textAlign: 'right' }}>{night.score}</span>
|
||||
<div style={{ position: 'relative', height: 8, background: 'var(--bg-deep)', borderRadius: 2 }}>
|
||||
<div style={{ position: 'absolute', left: 0, width: `${barWidth}%`, height: '100%', background: barColor, borderRadius: 2, opacity: 0.7 }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center', justifyContent: 'flex-end' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: moonBar(night.moon_illumination) }}>
|
||||
☽ {Math.round(night.moon_illumination * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MonthlyHighlightsCard() {
|
||||
const { data } = useMonthlyHighlights();
|
||||
const highlights = data?.highlights ?? [];
|
||||
const month = data?.month;
|
||||
if (!highlights.length) return null;
|
||||
const monthLabel = month ? new Date(month + '-01').toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }) : '';
|
||||
return (
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
|
||||
<div style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase' }}>
|
||||
Best of {monthLabel}
|
||||
</span>
|
||||
</div>
|
||||
{highlights.slice(0, 5).map(h => (
|
||||
<div key={h.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 14px', borderBottom: '1px solid var(--border)' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', width: 32, flexShrink: 0 }}>
|
||||
{TYPE_ABBR[h.obj_type] ?? h.obj_type.slice(0, 3).toUpperCase()}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: h.keeper_count > 0 ? 'var(--text-mid)' : 'var(--amber)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{h.common_name ?? h.name}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
|
||||
{h.name}{h.constellation ? ` · ${h.constellation}` : ''}
|
||||
{h.keeper_count > 0 && <span style={{ color: 'var(--good)', marginLeft: 6 }}>✓ imaged</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--good)', fontWeight: 600 }}>
|
||||
{h.peak_alt?.toFixed(0)}°
|
||||
</div>
|
||||
{h.recommended_filter && (
|
||||
<span className={`filter-pill ${h.recommended_filter}`} style={{ fontSize: 9 }}>
|
||||
{FILTER_LABELS[h.recommended_filter] ?? h.recommended_filter.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const GOAL_HOURS: Record<string, Record<string, number>> = {
|
||||
galaxy: { uvir: 4.0, sv260: 6.0 },
|
||||
emission_nebula: { sv220: 3.0, c2: 4.0, sv260: 8.0, uvir: 12.0 },
|
||||
reflection_nebula: { uvir: 3.0, sv260: 5.0 },
|
||||
planetary_nebula: { sv220: 2.0, c2: 3.0 },
|
||||
snr: { sv220: 5.0, c2: 6.0 },
|
||||
open_cluster: { uvir: 1.0 },
|
||||
globular_cluster: { uvir: 1.5 },
|
||||
dark_nebula: { uvir: 3.0 },
|
||||
};
|
||||
|
||||
const FILTER_COLORS_DB: Record<string, string> = {
|
||||
sv220: 'var(--good)', c2: 'var(--teal)', sv260: 'var(--blue)', uvir: 'var(--amber)',
|
||||
};
|
||||
|
||||
function IntegrationGoalsCard({ goals }: { goals: import('../api/types').IntegrationGoal[] }) {
|
||||
if (!goals?.length) return (
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)', padding: '12px 0' }}>
|
||||
No keeper integration recorded yet.
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{goals.map(g => {
|
||||
const byType = GOAL_HOURS[g.obj_type] ?? {};
|
||||
const filterKeys = Object.keys(byType);
|
||||
const totalKeeperMin = g.sv220_min + g.c2_min + g.uvir_min + g.sv260_min;
|
||||
const filterMinMap: Record<string, number> = { sv220: g.sv220_min, c2: g.c2_min, uvir: g.uvir_min, sv260: g.sv260_min };
|
||||
|
||||
return (
|
||||
<div key={g.id} style={{ borderBottom: '1px solid var(--border)', paddingBottom: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--amber)' }}>
|
||||
{g.common_name ?? g.name}
|
||||
{g.common_name && <span style={{ fontSize: 10, color: 'var(--text-lo)', marginLeft: 6 }}>{g.name}</span>}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
|
||||
{Math.floor(totalKeeperMin / 60)}h {totalKeeperMin % 60}m total
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{filterKeys.filter(fk => filterMinMap[fk] > 0 || (byType[fk] ?? 0) > 0).map(fk => {
|
||||
const goalMin = (byType[fk] ?? 0) * 60;
|
||||
const doneMin = filterMinMap[fk] ?? 0;
|
||||
const pct = goalMin > 0 ? Math.min((doneMin / goalMin) * 100, 100) : 0;
|
||||
const color = pct >= 100 ? 'var(--good)' : pct >= 60 ? 'var(--warn)' : 'var(--danger)';
|
||||
const filterColor = FILTER_COLORS_DB[fk] ?? 'var(--muted)';
|
||||
return (
|
||||
<div key={fk} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: filterColor, width: 50 }}>
|
||||
{FILTER_LABELS[fk] ?? fk}
|
||||
</span>
|
||||
<div style={{ flex: 1, height: 5, background: 'var(--bg-deep)', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', width: `${pct}%`, background: doneMin > 0 ? color : 'transparent', borderRadius: 3, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: doneMin > 0 ? color : 'var(--text-lo)', width: 60, textAlign: 'right' }}>
|
||||
{doneMin > 0 ? `${Math.floor(doneMin / 60)}h${doneMin % 60}m` : '—'}
|
||||
{goalMin > 0 && ` / ${byType[fk]}h`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Angular distance between two RA/Dec positions (degrees). */
|
||||
function angularDist(ra1: number, dec1: number, ra2: number, dec2: number): number {
|
||||
const toRad = (d: number) => d * Math.PI / 180;
|
||||
const dRa = toRad(ra2 - ra1);
|
||||
const dDec = toRad(dec2 - dec1);
|
||||
const a = Math.sin(dDec / 2) ** 2 + Math.cos(toRad(dec1)) * Math.cos(toRad(dec2)) * Math.sin(dRa / 2) ** 2;
|
||||
return 2 * Math.asin(Math.sqrt(a)) * 180 / Math.PI;
|
||||
}
|
||||
|
||||
/** Nearest-neighbor RA/Dec sort starting from the first target (sorted by best_start). */
|
||||
function slewOptimize(targets: import('../api/types').Target[]): import('../api/types').Target[] {
|
||||
if (targets.length <= 2) return targets;
|
||||
const remaining = [...targets];
|
||||
const result: import('../api/types').Target[] = [remaining.shift()!];
|
||||
while (remaining.length) {
|
||||
const last = result[result.length - 1];
|
||||
let nearestIdx = 0;
|
||||
let nearestDist = Infinity;
|
||||
remaining.forEach((t, i) => {
|
||||
const d = angularDist(last.ra_deg, last.dec_deg, t.ra_deg, t.dec_deg);
|
||||
if (d < nearestDist) { nearestDist = d; nearestIdx = i; }
|
||||
});
|
||||
result.push(remaining.splice(nearestIdx, 1)[0]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function RunOrderCard({ items, dusk, dawn }: {
|
||||
items: import('../api/types').Target[];
|
||||
dusk: string;
|
||||
dawn: string;
|
||||
}) {
|
||||
const [slewMode, setSlewMode] = useState(false);
|
||||
const duskMs = new Date(dusk).getTime();
|
||||
const dawnMs = new Date(dawn).getTime();
|
||||
const nightMs = dawnMs - duskMs;
|
||||
|
||||
const withWindow = useMemo(() =>
|
||||
items.filter(t => t.best_start_utc && t.best_end_utc),
|
||||
[items]);
|
||||
|
||||
const displayItems = useMemo(() => {
|
||||
if (!slewMode || withWindow.length <= 2) return withWindow;
|
||||
return slewOptimize(withWindow);
|
||||
}, [withWindow, slewMode]);
|
||||
|
||||
if (!withWindow.length) return (
|
||||
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>
|
||||
No targets with defined imaging windows tonight.
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{/* Slew optimizer toggle */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
|
||||
<button
|
||||
onClick={() => setSlewMode(v => !v)}
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10,
|
||||
color: slewMode ? 'var(--amber)' : 'var(--text-lo)',
|
||||
background: slewMode ? 'var(--amber-glow)' : 'var(--bg-deep)',
|
||||
border: `1px solid ${slewMode ? 'var(--amber-dim)' : 'var(--border)'}`,
|
||||
borderRadius: 3, padding: '3px 10px', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
⟳ Slew-optimized order
|
||||
</button>
|
||||
{slewMode && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
|
||||
Nearest-neighbor sort minimizes mount slew distance
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Timeline header ticks */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '160px 1fr 80px', gap: 8, marginBottom: 2 }}>
|
||||
<div />
|
||||
<div style={{ position: 'relative', height: 14 }}>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map(frac => {
|
||||
const t = new Date(duskMs + frac * nightMs);
|
||||
return (
|
||||
<span key={frac} style={{
|
||||
position: 'absolute',
|
||||
left: `${frac * 100}%`,
|
||||
transform: 'translateX(-50%)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 9,
|
||||
color: 'var(--text-lo)',
|
||||
}}>
|
||||
{t.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' })}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
{displayItems.map(t => {
|
||||
const startMs = new Date(t.best_start_utc!).getTime();
|
||||
const endMs = new Date(t.best_end_utc!).getTime();
|
||||
const left = Math.max(0, (startMs - duskMs) / nightMs) * 100;
|
||||
const width = Math.min(100 - left, Math.max(2, (endMs - startMs) / nightMs * 100));
|
||||
const filterColor = t.recommended_filter ? (FILTER_COLORS[t.recommended_filter] ?? 'var(--muted)') : 'var(--muted)';
|
||||
return (
|
||||
<div key={t.id} style={{ display: 'grid', gridTemplateColumns: '160px 1fr 80px', gap: 8, alignItems: 'center' }}>
|
||||
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--amber)' }}>
|
||||
{t.common_name ?? t.name}
|
||||
</span>
|
||||
{t.common_name && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', marginLeft: 5 }}>
|
||||
{t.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ position: 'relative', height: 14, background: 'var(--bg-deep)', borderRadius: 2 }}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: `${left}%`,
|
||||
width: `${width}%`,
|
||||
height: '100%',
|
||||
background: filterColor,
|
||||
borderRadius: 2,
|
||||
opacity: 0.75,
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
{new Date(t.best_start_utc!).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' })}
|
||||
{' → '}
|
||||
{new Date(t.best_end_utc!).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { data: tonight } = useTonight();
|
||||
const { data: weather } = useWeather();
|
||||
const { data: forecast } = useForecast();
|
||||
const { data: targets } = useTargets({ tonight: true, limit: 5 });
|
||||
const { data: runOrder } = useTargets({ tonight: true, sort: 'best_start', limit: 20 });
|
||||
const { data: stats } = useStats();
|
||||
// best nights + monthly highlights loaded inside their own components
|
||||
const [expandedTarget, setExpandedTarget] = useState<Target | null>(null);
|
||||
const [planningOpen, setPlanningOpen] = useState(false);
|
||||
|
||||
const moonPct = tonight?.moon_illumination != null
|
||||
? `${Math.round(tonight.moon_illumination * 100)}%`
|
||||
: '—';
|
||||
|
||||
// Moon separation warning: flag if any top-5 tonight target has moon_sep < 20°
|
||||
const closeMoonTarget = targets?.items?.find(t => (t.moon_sep_deg ?? 180) < 20);
|
||||
|
||||
// Next 4 forecast slots for mini weather bar (3h each = 12h ahead)
|
||||
const slots = (forecast as { dataseries?: { cloudcover?: number; seeing?: number; timepoint?: number }[] })?.dataseries?.slice(0, 8) ?? [];
|
||||
|
||||
@@ -62,6 +432,31 @@ export default function Dashboard() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Moon separation warning */}
|
||||
{closeMoonTarget && (
|
||||
<div style={{
|
||||
marginBottom: 12,
|
||||
background: 'rgba(224,82,82,0.12)',
|
||||
border: '1px solid var(--danger)',
|
||||
borderRadius: 4,
|
||||
padding: '8px 14px',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 12,
|
||||
color: 'var(--danger)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
}}>
|
||||
<span style={{ fontSize: 16 }}>☽</span>
|
||||
<span>
|
||||
<strong>{closeMoonTarget.common_name ?? closeMoonTarget.name}</strong>
|
||||
{' '}is only{' '}
|
||||
<strong>{closeMoonTarget.moon_sep_deg?.toFixed(1)}°</strong>
|
||||
{' '}from the Moon — consider a narrowband filter or delaying this target.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dew alert banner */}
|
||||
{weather?.dew_alert && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
@@ -119,6 +514,20 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Integration gap detector */}
|
||||
{(stats?.integration_gaps?.length ?? 0) > 0 && (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 10 }}>
|
||||
Filter Gaps — targets missing a companion filter
|
||||
</div>
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
|
||||
{stats!.integration_gaps.map(gap => (
|
||||
<GapCard key={gap.catalog_id} gap={gap} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tonight timing + top targets + forecast */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr 1fr', gap: 16, marginBottom: 20 }}>
|
||||
|
||||
@@ -230,6 +639,89 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pre-session checklist */}
|
||||
{tonight?.date && (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<SessionChecklist duskDate={tonight.date} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monthly highlights + best nights */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 20 }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 10 }}>
|
||||
Monthly Highlights
|
||||
</div>
|
||||
<MonthlyHighlightsCard />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 10 }}>
|
||||
Best Nights (14-day)
|
||||
</div>
|
||||
<BestNightsCard />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Integration Goals Progress */}
|
||||
{stats?.integration_goals?.length ? (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 10 }}>
|
||||
Integration Goals — keeper progress per target & filter
|
||||
</div>
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||
<IntegrationGoalsCard goals={stats.integration_goals} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Tonight run order */}
|
||||
{tonight?.astro_dusk_utc && (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 10 }}>
|
||||
Tonight's Run Order — imaging windows sorted by start time
|
||||
</div>
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||
<RunOrderCard
|
||||
items={runOrder?.items ?? []}
|
||||
dusk={tonight.astro_dusk_utc}
|
||||
dawn={tonight.astro_dawn_utc}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session Planning Timeline */}
|
||||
{tonight?.astro_dusk_utc && (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase' }}>
|
||||
Plan Tonight
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPlanningOpen(o => !o)}
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10,
|
||||
color: planningOpen ? 'var(--amber)' : 'var(--text-lo)',
|
||||
background: planningOpen ? 'var(--amber-glow)' : 'var(--bg-deep)',
|
||||
border: `1px solid ${planningOpen ? 'var(--amber-dim)' : 'var(--border)'}`,
|
||||
borderRadius: 3, padding: '2px 10px', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{planningOpen ? '▲ Hide' : '▼ Build Plan'}
|
||||
</button>
|
||||
</div>
|
||||
{planningOpen && (
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||
<PlanningTimeline
|
||||
targets={runOrder?.items ?? []}
|
||||
dusk={tonight.astro_dusk_utc}
|
||||
dawn={tonight.astro_dawn_utc}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,189 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../api';
|
||||
import type { HorizonPoint } from '../api/types';
|
||||
|
||||
// Current hardcoded setup constants (from config.rs)
|
||||
const CURRENT_SETUP = {
|
||||
name: 'AT71 + ATR2600C (current)',
|
||||
focal_mm: 490,
|
||||
aperture_mm: 71,
|
||||
pixel_um: 3.76,
|
||||
res_x: 6248,
|
||||
res_y: 4176,
|
||||
active: true,
|
||||
};
|
||||
|
||||
interface EquipProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
focal_mm: number;
|
||||
aperture_mm: number;
|
||||
pixel_um: number;
|
||||
res_x: number;
|
||||
res_y: number;
|
||||
}
|
||||
|
||||
function calcProfile(p: { focal_mm: number; aperture_mm: number; pixel_um: number; res_x: number; res_y: number }) {
|
||||
const plate_scale = (206.265 * p.pixel_um) / (p.focal_mm * 1000 / 1000);
|
||||
// plate_scale in arcsec/px: (206265 * pixel_size_um / 1000) / focal_mm
|
||||
const ps = (206.265 * p.pixel_um / 1000) / p.focal_mm * 1000;
|
||||
const fov_w_deg = (ps * p.res_x) / 3600;
|
||||
const fov_h_deg = (ps * p.res_y) / 3600;
|
||||
const focal_ratio = p.focal_mm / p.aperture_mm;
|
||||
return {
|
||||
plate_scale_arcsec: ps.toFixed(3),
|
||||
fov_w: `${(fov_w_deg * 60).toFixed(1)}′ × ${(fov_h_deg * 60).toFixed(1)}′`,
|
||||
focal_ratio: `f/${focal_ratio.toFixed(1)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function EquipmentProfiles() {
|
||||
const [profiles, setProfiles] = useState<EquipProfile[]>(() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('astronome_equip_profiles') ?? '[]');
|
||||
} catch { return []; }
|
||||
});
|
||||
const [editing, setEditing] = useState<EquipProfile | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', focal_mm: 490, aperture_mm: 71, pixel_um: 3.76, res_x: 6248, res_y: 4176 });
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('astronome_equip_profiles', JSON.stringify(profiles));
|
||||
}, [profiles]);
|
||||
|
||||
const saveProfile = () => {
|
||||
if (!form.name.trim()) return;
|
||||
if (editing) {
|
||||
setProfiles(ps => ps.map(p => p.id === editing.id ? { ...form, id: editing.id } : p));
|
||||
} else {
|
||||
setProfiles(ps => [...ps, { ...form, id: Date.now().toString() }]);
|
||||
}
|
||||
setEditing(null);
|
||||
setAdding(false);
|
||||
setForm({ name: '', focal_mm: 490, aperture_mm: 71, pixel_um: 3.76, res_x: 6248, res_y: 4176 });
|
||||
};
|
||||
|
||||
const deleteProfile = (id: string) => {
|
||||
setProfiles(ps => ps.filter(p => p.id !== id));
|
||||
};
|
||||
|
||||
const startEdit = (p: EquipProfile) => {
|
||||
setEditing(p);
|
||||
setForm({ name: p.name, focal_mm: p.focal_mm, aperture_mm: p.aperture_mm, pixel_um: p.pixel_um, res_x: p.res_x, res_y: p.res_y });
|
||||
setAdding(false);
|
||||
};
|
||||
|
||||
const current = calcProfile(CURRENT_SETUP);
|
||||
const allProfiles = [{ ...CURRENT_SETUP, id: '__current__' }, ...profiles];
|
||||
|
||||
const fieldStyle: React.CSSProperties = {
|
||||
background: 'var(--bg-void)', border: '1px solid var(--border)', borderRadius: 3,
|
||||
color: 'var(--text-hi)', fontFamily: 'var(--font-mono)', fontSize: 12,
|
||||
padding: '4px 8px', width: '100%', boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
return (
|
||||
<section style={{ marginBottom: 32 }}>
|
||||
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 16, marginBottom: 14, color: 'var(--text-hi)' }}>
|
||||
Equipment Profiles
|
||||
</h2>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, maxWidth: 640 }}>
|
||||
{allProfiles.map(p => {
|
||||
const calc = calcProfile(p);
|
||||
const isCurrent = p.id === '__current__';
|
||||
return (
|
||||
<div key={p.id} style={{
|
||||
background: 'var(--bg-panel)', border: `1px solid ${isCurrent ? 'var(--amber-dim)' : 'var(--border)'}`,
|
||||
borderRadius: 6, padding: '12px 16px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: isCurrent ? 'var(--amber)' : 'var(--text-hi)', fontWeight: 600 }}>
|
||||
{p.name}
|
||||
{isCurrent && <span style={{ marginLeft: 8, fontSize: 10, color: 'var(--amber)', background: 'var(--amber-glow)', padding: '1px 6px', borderRadius: 3 }}>ACTIVE</span>}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)', marginTop: 4 }}>
|
||||
{p.focal_mm}mm {calc.focal_ratio} · {p.aperture_mm}mm aperture · {p.pixel_um}μm px · {p.res_x}×{p.res_y}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', marginTop: 3 }}>
|
||||
Plate scale: <strong style={{ color: 'var(--teal)' }}>{calc.plate_scale_arcsec}″/px</strong>
|
||||
{' · '}FOV: <strong style={{ color: 'var(--teal)' }}>{calc.fov_w}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{!isCurrent && (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={() => startEdit(p as EquipProfile)} style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--blue)', background: 'none', border: '1px solid var(--border)', borderRadius: 3, padding: '3px 8px', cursor: 'pointer' }}>
|
||||
Edit
|
||||
</button>
|
||||
<button onClick={() => deleteProfile(p.id)} style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--danger)', background: 'none', border: '1px solid var(--border)', borderRadius: 3, padding: '3px 8px', cursor: 'pointer' }}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{(adding || editing) && (
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border-hi)', borderRadius: 6, padding: '14px 16px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)', marginBottom: 10, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
{editing ? 'Edit Profile' : 'New Profile'}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10, marginBottom: 10 }}>
|
||||
{[
|
||||
{ key: 'name', label: 'Profile name', type: 'text', fullWidth: true },
|
||||
{ key: 'focal_mm', label: 'Focal length (mm)', type: 'number' },
|
||||
{ key: 'aperture_mm', label: 'Aperture (mm)', type: 'number' },
|
||||
{ key: 'pixel_um', label: 'Pixel size (μm)', type: 'number' },
|
||||
{ key: 'res_x', label: 'Sensor width (px)', type: 'number' },
|
||||
{ key: 'res_y', label: 'Sensor height (px)', type: 'number' },
|
||||
].map(field => (
|
||||
<div key={field.key} style={{ gridColumn: field.fullWidth ? '1 / -1' : undefined }}>
|
||||
<label style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', display: 'block', marginBottom: 3 }}>
|
||||
{field.label}
|
||||
</label>
|
||||
<input
|
||||
type={field.type}
|
||||
step={field.key === 'pixel_um' ? '0.01' : '1'}
|
||||
value={(form as Record<string, string | number>)[field.key]}
|
||||
onChange={e => setForm(f => ({ ...f, [field.key]: field.type === 'number' ? parseFloat(e.target.value) || 0 : e.target.value }))}
|
||||
style={fieldStyle}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{form.focal_mm > 0 && form.pixel_um > 0 && (
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--teal)', marginBottom: 10 }}>
|
||||
Preview: {calcProfile(form).plate_scale_arcsec}″/px · {calcProfile(form).fov_w} FOV
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={saveProfile} style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: '#fff', background: 'var(--amber)', border: 'none', borderRadius: 3, padding: '5px 14px', cursor: 'pointer' }}>
|
||||
Save
|
||||
</button>
|
||||
<button onClick={() => { setAdding(false); setEditing(null); }} style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-mid)', background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 3, padding: '5px 14px', cursor: 'pointer' }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!adding && !editing && (
|
||||
<button
|
||||
onClick={() => { setAdding(true); setEditing(null); }}
|
||||
style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--amber)', background: 'var(--bg-deep)', border: '1px dashed var(--amber-dim)', borderRadius: 4, padding: '8px 16px', cursor: 'pointer', textAlign: 'left' }}
|
||||
>
|
||||
+ Add equipment profile
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function HorizonPolarChart({ points }: { points: HorizonPoint[] }) {
|
||||
const size = 280;
|
||||
const cx = size / 2;
|
||||
@@ -165,6 +346,9 @@ export default function Settings() {
|
||||
<div>
|
||||
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 22, marginBottom: 24 }}>Settings</h1>
|
||||
|
||||
{/* Equipment Profiles */}
|
||||
<EquipmentProfiles />
|
||||
|
||||
{/* Custom Horizon */}
|
||||
<section style={{ marginBottom: 32 }}>
|
||||
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 16, marginBottom: 14, color: 'var(--text-hi)' }}>
|
||||
|
||||
@@ -5,8 +5,120 @@ import {
|
||||
import { useStats } from '../hooks/useStats';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../api';
|
||||
import type { Phd2Log } from '../api/types';
|
||||
import { useRef, useState } from 'react';
|
||||
import type { HistoryEntry, Phd2Log } from '../api/types';
|
||||
import { useRef, useState, useMemo } from 'react';
|
||||
|
||||
const FILTER_PILL_COLORS: Record<string, string> = {
|
||||
sv220: '#9b59b6', c2: '#4d9de0', sv260: '#e8832a', uvir: '#3dba72',
|
||||
};
|
||||
const FILTER_LABELS_HIST: Record<string, string> = {
|
||||
sv220: 'Ha+OIII', c2: 'SII+OIII', sv260: 'LP', uvir: 'UV/IR',
|
||||
};
|
||||
const QUALITY_COLORS: Record<string, string> = {
|
||||
keeper: 'var(--good)', needs_more: 'var(--blue)', rejected: 'var(--danger)', pending: 'var(--muted)',
|
||||
};
|
||||
|
||||
function SessionTimeline({ entries }: { entries: HistoryEntry[] }) {
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, HistoryEntry[]>();
|
||||
for (const e of entries) {
|
||||
const existing = map.get(e.date) ?? [];
|
||||
existing.push(e);
|
||||
map.set(e.date, existing);
|
||||
}
|
||||
return Array.from(map.entries()).sort((a, b) => b[0].localeCompare(a[0]));
|
||||
}, [entries]);
|
||||
|
||||
if (!grouped.length) return (
|
||||
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||
No sessions logged yet.
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{grouped.map(([date, items]) => {
|
||||
const totalMin = items.reduce((s, i) => s + i.integration_min, 0);
|
||||
const hasKeeper = items.some(i => i.quality === 'keeper');
|
||||
const thumbEntry = items.find(i => i.gallery_url);
|
||||
return (
|
||||
<div key={date} style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 12 }}>
|
||||
{/* Left: date stem */}
|
||||
<div style={{ width: 100, flexShrink: 0, paddingRight: 16 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--amber)', fontWeight: 700 }}>
|
||||
{date.slice(5)} {/* MM-DD */}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
|
||||
{date.slice(0, 4)}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: hasKeeper ? 'var(--good)' : 'var(--text-lo)', marginTop: 4 }}>
|
||||
{totalMin >= 60 ? `${(totalMin/60).toFixed(1)}h` : `${totalMin}m`}
|
||||
</div>
|
||||
{/* vertical line */}
|
||||
<div style={{ width: 1, background: 'var(--border)', height: '100%', marginLeft: 10, marginTop: 6 }} />
|
||||
</div>
|
||||
|
||||
{/* Right: session entries */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{items.map((item, idx) => (
|
||||
<div key={idx} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{/* Gallery thumbnail */}
|
||||
{item.gallery_url ? (
|
||||
<img src={item.gallery_url} alt={item.name} style={{ width: 40, height: 40, objectFit: 'cover', borderRadius: 2, background: '#000', flexShrink: 0 }} />
|
||||
) : (
|
||||
<div style={{ width: 40, height: 40, background: 'var(--bg-deep)', borderRadius: 2, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<span style={{ fontSize: 14, color: 'var(--text-lo)' }}>✦</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-hi)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.common_name ?? item.name}
|
||||
{item.common_name && (
|
||||
<span style={{ color: 'var(--text-lo)', fontSize: 10, marginLeft: 5 }}>{item.name}</span>
|
||||
)}
|
||||
</div>
|
||||
{item.notes && (
|
||||
<div style={{ fontFamily: 'var(--font-sans)', fontSize: 11, color: 'var(--text-lo)', fontStyle: 'italic', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10, padding: '2px 6px', borderRadius: 3,
|
||||
background: `${FILTER_PILL_COLORS[item.filter_id] ?? 'var(--muted)'}22`,
|
||||
color: FILTER_PILL_COLORS[item.filter_id] ?? 'var(--text-lo)',
|
||||
border: `1px solid ${FILTER_PILL_COLORS[item.filter_id] ?? 'var(--border)'}`,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{FILTER_LABELS_HIST[item.filter_id] ?? item.filter_id}
|
||||
</span>
|
||||
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', width: 36, textAlign: 'right', flexShrink: 0 }}>
|
||||
{item.integration_min >= 60
|
||||
? `${(item.integration_min / 60).toFixed(1)}h`
|
||||
: `${item.integration_min}m`}
|
||||
</span>
|
||||
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 9, padding: '2px 5px', borderRadius: 3,
|
||||
background: `${QUALITY_COLORS[item.quality] ?? 'var(--muted)'}22`,
|
||||
color: QUALITY_COLORS[item.quality] ?? 'var(--text-lo)',
|
||||
border: `1px solid ${QUALITY_COLORS[item.quality] ?? 'var(--border)'}44`,
|
||||
flexShrink: 0, textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
}}>
|
||||
{item.quality.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FILTER_COLORS: Record<string, string> = {
|
||||
sv220: '#9b59b6',
|
||||
@@ -366,6 +478,58 @@ export default function Stats() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Catalogue completion tracker */}
|
||||
{(stats.catalogue_completion?.length ?? 0) > 0 && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 14, fontWeight: 700, marginBottom: 14 }}>
|
||||
Catalogue Completion
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
|
||||
{stats.catalogue_completion.map(cat => {
|
||||
const done = cat.pct >= 100;
|
||||
return (
|
||||
<div key={cat.name} style={{
|
||||
background: 'var(--bg-panel)',
|
||||
border: `1px solid ${done ? 'var(--good)' : 'var(--border)'}`,
|
||||
borderRadius: 6, padding: '12px 14px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700 }}>
|
||||
{cat.name} {done && '🏆'}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: done ? 'var(--good)' : 'var(--text-mid)' }}>
|
||||
{cat.keepers} / {cat.total}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: 6, background: 'var(--bg-deep)', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
width: `${Math.min(cat.pct, 100)}%`, height: '100%',
|
||||
background: done ? 'var(--good)' : 'var(--amber)',
|
||||
borderRadius: 3, transition: 'width 0.4s',
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', marginTop: 4, textAlign: 'right' }}>
|
||||
{cat.pct}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Observation history timeline */}
|
||||
{(stats.history?.length ?? 0) > 0 && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 14, fontWeight: 700, marginBottom: 14 }}>
|
||||
Observation History
|
||||
</div>
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '16px 20px' }}>
|
||||
<SessionTimeline entries={stats.history} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PHD2Section />
|
||||
</div>
|
||||
);
|
||||
|
||||
+175
-38
@@ -1,13 +1,15 @@
|
||||
import { useState, Fragment } from 'react';
|
||||
import { useState, Fragment, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTargets } from '../hooks/useTargets';
|
||||
import TargetRow from '../components/targets/TargetRow';
|
||||
import DetailDrawer from '../components/targets/DetailDrawer';
|
||||
import CompareModal from '../components/targets/CompareModal';
|
||||
import type { Target } from '../api/types';
|
||||
|
||||
const OBJ_TYPES = ['All', 'galaxy', 'emission_nebula', 'reflection_nebula', 'planetary_nebula', 'snr', 'open_cluster', 'globular_cluster', 'dark_nebula'];
|
||||
const OBJ_TYPE_LIST = ['galaxy', 'galaxy_cluster', 'emission_nebula', 'reflection_nebula', 'planetary_nebula', 'snr', 'open_cluster', 'globular_cluster', 'dark_nebula'];
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
galaxy: 'Galaxy', emission_nebula: 'Emission', reflection_nebula: 'Reflection',
|
||||
planetary_nebula: 'Planetary', snr: 'SNR', open_cluster: 'Cluster',
|
||||
galaxy: 'Galaxy', galaxy_cluster: 'Cluster (ACO)', emission_nebula: 'Emission', reflection_nebula: 'Reflection',
|
||||
planetary_nebula: 'Planetary', snr: 'SNR', open_cluster: 'Open Cl.',
|
||||
globular_cluster: 'Globular', dark_nebula: 'Dark',
|
||||
};
|
||||
const FILTERS = [
|
||||
@@ -18,21 +20,52 @@ const FILTERS = [
|
||||
{ id: 'uvir', label: 'UV/IR Cut' },
|
||||
];
|
||||
const SORT_OPTIONS = [
|
||||
{ value: '', label: 'Best alt tonight' },
|
||||
{ value: '', label: 'Best score tonight' },
|
||||
{ value: 'transit', label: 'Transit time' },
|
||||
{ value: 'size', label: 'Size (largest)' },
|
||||
{ value: 'magnitude', label: 'Magnitude' },
|
||||
{ value: 'difficulty', label: 'Difficulty' },
|
||||
{ value: 'integration', label: 'Total integration' },
|
||||
];
|
||||
const STATUS_OPTIONS = [
|
||||
{ id: 'tonight', label: 'Tonight only' },
|
||||
{ id: 'not_imaged', label: 'Not yet imaged' },
|
||||
{ id: 'mosaic_only', label: 'Mosaics only' },
|
||||
{ value: 'altitude', label: 'Altitude tonight' },
|
||||
{ value: 'best_start', label: 'Run order (imaging window)' },
|
||||
];
|
||||
|
||||
const COL_HEADERS = ['Type', 'Name', 'Size', 'Fill', 'Mosaic', 'Mag', '★', 'Filter', 'Alt', 'Vis', 'Int', 'Goal'];
|
||||
|
||||
const LS_KEY = 'astronome_targets_filters_v2';
|
||||
|
||||
interface FilterState {
|
||||
typeFilters: string[];
|
||||
filterPill: string;
|
||||
tonight: boolean;
|
||||
notImaged: boolean;
|
||||
mosaicOnly: boolean;
|
||||
showCustom: boolean;
|
||||
accessible: boolean;
|
||||
minAlt: number | null;
|
||||
minUsable: number | null;
|
||||
sort: string;
|
||||
}
|
||||
|
||||
function loadFilters(): FilterState {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (raw) return JSON.parse(raw) as FilterState;
|
||||
} catch { /* ignore */ }
|
||||
return {
|
||||
typeFilters: [],
|
||||
filterPill: '',
|
||||
tonight: true,
|
||||
notImaged: false,
|
||||
mosaicOnly: false,
|
||||
showCustom: true,
|
||||
accessible: false,
|
||||
minAlt: null,
|
||||
minUsable: null,
|
||||
sort: '',
|
||||
};
|
||||
}
|
||||
|
||||
function Chip({ active, color, onClick, children }: { active: boolean; color?: string; onClick: () => void; children: React.ReactNode }) {
|
||||
const c = color ?? 'var(--amber)';
|
||||
return (
|
||||
@@ -56,32 +89,86 @@ function Chip({ active, color, onClick, children }: { active: boolean; color?: s
|
||||
}
|
||||
|
||||
export default function Targets() {
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
const [filterPill, setFilterPill] = useState('');
|
||||
const [tonight, setTonight] = useState(true);
|
||||
const [notImaged, setNotImaged] = useState(false);
|
||||
const [mosaicOnly, setMosaicOnly] = useState(false);
|
||||
const [minAlt, setMinAlt] = useState<number | undefined>(undefined);
|
||||
const [minUsable, setMinUsable] = useState<number | undefined>(undefined);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState('');
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const { targetId: urlTargetId } = useParams<{ targetId?: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data, isLoading } = useTargets({
|
||||
type: typeFilter || undefined,
|
||||
const saved = loadFilters();
|
||||
const [typeFilters, setTypeFilters] = useState<string[]>(saved.typeFilters);
|
||||
const [filterPill, setFilterPill] = useState(saved.filterPill);
|
||||
const [tonight, setTonight] = useState(saved.tonight);
|
||||
const [notImaged, setNotImaged] = useState(saved.notImaged);
|
||||
const [mosaicOnly, setMosaicOnly] = useState(saved.mosaicOnly);
|
||||
const [showCustom, setShowCustom] = useState(saved.showCustom);
|
||||
const [accessible, setAccessible] = useState(saved.accessible ?? false);
|
||||
const [minAlt, setMinAlt] = useState<number | null>(saved.minAlt);
|
||||
const [minUsable, setMinUsable] = useState<number | null>(saved.minUsable);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState(saved.sort);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(urlTargetId ?? null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [compareTargets, setCompareTargets] = useState<Target[]>([]);
|
||||
const [showCompare, setShowCompare] = useState(false);
|
||||
|
||||
const toggleCompare = (t: Target) => {
|
||||
setCompareTargets(prev => {
|
||||
if (prev.find(p => p.id === t.id)) return prev.filter(p => p.id !== t.id);
|
||||
if (prev.length >= 2) return [prev[1], t];
|
||||
return [...prev, t];
|
||||
});
|
||||
};
|
||||
|
||||
// Persist filter state to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
const state: FilterState = { typeFilters, filterPill, tonight, notImaged, mosaicOnly, showCustom, accessible, minAlt, minUsable, sort };
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(state));
|
||||
}, [typeFilters, filterPill, tonight, notImaged, mosaicOnly, showCustom, minAlt, minUsable, sort]);
|
||||
|
||||
const toggleType = (t: string) => {
|
||||
setTypeFilters(prev =>
|
||||
prev.includes(t) ? prev.filter(x => x !== t) : [...prev, t]
|
||||
);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
// "Accessible tonight" applies extra server-side constraints: alt≥40°, moon_sep≥45°, usable≥60min, difficulty≤2
|
||||
const effectiveMinAlt = accessible ? Math.max(minAlt ?? 0, 40) : (minAlt ?? undefined);
|
||||
const effectiveMinUsable = accessible ? Math.max(minUsable ?? 0, 60) : (minUsable ?? undefined);
|
||||
|
||||
// When URL has a target ID, disable tonight-only filter so the target is found even if off-season
|
||||
const { data: rawData, isLoading } = useTargets({
|
||||
type: typeFilters.length ? typeFilters.join(',') : undefined,
|
||||
filter: filterPill || undefined,
|
||||
tonight,
|
||||
tonight: urlTargetId ? false : tonight,
|
||||
not_imaged: notImaged || undefined,
|
||||
mosaic_only: mosaicOnly || undefined,
|
||||
min_alt_deg: minAlt,
|
||||
min_usable_min: minUsable,
|
||||
search: search || undefined,
|
||||
show_custom: showCustom ? undefined : false,
|
||||
min_alt_deg: effectiveMinAlt || undefined,
|
||||
min_usable_min: effectiveMinUsable || undefined,
|
||||
search: urlTargetId ? urlTargetId : (search || undefined),
|
||||
sort: sort || undefined,
|
||||
page,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
// Client-side accessible filter: difficulty ≤ 2 and moon_sep ≥ 45°
|
||||
const data = accessible && rawData ? {
|
||||
...rawData,
|
||||
items: rawData.items.filter(t =>
|
||||
(t.difficulty == null || t.difficulty <= 2) &&
|
||||
(t.moon_sep_deg == null || t.moon_sep_deg >= 45)
|
||||
),
|
||||
} : rawData;
|
||||
|
||||
// Sync expandedId → URL
|
||||
useEffect(() => {
|
||||
if (expandedId) {
|
||||
navigate(`/targets/${encodeURIComponent(expandedId)}`, { replace: true });
|
||||
} else if (urlTargetId) {
|
||||
navigate('/targets', { replace: true });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [expandedId]);
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
setExpandedId(prev => prev === id ? null : id);
|
||||
};
|
||||
@@ -100,18 +187,30 @@ export default function Targets() {
|
||||
borderBottom: '1px solid var(--border)',
|
||||
marginBottom: 10,
|
||||
}}>
|
||||
{/* Row 1: object types */}
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 6 }}>
|
||||
{OBJ_TYPES.map(t => (
|
||||
{/* Row 1: object types (multi-select) */}
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 6, alignItems: 'center' }}>
|
||||
<Chip
|
||||
active={typeFilters.length === 0}
|
||||
color="var(--amber)"
|
||||
onClick={() => { setTypeFilters([]); setPage(1); }}
|
||||
>
|
||||
All
|
||||
</Chip>
|
||||
{OBJ_TYPE_LIST.map(t => (
|
||||
<Chip
|
||||
key={t}
|
||||
active={(t === 'All' && !typeFilter) || t === typeFilter}
|
||||
active={typeFilters.includes(t)}
|
||||
color="var(--amber)"
|
||||
onClick={() => setTypeFilter(t === 'All' ? '' : t)}
|
||||
onClick={() => toggleType(t)}
|
||||
>
|
||||
{TYPE_LABELS[t] ?? t}
|
||||
</Chip>
|
||||
))}
|
||||
{typeFilters.length > 0 && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', marginLeft: 4 }}>
|
||||
{typeFilters.length} type{typeFilters.length > 1 ? 's' : ''} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: filters + sort + status + search */}
|
||||
@@ -124,7 +223,7 @@ export default function Targets() {
|
||||
key={f.id}
|
||||
active={f.id === filterPill}
|
||||
color="var(--blue)"
|
||||
onClick={() => setFilterPill(f.id === filterPill ? '' : f.id)}
|
||||
onClick={() => { setFilterPill(f.id === filterPill ? '' : f.id); setPage(1); }}
|
||||
>
|
||||
{f.label}
|
||||
</Chip>
|
||||
@@ -143,6 +242,12 @@ export default function Targets() {
|
||||
<Chip active={mosaicOnly} color="var(--warn)" onClick={() => setMosaicOnly(v => !v)}>
|
||||
Mosaics only
|
||||
</Chip>
|
||||
<Chip active={showCustom} color="var(--teal)" onClick={() => setShowCustom(v => !v)}>
|
||||
Custom
|
||||
</Chip>
|
||||
<Chip active={accessible} color="var(--good)" onClick={() => { setAccessible(v => !v); setPage(1); }}>
|
||||
Accessible tonight
|
||||
</Chip>
|
||||
|
||||
<div style={{ width: 1, background: 'var(--border)', height: 16, margin: '0 2px' }} />
|
||||
|
||||
@@ -151,7 +256,7 @@ export default function Targets() {
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>MIN ALT</span>
|
||||
<select
|
||||
value={minAlt ?? ''}
|
||||
onChange={e => setMinAlt(e.target.value ? Number(e.target.value) : undefined)}
|
||||
onChange={e => { setMinAlt(e.target.value ? Number(e.target.value) : null); setPage(1); }}
|
||||
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, padding: '2px 6px', width: 70 }}
|
||||
>
|
||||
<option value="">Any</option>
|
||||
@@ -167,7 +272,7 @@ export default function Targets() {
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>MIN TIME</span>
|
||||
<select
|
||||
value={minUsable ?? ''}
|
||||
onChange={e => setMinUsable(e.target.value ? Number(e.target.value) : undefined)}
|
||||
onChange={e => { setMinUsable(e.target.value ? Number(e.target.value) : null); setPage(1); }}
|
||||
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, padding: '2px 6px', width: 80 }}
|
||||
>
|
||||
<option value="">Any</option>
|
||||
@@ -213,6 +318,29 @@ export default function Targets() {
|
||||
<span style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)' }}>
|
||||
{data?.total ?? 0} objects
|
||||
</span>
|
||||
{compareTargets.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 16 }}>
|
||||
{compareTargets.map(t => (
|
||||
<span key={t.id} style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)', background: 'var(--amber-glow)', border: '1px solid var(--amber-dim)', borderRadius: 3, padding: '1px 6px' }}>
|
||||
{t.common_name ?? t.name}
|
||||
</span>
|
||||
))}
|
||||
{compareTargets.length === 2 && (
|
||||
<button
|
||||
onClick={() => setShowCompare(true)}
|
||||
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--bg-void)', background: 'var(--amber)', border: 'none', borderRadius: 3, padding: '3px 10px', cursor: 'pointer', fontWeight: 700 }}
|
||||
>
|
||||
Compare →
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setCompareTargets([])}
|
||||
style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', background: 'none', border: '1px solid var(--border)', borderRadius: 3, padding: '1px 6px', cursor: 'pointer' }}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -227,8 +355,8 @@ export default function Targets() {
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border-hi)' }}>
|
||||
{COL_HEADERS.map(h => (
|
||||
<th key={h} style={{
|
||||
{[...COL_HEADERS, ''].map((h, i) => (
|
||||
<th key={i} style={{
|
||||
padding: '4px 8px',
|
||||
textAlign: 'left',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
@@ -250,10 +378,12 @@ export default function Targets() {
|
||||
target={target}
|
||||
expanded={expandedId === target.id}
|
||||
onToggle={() => toggleExpand(target.id)}
|
||||
inCompare={compareTargets.some(c => c.id === target.id)}
|
||||
onCompare={toggleCompare}
|
||||
/>
|
||||
{expandedId === target.id && (
|
||||
<tr>
|
||||
<td colSpan={COL_HEADERS.length} style={{ padding: 0 }}>
|
||||
<td colSpan={COL_HEADERS.length + 1} style={{ padding: 0 }}>
|
||||
<DetailDrawer target={target} />
|
||||
</td>
|
||||
</tr>
|
||||
@@ -263,6 +393,13 @@ export default function Targets() {
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{showCompare && compareTargets.length === 2 && (
|
||||
<CompareModal
|
||||
targets={[compareTargets[0], compareTargets[1]]}
|
||||
onClose={() => setShowCompare(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,6 +97,9 @@ input:focus, select:focus, textarea:focus {
|
||||
.type-badge.reflection_nebula { background: var(--type-reflection); color: #fff; }
|
||||
.type-badge.dark_nebula { background: var(--type-dark); color: var(--text-mid); }
|
||||
.type-badge.nebula { background: var(--teal); color: #fff; }
|
||||
.type-badge.galaxy_cluster { background: #7b3f9e; color: #fff; }
|
||||
.type-badge.galaxy_group { background: #4a5268; color: #fff; }
|
||||
.type-badge.interacting_galaxy { background: #5c4070; color: #fff; }
|
||||
|
||||
/* Quality chips */
|
||||
.quality-chip {
|
||||
|
||||
Reference in New Issue
Block a user