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:
@@ -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)' }}>
|
||||
|
||||
Reference in New Issue
Block a user