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:
2026-04-17 07:20:10 +02:00
parent 8f72745bc0
commit 2bb80a8475
45 changed files with 5613 additions and 628 deletions
@@ -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: '3050° good' },
{ color: 'var(--warn)', label: '1530° marginal' },
{ color: 'var(--muted)', label: '<15° poor' },
{ color: '#4d9de0', label: 'Moon %' },
{ color: '#e8c030', label: '1530° 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>
);
}
+343 -112
View File
@@ -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) && (
+47 -1
View File
@@ -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;