import { useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from '../api'; import type { HorizonPoint } from '../api/types'; const DEFAULT_PROFILE: EquipProfile = { id: 'default', name: 'AT71 + ATR2600C', focal_mm: 490, aperture_mm: 71, pixel_um: 3.76, res_x: 6248, res_y: 4176, }; interface EquipProfile { id: string; name: string; focal_mm: number; aperture_mm: number; pixel_um: number; res_x: number; res_y: number; } function loadProfiles(): EquipProfile[] { try { const stored = JSON.parse(localStorage.getItem('astronome_equip_profiles') ?? '[]') as EquipProfile[]; // Ensure the default profile is always present if (!stored.find(p => p.id === 'default')) { return [DEFAULT_PROFILE, ...stored]; } return stored; } catch { return [DEFAULT_PROFILE]; } } function calcProfile(p: { focal_mm: number; aperture_mm: number; pixel_um: number; res_x: number; res_y: number }) { 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(loadProfiles); const [activeId, setActiveId] = useState(() => localStorage.getItem('astronome_active_profile') ?? 'default' ); const [editing, setEditing] = useState(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]); useEffect(() => { localStorage.setItem('astronome_active_profile', activeId); }, [activeId]); 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) => { if (profiles.length <= 1) return; setProfiles(ps => ps.filter(p => p.id !== id)); if (activeId === id) setActiveId(profiles.find(p => p.id !== id)?.id ?? 'default'); }; 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 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 (

Equipment Profiles

{profiles.map(p => { const calc = calcProfile(p); const isActive = p.id === activeId; return (
{p.name} {isActive && ACTIVE}
{p.focal_mm}mm {calc.focal_ratio} · {p.aperture_mm}mm aperture · {p.pixel_um}μm px · {p.res_x}×{p.res_y}
Plate scale: {calc.plate_scale_arcsec}″/px {' · '}FOV: {calc.fov_w}
{!isActive && ( )} {profiles.length > 1 && ( )}
); })} {(adding || editing) && (
{editing ? 'Edit Profile' : 'New Profile'}
{[ { 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 => (
)[field.key]} onChange={e => setForm(f => ({ ...f, [field.key]: field.type === 'number' ? parseFloat(e.target.value) || 0 : e.target.value }))} style={fieldStyle} />
))}
{form.focal_mm > 0 && form.pixel_um > 0 && (
Preview: {calcProfile(form).plate_scale_arcsec}″/px · {calcProfile(form).fov_w} FOV
)}
)} {!adding && !editing && ( )}
); } function HorizonPolarChart({ points }: { points: HorizonPoint[] }) { const size = 280; const cx = size / 2; const cy = size / 2; const r = 120; // Draw horizon profile as a polar chart const pathParts = points.map((p, i) => { const azRad = (p.az_deg - 90) * (Math.PI / 180); const altFrac = 1 - p.alt_deg / 90; const pr = altFrac * r; const x = cx + pr * Math.cos(azRad); const y = cy + pr * Math.sin(azRad); return `${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`; }); if (pathParts.length) pathParts.push('Z'); return ( {/* Grid circles */} {[15, 30, 45, 60, 75, 90].map(alt => { const pr = (1 - alt / 90) * r; return ( {alt}° ); })} {/* Cardinal lines */} {[0, 90, 180, 270].map(az => { const azRad = (az - 90) * (Math.PI / 180); return ( ); })} {/* Labels */} {[['N', 0], ['E', 90], ['S', 180], ['W', 270]].map(([label, az]) => { const azRad = ((az as number) - 90) * (Math.PI / 180); return ( {label as string} ); })} {/* Horizon profile */} {pathParts.length > 0 && ( )} ); } export default function Settings() { const qc = useQueryClient(); const { data: horizonData } = useQuery({ queryKey: ['horizon'], queryFn: () => api.horizon.get(), }); const { data: health } = useQuery({ queryKey: ['health'], queryFn: () => api.health.get(), }); const setHorizon = useMutation({ mutationFn: (points: HorizonPoint[]) => api.horizon.set(points), onSuccess: () => qc.invalidateQueries({ queryKey: ['horizon'] }), }); const [recomputing, setRecomputing] = useState(false); const [recomputeMsg, setRecomputeMsg] = useState(''); const [rebuilding, setRebuilding] = useState(false); const [rebuildResult, setRebuildResult] = useState(null); const [showResetConfirm, setShowResetConfirm] = useState(false); const [resetting, setResetting] = useState(false); const [resetMsg, setResetMsg] = useState(''); const triggerRecompute = async () => { setRecomputing(true); setRecomputeMsg(''); try { const res = await fetch('/api/nightly/recompute', { method: 'POST' }); if (res.ok) { setRecomputeMsg('Nightly recompute started — takes ~20s. Reload the Targets page when done.'); } else { setRecomputeMsg('Backend returned an error. Check logs.'); } } catch { setRecomputeMsg('Error reaching backend.'); } setRecomputing(false); }; const triggerFactoryReset = async () => { setResetting(true); setResetMsg(''); setShowResetConfirm(false); try { const res = await fetch('/api/factory-reset', { method: 'POST' }); if (res.ok) { setResetMsg('Reset started. Catalog is rebuilding (~60s). Reload the page when done.'); qc.invalidateQueries({ queryKey: ['health'] }); qc.invalidateQueries({ queryKey: ['targets'] }); } else { const d = await res.json().catch(() => ({})); setResetMsg((d as { error?: string }).error ?? 'Backend returned an error.'); } } catch { setResetMsg('Error reaching backend.'); } setResetting(false); }; const triggerRebuild = async () => { setRebuilding(true); setRebuildResult(null); try { const res = await fetch('/api/catalog/rebuild'); if (res.ok) { const data = await res.json(); if (data.status === 'success') { setRebuildResult({ status: 'success', message: `Rebuild complete: ${data.total} objects. Starting automatic nightly recompute...`, ...data }); // Invalidate queries to refresh the catalog qc.invalidateQueries({ queryKey: ['targets'] }); qc.invalidateQueries({ queryKey: ['health'] }); // Wait for nightly recompute to complete (~30s) then reload setTimeout(() => window.location.reload(), 4000); } else { setRebuildResult({ error: 'Unexpected response from server.' }); } } else { const errorData = await res.json().catch(() => ({})); setRebuildResult({ error: errorData.error || 'Backend returned an error. Check logs.' }); } } catch (err) { setRebuildResult({ error: `Error reaching backend: ${String(err)}` }); } setRebuilding(false); }; const handleHorizonCSV = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const text = await file.text(); const lines = text.trim().split('\n').slice(1); // skip header const points: HorizonPoint[] = []; for (const line of lines) { const [az, alt] = line.split(',').map(Number); if (!isNaN(az) && !isNaN(alt)) { points.push({ az_deg: Math.round(az) % 360, alt_deg: Math.max(0, Math.min(90, alt)) }); } } if (points.length === 360) { setHorizon.mutate(points); } else { alert(`CSV must have exactly 360 rows (got ${points.length}). Format: az_deg,alt_deg`); } }; const resetHorizon = () => { const flat: HorizonPoint[] = Array.from({ length: 360 }, (_, i) => ({ az_deg: i, alt_deg: 15.0 })); setHorizon.mutate(flat); }; return (

Settings

{/* Equipment Profiles */} {/* Custom Horizon */}

Custom Horizon Profile

{horizonData?.points && }
Upload a CSV file with columns az_deg,alt_deg, one row per degree (360 rows total).
{setHorizon.isSuccess && (
✓ Horizon updated
)}
{/* App Info */}

App Info

{[ ['Status', health?.status ?? '—'], ['Catalog size', health?.catalog_size != null ? `${health.catalog_size.toLocaleString()} objects` : '—'], ['Last refreshed', health?.catalog_last_refreshed ? new Date(health.catalog_last_refreshed * 1000).toLocaleString('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }) : '—'], ['DB size', health?.db_size_bytes != null ? health.db_size_bytes < 1024 * 1024 ? `${Math.round(health.db_size_bytes / 1024)} KB` : `${(health.db_size_bytes / 1024 / 1024).toFixed(1)} MB` : '—'], ['Backend version', health?.version ?? '—'], ].map(([label, value]) => ( ))}
{label} {value}
{/* Factory Reset */}
{!showResetConfirm ? ( ) : (
Factory Reset
Clears catalog, nightly cache, and weather data. Triggers a full rebuild (~60s).
Imaging logs, gallery, PHD2 logs, and horizon are preserved.
)}
{recomputeMsg && (
{recomputeMsg}
)} {resetMsg && (
{resetMsg}
)} {rebuildResult && (
{rebuildResult.error ? (
{rebuildResult.error}
) : rebuildResult.message ? (
{rebuildResult.message}
) : ( <>
Total: {rebuildResult.total?.toLocaleString() || '?'} entries
By Type: {rebuildResult.by_type && Object.entries(rebuildResult.by_type).length > 0 ? (
{Object.entries(rebuildResult.by_type).map(([type, count]: [string, any]) => (
{type}: {count}
))}
) : (
None
)}
Messier: {rebuildResult.messier_count || 0} ({rebuildResult.has_sizes || 0} with size data)
)}
)}
); }