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
+185 -1
View File
@@ -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)' }}>