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>
))}