Initial Commit
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import PageShell from './components/layout/PageShell';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Targets from './pages/Targets';
|
||||
import Calendar from './pages/Calendar';
|
||||
import Stats from './pages/Stats';
|
||||
import Settings from './pages/Settings';
|
||||
import Gallery from './pages/Gallery';
|
||||
import SolarSystem from './pages/SolarSystem';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<PageShell>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/targets" element={<Targets />} />
|
||||
<Route path="/calendar" element={<Calendar />} />
|
||||
<Route path="/stats" element={<Stats />} />
|
||||
<Route path="/gallery" element={<Gallery />} />
|
||||
<Route path="/solar-system" element={<SolarSystem />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</PageShell>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
declare module 'aladin-lite' {
|
||||
interface AladinInstance {
|
||||
setFov: (fov: number) => void;
|
||||
gotoRaDec: (ra: number, dec: number) => void;
|
||||
addOverlay: (overlay: AladinOverlay) => void;
|
||||
}
|
||||
|
||||
interface AladinOverlay {
|
||||
add: (shape: unknown) => void;
|
||||
}
|
||||
|
||||
interface AladinStatic {
|
||||
init: Promise<void>;
|
||||
aladin: (selector: string, options: object) => AladinInstance;
|
||||
graphicOverlay: (options: object) => AladinOverlay;
|
||||
polyline: (points: [number, number][], options?: object) => unknown;
|
||||
}
|
||||
|
||||
const A: AladinStatic;
|
||||
export default A;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import type {
|
||||
CalendarDay,
|
||||
CalendarDateDetail,
|
||||
CurvePoint,
|
||||
FilterBreakdownItem,
|
||||
FilterRecommendation,
|
||||
GalleryImage,
|
||||
HorizonPoint,
|
||||
LogEntry,
|
||||
Phd2Log,
|
||||
Stats,
|
||||
Target,
|
||||
TargetNotes,
|
||||
TargetsResponse,
|
||||
Tonight,
|
||||
VisibilitySummary,
|
||||
WeatherData,
|
||||
Workflow,
|
||||
} from './types';
|
||||
|
||||
const base = '/api';
|
||||
|
||||
async function get<T>(path: string): Promise<T> {
|
||||
const resp = await fetch(`${base}${path}`);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${path}`);
|
||||
return resp.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function post<T>(path: string, body: unknown): Promise<T> {
|
||||
const resp = await fetch(`${base}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${path}`);
|
||||
return resp.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function put<T>(path: string, body: unknown): Promise<T> {
|
||||
const resp = await fetch(`${base}${path}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${path}`);
|
||||
return resp.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function del<T>(path: string): Promise<T> {
|
||||
const resp = await fetch(`${base}${path}`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${path}`);
|
||||
return resp.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// Targets
|
||||
export interface TargetsParams {
|
||||
type?: string;
|
||||
constellation?: string;
|
||||
filter?: string;
|
||||
tonight?: boolean;
|
||||
search?: string;
|
||||
sort?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
min_alt_deg?: number;
|
||||
min_usable_min?: number;
|
||||
mosaic_only?: boolean;
|
||||
not_imaged?: boolean;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
targets: {
|
||||
list: (params: TargetsParams = {}): Promise<TargetsResponse> => {
|
||||
const q = new URLSearchParams();
|
||||
if (params.type) q.set('type', params.type);
|
||||
if (params.constellation) q.set('constellation', params.constellation);
|
||||
if (params.filter) q.set('filter', params.filter);
|
||||
if (params.tonight !== undefined) q.set('tonight', String(params.tonight));
|
||||
if (params.search) q.set('search', params.search);
|
||||
if (params.sort) q.set('sort', params.sort);
|
||||
if (params.page) q.set('page', String(params.page));
|
||||
if (params.limit) q.set('limit', String(params.limit));
|
||||
if (params.min_alt_deg !== undefined) q.set('min_alt_deg', String(params.min_alt_deg));
|
||||
if (params.min_usable_min !== undefined) q.set('min_usable_min', String(params.min_usable_min));
|
||||
if (params.mosaic_only) q.set('mosaic_only', 'true');
|
||||
if (params.not_imaged) q.set('not_imaged', 'true');
|
||||
return get<TargetsResponse>(`/targets?${q}`);
|
||||
},
|
||||
get: (id: string): Promise<Target> => get(`/targets/${id}`),
|
||||
visibility: (id: string): Promise<VisibilitySummary> => get(`/targets/${id}/visibility`),
|
||||
curve: (id: string): Promise<{ catalog_id: string; curve: CurvePoint[] }> => get(`/targets/${id}/curve`),
|
||||
filters: (id: string): Promise<{ recommendations: FilterRecommendation[] }> => get(`/targets/${id}/filters`),
|
||||
workflow: (id: string, filterId: string): Promise<Workflow> => get(`/targets/${id}/workflow/${filterId}`),
|
||||
yearly: (id: string): Promise<{ catalog_id: string; points: { date: string; alt_at_midnight: number; transit_alt: number; usable_min: number; moon_illumination: number }[] }> => get(`/targets/${id}/yearly`),
|
||||
getNotes: (id: string): Promise<TargetNotes> => get(`/targets/${id}/notes`),
|
||||
putNotes: (id: string, notes: string): Promise<{ status: string }> => put(`/targets/${id}/notes`, { notes }),
|
||||
},
|
||||
|
||||
tonight: {
|
||||
get: (): Promise<Tonight> => get('/tonight'),
|
||||
},
|
||||
|
||||
calendar: {
|
||||
get: (months?: number): Promise<{ days: CalendarDay[] }> =>
|
||||
get(`/calendar${months ? `?months=${months}` : ''}`),
|
||||
getDate: (date: string): Promise<CalendarDateDetail> =>
|
||||
get(`/calendar/${date}`),
|
||||
getNewMoonWindows: (): Promise<{ windows: { date: string; illumination: number; top_targets: { id: string; name: string; common_name?: string; max_alt_deg?: number; recommended_filter?: string }[] }[] }> =>
|
||||
get('/calendar/new-moon-windows'),
|
||||
},
|
||||
|
||||
weather: {
|
||||
get: (): Promise<WeatherData> => get('/weather'),
|
||||
forecast: (): Promise<unknown> => get('/weather/forecast'),
|
||||
},
|
||||
|
||||
log: {
|
||||
list: (page?: number): Promise<{ items: LogEntry[]; total: number }> =>
|
||||
get(`/log${page ? `?page=${page}` : ''}`),
|
||||
forTarget: (catalogId: string): Promise<{ items: LogEntry[]; total_integration_min: number; filter_breakdown: FilterBreakdownItem[] }> =>
|
||||
get(`/log/${catalogId}`),
|
||||
create: (entry: Omit<LogEntry, 'id' | 'created_at' | 'target_name' | 'target_common_name' | 'target_obj_type'>): Promise<{ id: number }> =>
|
||||
post('/log', entry),
|
||||
update: (id: number, data: Partial<LogEntry>): Promise<{ id: number }> =>
|
||||
put(`/log/entry/${id}`, data),
|
||||
delete: (id: number): Promise<{ id: number }> =>
|
||||
del(`/log/entry/${id}`),
|
||||
exportCsv: (): void => { window.open('/api/log/export', '_blank'); },
|
||||
},
|
||||
|
||||
phd2: {
|
||||
list: (): Promise<{ items: Phd2Log[] }> => get('/phd2'),
|
||||
get: (id: number): Promise<Phd2Log> => get(`/phd2/${id}`),
|
||||
delete: (id: number): Promise<{ status: string; id: number }> => del(`/phd2/${id}`),
|
||||
upload: (formData: FormData): Promise<{ id: number; duplicate: boolean; duplicate_id?: number; analysis: unknown; message?: string }> => {
|
||||
return fetch(`${base}/phd2/upload`, { method: 'POST', body: formData })
|
||||
.then(r => r.json() as Promise<{ id: number; duplicate: boolean; duplicate_id?: number; analysis: unknown; message?: string }>);
|
||||
},
|
||||
},
|
||||
|
||||
gallery: {
|
||||
listAll: (): Promise<{ items: (GalleryImage & { target_name?: string; target_common_name?: string })[] }> =>
|
||||
get('/gallery'),
|
||||
list: (catalogId: string): Promise<{ items: GalleryImage[] }> =>
|
||||
get(`/gallery/${catalogId}`),
|
||||
delete: (id: number): Promise<{ id: number }> => del(`/gallery/item/${id}`),
|
||||
upload: (catalogId: string, formData: FormData): Promise<{ id: number; url: string }> => {
|
||||
return fetch(`${base}/gallery/${catalogId}`, { method: 'POST', body: formData })
|
||||
.then(r => r.json() as Promise<{ id: number; url: string }>);
|
||||
},
|
||||
},
|
||||
|
||||
horizon: {
|
||||
get: (): Promise<{ points: HorizonPoint[] }> => get('/horizon'),
|
||||
set: (points: HorizonPoint[]): Promise<{ status: string }> => put('/horizon', points),
|
||||
},
|
||||
|
||||
stats: {
|
||||
get: (): Promise<Stats> => get('/stats'),
|
||||
},
|
||||
|
||||
health: {
|
||||
get: (): Promise<{ status: string; catalog_size: number; catalog_last_refreshed?: number; db_size_bytes?: number; version: string }> => get('/health'),
|
||||
},
|
||||
|
||||
admin: {
|
||||
catalogRefresh: (): Promise<{ status: string }> =>
|
||||
fetch('/api/catalog/refresh', { method: 'POST' }).then(r => r.json() as Promise<{ status: string }>),
|
||||
nightlyRecompute: (): Promise<{ status: string }> =>
|
||||
fetch('/api/nightly/recompute', { method: 'POST' }).then(r => r.json() as Promise<{ status: string }>),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,237 @@
|
||||
export interface Target {
|
||||
id: string;
|
||||
name: string;
|
||||
common_name?: string;
|
||||
obj_type: string;
|
||||
ra_deg: number;
|
||||
dec_deg: number;
|
||||
ra_h: string;
|
||||
dec_dms: string;
|
||||
constellation?: string;
|
||||
size_arcmin_maj?: number;
|
||||
size_arcmin_min?: number;
|
||||
pos_angle_deg?: number;
|
||||
mag_v?: number;
|
||||
surface_brightness?: number;
|
||||
hubble_type?: string;
|
||||
messier_num?: number;
|
||||
is_highlight: boolean;
|
||||
fov_fill_pct?: number;
|
||||
mosaic_flag: boolean;
|
||||
mosaic_panels_w: number;
|
||||
mosaic_panels_h: number;
|
||||
difficulty?: number;
|
||||
guide_star_density?: string;
|
||||
// From nightly_cache
|
||||
max_alt_deg?: number;
|
||||
usable_min?: number;
|
||||
transit_utc?: string;
|
||||
recommended_filter?: string;
|
||||
best_start_utc?: string;
|
||||
best_end_utc?: string;
|
||||
moon_sep_deg?: number;
|
||||
is_visible_tonight?: boolean;
|
||||
total_integration_min?: number;
|
||||
}
|
||||
|
||||
export interface TargetsResponse {
|
||||
items: Target[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface VisibilitySummary {
|
||||
catalog_id: string;
|
||||
night_date?: string;
|
||||
max_alt_deg?: number;
|
||||
transit_utc?: string;
|
||||
rise_utc?: string;
|
||||
set_utc?: string;
|
||||
best_start_utc?: string;
|
||||
best_end_utc?: string;
|
||||
usable_min?: number;
|
||||
meridian_flip_utc?: string;
|
||||
airmass_at_transit?: number;
|
||||
extinction_mag?: number;
|
||||
moon_sep_deg?: number;
|
||||
recommended_filter?: string;
|
||||
is_visible_tonight?: boolean;
|
||||
}
|
||||
|
||||
export interface CurvePoint {
|
||||
utc: string;
|
||||
alt_deg: number;
|
||||
az_deg: number;
|
||||
airmass: number;
|
||||
above_custom_horizon: boolean;
|
||||
moon_alt_deg: number;
|
||||
}
|
||||
|
||||
export interface Tonight {
|
||||
date: string;
|
||||
astro_dusk_utc: string;
|
||||
astro_dawn_utc: string;
|
||||
moon_rise_utc?: string;
|
||||
moon_set_utc?: string;
|
||||
moon_illumination?: number;
|
||||
moon_phase_name?: string;
|
||||
moon_ra_deg?: number;
|
||||
moon_dec_deg?: number;
|
||||
true_dark_start_utc?: string;
|
||||
true_dark_end_utc?: string;
|
||||
true_dark_minutes?: number;
|
||||
computed_at?: number;
|
||||
}
|
||||
|
||||
export interface WeatherData {
|
||||
dew_point_c?: number;
|
||||
temp_c?: number;
|
||||
humidity_pct?: number;
|
||||
go_nogo?: 'go' | 'marginal' | 'nogo';
|
||||
go_nogo_reasons?: string[];
|
||||
fetched_at?: number;
|
||||
dew_alert?: 'warning' | 'critical';
|
||||
cloudcover?: number;
|
||||
seeing?: number;
|
||||
transparency?: number;
|
||||
lifted_index?: number;
|
||||
wind10m?: { speed: number; direction: string };
|
||||
rh2m?: number;
|
||||
}
|
||||
|
||||
export interface FilterRecommendation {
|
||||
filter_id: string;
|
||||
filter_name: string;
|
||||
suitability: 'ideal' | 'good' | 'marginal' | 'unsuitable';
|
||||
reason: string;
|
||||
warning?: string;
|
||||
est_integration_hours?: number;
|
||||
sessions_needed?: number;
|
||||
exposure_sec?: number;
|
||||
frames_needed?: number;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
id: number;
|
||||
catalog_id: string;
|
||||
session_date: string;
|
||||
filter_id: string;
|
||||
integration_min: number;
|
||||
quality: 'keeper' | 'needs_more' | 'rejected' | 'pending';
|
||||
notes?: string;
|
||||
guiding_rms?: number;
|
||||
mean_temp_c?: number;
|
||||
created_at: number;
|
||||
target_name?: string;
|
||||
target_common_name?: string;
|
||||
target_obj_type?: string;
|
||||
}
|
||||
|
||||
export interface Phd2Log {
|
||||
id: number;
|
||||
session_date: string;
|
||||
filename: string;
|
||||
rms_total?: number;
|
||||
rms_ra?: number;
|
||||
rms_dec?: number;
|
||||
peak_error?: number;
|
||||
star_lost_count?: number;
|
||||
duration_min?: number;
|
||||
guide_star_snr?: number;
|
||||
created_at: number;
|
||||
// Equipment details
|
||||
equipment_profile?: string;
|
||||
camera_name?: string;
|
||||
exposure_ms?: number;
|
||||
mount_name?: string;
|
||||
pixel_scale_arcsec?: number;
|
||||
hfd_px?: number;
|
||||
guide_star_snr_at_start?: number;
|
||||
}
|
||||
|
||||
export interface GalleryImage {
|
||||
id: number;
|
||||
catalog_id: string;
|
||||
filename: string;
|
||||
url: string;
|
||||
caption?: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface HorizonPoint {
|
||||
az_deg: number;
|
||||
alt_deg: number;
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
total_sessions: number;
|
||||
total_integration_min: number;
|
||||
objects_with_keeper: number;
|
||||
filter_usage: { filter_id: string; count: number; total_min: number }[];
|
||||
monthly: { month: string; sessions: number; total_min: number }[];
|
||||
by_type: { obj_type: string; sessions: number; total_min: number }[];
|
||||
quality: { quality: string; count: number }[];
|
||||
top_targets: { id: string; name: string; common_name?: string; obj_type: string; sessions: number; total_min: number }[];
|
||||
guiding: { date: string; rms_total?: number; rms_ra?: number; rms_dec?: number }[];
|
||||
}
|
||||
|
||||
export interface Workflow {
|
||||
name: string;
|
||||
steps: string[];
|
||||
plugins: [string, string][];
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface CalendarDay {
|
||||
date: string;
|
||||
visible_count?: number;
|
||||
max_usable_min?: number;
|
||||
avg_max_alt?: number;
|
||||
moon_illumination?: number;
|
||||
}
|
||||
|
||||
export interface CalendarDateDetail {
|
||||
date: string;
|
||||
moon_illumination: number;
|
||||
top_targets: {
|
||||
id: string;
|
||||
name: string;
|
||||
common_name?: string;
|
||||
obj_type: string;
|
||||
max_alt_deg?: number;
|
||||
usable_min?: number;
|
||||
transit_utc?: string;
|
||||
recommended_filter?: string;
|
||||
}[];
|
||||
tonight?: {
|
||||
astro_dusk_utc?: string;
|
||||
astro_dawn_utc?: string;
|
||||
moon_rise_utc?: string;
|
||||
moon_set_utc?: string;
|
||||
moon_illumination?: number;
|
||||
moon_phase_name?: string;
|
||||
true_dark_start_utc?: string;
|
||||
true_dark_end_utc?: string;
|
||||
true_dark_minutes?: number;
|
||||
};
|
||||
weather?: {
|
||||
go_nogo?: string;
|
||||
temp_c?: number;
|
||||
dew_point_c?: number;
|
||||
cloudcover?: number;
|
||||
seeing?: number;
|
||||
transparency?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TargetNotes {
|
||||
catalog_id: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface FilterBreakdownItem {
|
||||
filter_id: string;
|
||||
total_min: number;
|
||||
sessions: number;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ReferenceLine,
|
||||
ReferenceArea,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import type { CurvePoint } from '../../api/types';
|
||||
|
||||
interface Props {
|
||||
curve: CurvePoint[];
|
||||
dusk: string;
|
||||
dawn: string;
|
||||
trueDarkStart?: string;
|
||||
trueDarkEnd?: string;
|
||||
meridianFlip?: string;
|
||||
transitUtc?: string;
|
||||
horizonPoints?: { az_deg: number; alt_deg: number }[];
|
||||
moonSepDeg?: number;
|
||||
}
|
||||
|
||||
function fmtHour(utc: string): string {
|
||||
return new Date(utc).toLocaleTimeString('fr-FR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Europe/Paris',
|
||||
});
|
||||
}
|
||||
|
||||
/** Interpolate horizon alt at a given azimuth — mirrors backend horizon_alt() exactly. */
|
||||
function horizonAlt(az: number, pts: { az_deg: number; alt_deg: number }[]): number {
|
||||
if (!pts.length) return 15;
|
||||
const norm = ((az % 360) + 360) % 360;
|
||||
const loIdx = Math.floor(norm) % 360;
|
||||
const hiIdx = (loIdx + 1) % 360;
|
||||
const frac = norm - Math.floor(norm);
|
||||
const loAlt = pts.find(p => p.az_deg === loIdx)?.alt_deg ?? 15;
|
||||
const hiAlt = pts.find(p => p.az_deg === hiIdx)?.alt_deg ?? 15;
|
||||
return loAlt + frac * (hiAlt - loAlt);
|
||||
}
|
||||
|
||||
export default function AltitudeCurve({
|
||||
curve,
|
||||
dusk,
|
||||
dawn,
|
||||
trueDarkStart,
|
||||
trueDarkEnd,
|
||||
meridianFlip,
|
||||
horizonPoints,
|
||||
moonSepDeg,
|
||||
}: Props) {
|
||||
if (!curve || curve.length === 0) {
|
||||
return (
|
||||
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12, padding: 16 }}>
|
||||
No visibility curve available.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Subsample to ~120 points max for rendering performance (1-min data = 480+ points)
|
||||
const stride = Math.max(1, Math.floor(curve.length / 120));
|
||||
const sampled = curve.filter((_, i) => i % stride === 0);
|
||||
|
||||
const data = sampled
|
||||
.filter(p => p.alt_deg > 0) // Only show above 0° altitude
|
||||
.map(p => {
|
||||
const horizonAltitude = horizonPoints?.length
|
||||
? horizonAlt(p.az_deg, horizonPoints)
|
||||
: 15;
|
||||
const belowHorizon = p.alt_deg < horizonAltitude;
|
||||
return {
|
||||
time: p.utc,
|
||||
alt: belowHorizon ? null : Math.round(p.alt_deg * 10) / 10,
|
||||
altBelowHorizon: belowHorizon ? Math.round(p.alt_deg * 10) / 10 : null,
|
||||
// Only draw moon curve when above horizon
|
||||
moon: p.moon_alt_deg > 0 ? Math.round(p.moon_alt_deg * 10) / 10 : null,
|
||||
az: p.az_deg,
|
||||
label: fmtHour(p.utc),
|
||||
horizon: Math.round(horizonAltitude * 10) / 10,
|
||||
belowHorizon, // Flag for styling
|
||||
};
|
||||
});
|
||||
|
||||
// Find contiguous windows where moon is above horizon — shade those periods in blue-warn
|
||||
// Also shade with a stronger tint if moonSepDeg < 30° (close approach)
|
||||
type MoonWindow = { x1: string; x2: string; close: boolean };
|
||||
const moonWindows: MoonWindow[] = [];
|
||||
let winStart: { label: string; close: boolean } | null = null;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const pt = data[i];
|
||||
const moonUp = (pt.moon ?? 0) > 0;
|
||||
const close = moonSepDeg != null && moonSepDeg < 30 && moonUp;
|
||||
if (moonUp && !winStart) {
|
||||
winStart = { label: pt.label, close };
|
||||
} else if (!moonUp && winStart) {
|
||||
moonWindows.push({ x1: winStart.label, x2: data[i - 1].label, close: winStart.close });
|
||||
winStart = null;
|
||||
}
|
||||
}
|
||||
if (winStart && data.length > 0) {
|
||||
moonWindows.push({ x1: winStart.label, x2: data[data.length - 1].label, close: winStart.close });
|
||||
}
|
||||
|
||||
const nowUtc = new Date().toISOString();
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: 240 }}>
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={data} margin={{ top: 4, right: 8, bottom: 4, left: -10 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: 'var(--text-lo)', fontSize: 10, fontFamily: 'IBM Plex Mono' }}
|
||||
interval="preserveStartEnd"
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 90]}
|
||||
tick={{ fill: 'var(--text-lo)', fontSize: 10, fontFamily: 'IBM Plex Mono' }}
|
||||
tickLine={false}
|
||||
tickFormatter={v => `${v}°`}
|
||||
/>
|
||||
<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 === 'horizon') return [`${value}°`, 'Horizon'];
|
||||
if (name === 'moon') return [`${value}°`, 'Moon'];
|
||||
if (name === 'altBelowHorizon') return [`${value}°`, 'Altitude (below horizon)'];
|
||||
return [`${value}°`, 'Altitude'];
|
||||
}}
|
||||
labelStyle={{ color: 'var(--text-mid)' }}
|
||||
/>
|
||||
|
||||
{/* True dark window shading */}
|
||||
{trueDarkStart && trueDarkEnd && (
|
||||
<ReferenceArea
|
||||
x1={fmtHour(trueDarkStart)}
|
||||
x2={fmtHour(trueDarkEnd)}
|
||||
fill="var(--amber-glow)"
|
||||
strokeOpacity={0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Moon-above-horizon shading — subtle blue tint; stronger orange if within 30° */}
|
||||
{moonWindows.map((w, i) => (
|
||||
<ReferenceArea
|
||||
key={i}
|
||||
x1={w.x1}
|
||||
x2={w.x2}
|
||||
fill={w.close ? 'rgba(232,131,42,0.10)' : 'rgba(77,157,224,0.08)'}
|
||||
strokeOpacity={0}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 15° line */}
|
||||
<ReferenceLine y={15} stroke="var(--muted)" strokeDasharray="4 4" />
|
||||
{/* 30° line */}
|
||||
<ReferenceLine y={30} stroke="var(--good)" strokeDasharray="4 4" strokeOpacity={0.4} />
|
||||
|
||||
{/* Meridian flip */}
|
||||
{meridianFlip && (
|
||||
<ReferenceLine
|
||||
x={fmtHour(meridianFlip)}
|
||||
stroke="var(--amber)"
|
||||
strokeDasharray="6 3"
|
||||
label={{ value: 'Flip', fill: 'var(--amber)', fontSize: 9, fontFamily: 'IBM Plex Mono' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Now marker */}
|
||||
{nowUtc >= dusk && nowUtc <= dawn && (
|
||||
<ReferenceLine
|
||||
x={fmtHour(nowUtc)}
|
||||
stroke="var(--amber)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Moon altitude curve — dimmed blue */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="moon"
|
||||
stroke="#4d9de0"
|
||||
strokeWidth={1}
|
||||
dot={false}
|
||||
activeDot={false}
|
||||
strokeOpacity={0.5}
|
||||
strokeDasharray="4 2"
|
||||
/>
|
||||
|
||||
{/* Altitude below custom horizon — greyed out */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="altBelowHorizon"
|
||||
stroke="var(--good)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={false}
|
||||
strokeOpacity={0.5}
|
||||
/>
|
||||
|
||||
{/* Custom horizon step-line — red dashed */}
|
||||
{horizonPoints && horizonPoints.length > 0 && (
|
||||
<Line
|
||||
type="stepAfter"
|
||||
dataKey="horizon"
|
||||
stroke="var(--danger)"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="3 3"
|
||||
dot={false}
|
||||
activeDot={false}
|
||||
strokeOpacity={0.7}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Object altitude curve */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="alt"
|
||||
stroke="var(--good)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: 'var(--amber)' }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
ComposedChart,
|
||||
Bar,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
|
||||
interface YearPoint {
|
||||
date: string;
|
||||
alt_at_midnight: number;
|
||||
transit_alt: number;
|
||||
usable_min: number;
|
||||
moon_illumination: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
points: YearPoint[];
|
||||
}
|
||||
|
||||
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)';
|
||||
if (alt >= 30) return '#2ab8a0';
|
||||
if (alt >= 15) return 'var(--warn)';
|
||||
return 'var(--muted)';
|
||||
}
|
||||
|
||||
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>
|
||||
<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' }}>
|
||||
{[
|
||||
{ color: 'var(--good)', label: '≥50° excellent' },
|
||||
{ color: '#2ab8a0', label: '30–50° good' },
|
||||
{ color: 'var(--warn)', label: '15–30° marginal' },
|
||||
{ color: 'var(--muted)', label: '<15° poor' },
|
||||
{ color: '#4d9de0', label: 'Moon %' },
|
||||
].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 }} />
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { api } from '../../api';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
interface Props {
|
||||
catalogId: string;
|
||||
}
|
||||
|
||||
export default function ImageUploadZone({ catalogId }: Props) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const handleFiles = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
try {
|
||||
await api.gallery.upload(catalogId, fd);
|
||||
qc.invalidateQueries({ queryKey: ['gallery', catalogId] });
|
||||
} catch (e) {
|
||||
setError(`Upload failed: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={e => { e.preventDefault(); handleFiles(e.dataTransfer.files); }}
|
||||
style={{
|
||||
border: '1px dashed var(--border-hi)',
|
||||
borderRadius: 4,
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text-lo)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 12,
|
||||
background: 'var(--bg-deep)',
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
>
|
||||
{uploading ? 'Uploading...' : 'Drop images here or click to upload (JPEG, PNG, TIFF — max 50MB)'}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,.tiff,.tif"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={e => handleFiles(e.target.files)}
|
||||
/>
|
||||
{error && (
|
||||
<div style={{ color: 'var(--danger)', fontSize: 11, marginTop: 6 }}>{error}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react';
|
||||
import type { GalleryImage } from '../../api/types';
|
||||
import { api } from '../../api';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
interface Props {
|
||||
images: GalleryImage[];
|
||||
catalogId: string;
|
||||
}
|
||||
|
||||
export default function LightboxView({ images, catalogId }: Props) {
|
||||
const [lightbox, setLightbox] = useState<GalleryImage | null>(null);
|
||||
const qc = useQueryClient();
|
||||
|
||||
if (images.length === 0) {
|
||||
return (
|
||||
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12, padding: '8px 0' }}>
|
||||
No images yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6 }}>
|
||||
{images.map(img => (
|
||||
<div
|
||||
key={img.id}
|
||||
onClick={() => setLightbox(img)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
background: 'var(--bg-deep)',
|
||||
aspectRatio: '1',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={img.url}
|
||||
alt={img.caption ?? img.filename}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{lightbox && (
|
||||
<div
|
||||
onClick={() => setLightbox(null)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.92)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div onClick={e => e.stopPropagation()} style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}>
|
||||
<img
|
||||
src={lightbox.url}
|
||||
alt={lightbox.caption ?? lightbox.filename}
|
||||
style={{ maxWidth: '100%', maxHeight: '85vh', borderRadius: 4 }}
|
||||
/>
|
||||
<div style={{ marginTop: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
{lightbox.caption && (
|
||||
<span style={{ color: 'var(--text-mid)', fontSize: 12, fontFamily: 'var(--font-sans)' }}>
|
||||
{lightbox.caption}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
api.gallery.delete(lightbox.id).then(() => {
|
||||
qc.invalidateQueries({ queryKey: ['gallery', catalogId] });
|
||||
setLightbox(null);
|
||||
});
|
||||
}}
|
||||
style={{ color: 'var(--danger)', fontSize: 12, marginLeft: 'auto' }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setLightbox(null)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -12,
|
||||
color: 'var(--text-hi)',
|
||||
fontSize: 20,
|
||||
background: 'var(--bg-panel)',
|
||||
borderRadius: '50%',
|
||||
width: 28,
|
||||
height: 28,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function PageShell({ children }: Props) {
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
||||
<Sidebar />
|
||||
<main style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
background: 'var(--bg-void)',
|
||||
padding: '24px 32px',
|
||||
}}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTonight } from '../../hooks/useTonight';
|
||||
import { useWeather, useForecast } from '../../hooks/useWeather';
|
||||
import MoonPhaseIcon from '../sky/MoonPhaseIcon';
|
||||
import GoNogo from '../weather/GoNogo';
|
||||
|
||||
const SEEING_LABELS: Record<number, string> = {
|
||||
1: '0.5″', 2: '0.75″', 3: '1.0″', 4: '1.25″',
|
||||
5: '1.5″', 6: '2.0″', 7: '2.5″', 8: '>3″',
|
||||
};
|
||||
const TRANSP_LABELS: Record<number, string> = {
|
||||
1: 'Excellent', 2: 'Good', 3: 'Good', 4: 'Average',
|
||||
5: 'Average', 6: 'Poor', 7: 'Poor', 8: 'Bad',
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ path: '/dashboard', label: 'Dashboard', icon: '⬡' },
|
||||
{ path: '/targets', label: 'Targets', icon: '✦' },
|
||||
{ path: '/calendar', label: 'Calendar', icon: '◫' },
|
||||
{ path: '/stats', label: 'Statistics', icon: '▤' },
|
||||
{ path: '/gallery', label: 'Gallery', icon: '⬚' },
|
||||
{ path: '/solar-system', label: 'Solar System', icon: '◉' },
|
||||
{ path: '/settings', label: 'Settings', icon: '⚙' },
|
||||
];
|
||||
|
||||
function fmtTime(utc?: string): string {
|
||||
if (!utc) return '—';
|
||||
return new Date(utc).toLocaleTimeString('fr-FR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Europe/Paris',
|
||||
});
|
||||
}
|
||||
|
||||
export default function Sidebar() {
|
||||
const { data: tonight } = useTonight();
|
||||
const { data: weather } = useWeather();
|
||||
const { data: forecast } = useForecast();
|
||||
|
||||
// First forecast slot = current/nearest 3-hour window
|
||||
const slot = (forecast as { dataseries?: { seeing?: number; transparency?: number; cloudcover?: number }[] })?.dataseries?.[0];
|
||||
|
||||
const darkStart = tonight?.true_dark_start_utc;
|
||||
const darkEnd = tonight?.true_dark_end_utc;
|
||||
const darkStr = darkStart && darkEnd
|
||||
? `${fmtTime(darkStart)}–${fmtTime(darkEnd)}`
|
||||
: '—';
|
||||
|
||||
const dewMargin = weather?.temp_c != null && weather?.dew_point_c != null
|
||||
? (weather.temp_c - weather.dew_point_c).toFixed(1)
|
||||
: null;
|
||||
|
||||
const seeingMap: Record<number, string> = {
|
||||
1: '0.5″', 2: '0.75″', 3: '1.0″', 4: '1.25″',
|
||||
5: '1.5″', 6: '2.0″', 7: '2.5″', 8: '>3″',
|
||||
};
|
||||
|
||||
return (
|
||||
<aside style={{
|
||||
width: 220,
|
||||
minWidth: 220,
|
||||
background: 'var(--bg-deep)',
|
||||
borderRight: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Logo */}
|
||||
<div style={{
|
||||
padding: '20px 20px 16px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-display)',
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
color: 'var(--amber)',
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
Astronome
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav style={{ padding: '8px 0', flex: 1, overflow: 'auto' }}>
|
||||
{navItems.map(item => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
style={({ isActive }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '9px 20px',
|
||||
color: isActive ? 'var(--text-hi)' : 'var(--text-mid)',
|
||||
background: isActive ? 'var(--bg-hover)' : 'transparent',
|
||||
borderLeft: `2px solid ${isActive ? 'var(--amber)' : 'transparent'}`,
|
||||
fontFamily: 'var(--font-display)',
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? 700 : 400,
|
||||
letterSpacing: '0.05em',
|
||||
textDecoration: 'none',
|
||||
transition: 'color 0.1s',
|
||||
})}
|
||||
>
|
||||
<span style={{ fontSize: 14, opacity: 0.7 }}>{item.icon}</span>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Tonight widget */}
|
||||
<div style={{
|
||||
borderTop: '1px solid var(--border)',
|
||||
padding: '12px 16px',
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--text-lo)',
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
Tonight
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
{[
|
||||
['Dusk', fmtTime(tonight?.astro_dusk_utc)],
|
||||
['Dawn', fmtTime(tonight?.astro_dawn_utc)],
|
||||
['Dark', darkStr],
|
||||
].map(([label, value]) => (
|
||||
<tr key={label}>
|
||||
<td style={{ color: 'var(--text-lo)', fontSize: 11, fontFamily: 'var(--font-mono)', paddingBottom: 3 }}>{label}</td>
|
||||
<td style={{ color: 'var(--text-mid)', fontSize: 11, fontFamily: 'var(--font-mono)', textAlign: 'right' }}>{value}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td style={{ color: 'var(--text-lo)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>Moon</td>
|
||||
<td style={{ textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 4 }}>
|
||||
<span style={{ color: 'var(--text-mid)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>
|
||||
{tonight?.moon_illumination != null
|
||||
? `${Math.round(tonight.moon_illumination * 100)}%`
|
||||
: '—'}
|
||||
</span>
|
||||
{tonight?.moon_illumination != null && (
|
||||
<MoonPhaseIcon illumination={tonight.moon_illumination} size={14} />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Conditions widget */}
|
||||
<div style={{
|
||||
borderTop: '1px solid var(--border)',
|
||||
padding: '12px 16px',
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--text-lo)',
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
Conditions
|
||||
</div>
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<GoNogo status={weather?.go_nogo} compact />
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
{[
|
||||
['Temp', weather?.temp_c != null ? `${weather.temp_c.toFixed(1)}°C` : '—'],
|
||||
['Dew Δ', dewMargin ? `${dewMargin}°C ${parseFloat(dewMargin) < 4 ? '⚠' : '✓'}` : '—'],
|
||||
['Seeing', slot?.seeing ? SEEING_LABELS[slot.seeing] ?? '—' : '—'],
|
||||
['Transp', slot?.transparency ? TRANSP_LABELS[slot.transparency] ?? '—' : '—'],
|
||||
].map(([label, value]) => (
|
||||
<tr key={label}>
|
||||
<td style={{ color: 'var(--text-lo)', fontSize: 11, fontFamily: 'var(--font-mono)', paddingBottom: 3 }}>{label}</td>
|
||||
<td style={{
|
||||
color: label === 'Dew Δ' && dewMargin && parseFloat(dewMargin) < 4
|
||||
? 'var(--danger)'
|
||||
: 'var(--text-mid)',
|
||||
fontSize: 11,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
textAlign: 'right',
|
||||
}}>{value as string}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useState } from 'react';
|
||||
import { useCreateLog } from '../../hooks/useLog';
|
||||
import { api } from '../../api';
|
||||
|
||||
interface Props {
|
||||
catalogId: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export default function LogForm({ catalogId, onSuccess }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [filterId, setFilterId] = useState('sv220');
|
||||
const [duration, setDuration] = useState('');
|
||||
const [quality, setQuality] = useState<string>('pending');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [phd2File, setPhd2File] = useState<File | null>(null);
|
||||
const [phd2Uploading, setPhd2Uploading] = useState(false);
|
||||
const [phd2Result, setPhd2Result] = useState<{ rms_total?: number; rms_ra?: number; rms_dec?: number } | null>(null);
|
||||
const createLog = useCreateLog();
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
style={{
|
||||
background: 'var(--amber)',
|
||||
color: '#000',
|
||||
borderRadius: 4,
|
||||
padding: '6px 14px',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
+ Add Session
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!duration) return;
|
||||
let phd2LogId: number | undefined;
|
||||
|
||||
// Upload PHD2 log first if provided
|
||||
if (phd2File) {
|
||||
setPhd2Uploading(true);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', phd2File);
|
||||
const result = await api.phd2.upload(form);
|
||||
phd2LogId = result.id;
|
||||
const analysis = result.analysis as { rms_total?: number; rms_ra?: number; rms_dec?: number };
|
||||
setPhd2Result(analysis);
|
||||
} catch {
|
||||
// PHD2 upload failed — proceed without it
|
||||
}
|
||||
setPhd2Uploading(false);
|
||||
}
|
||||
|
||||
createLog.mutate({
|
||||
catalog_id: catalogId,
|
||||
session_date: date,
|
||||
filter_id: filterId,
|
||||
integration_min: parseInt(duration),
|
||||
quality: quality as 'keeper' | 'needs_more' | 'rejected' | 'pending',
|
||||
notes: notes || undefined,
|
||||
guiding_rms: phd2Result?.rms_total,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setExpanded(false);
|
||||
setDuration('');
|
||||
setNotes('');
|
||||
setPhd2File(null);
|
||||
setPhd2Result(null);
|
||||
onSuccess?.();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'var(--bg-deep)',
|
||||
border: '1px solid var(--border-hi)',
|
||||
borderRadius: 4,
|
||||
padding: 12,
|
||||
marginBottom: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<label style={{ fontSize: 10, color: 'var(--text-lo)', fontFamily: 'var(--font-mono)' }}>Date</label>
|
||||
<input type="date" value={date} onChange={e => setDate(e.target.value)} style={{ fontSize: 12 }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<label style={{ fontSize: 10, color: 'var(--text-lo)', fontFamily: 'var(--font-mono)' }}>Filter</label>
|
||||
<select value={filterId} onChange={e => setFilterId(e.target.value)} style={{ fontSize: 12 }}>
|
||||
<option value="sv220">HaOIII (SV220)</option>
|
||||
<option value="c2">SIIOIII (C2)</option>
|
||||
<option value="sv260">LP (SV260)</option>
|
||||
<option value="uvir">UV/IR Cut</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<label style={{ fontSize: 10, color: 'var(--text-lo)', fontFamily: 'var(--font-mono)' }}>Duration (min)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={duration}
|
||||
onChange={e => setDuration(e.target.value)}
|
||||
min={1}
|
||||
placeholder="120"
|
||||
style={{ fontSize: 12, width: 80 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<label style={{ fontSize: 10, color: 'var(--text-lo)', fontFamily: 'var(--font-mono)' }}>Quality</label>
|
||||
<select value={quality} onChange={e => setQuality(e.target.value)} style={{ fontSize: 12 }}>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="keeper">Keeper</option>
|
||||
<option value="needs_more">Needs More</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
placeholder="Notes (optional)..."
|
||||
rows={2}
|
||||
style={{ fontSize: 12, resize: 'vertical' }}
|
||||
/>
|
||||
{/* PHD2 log upload */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<label style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '4px 10px', background: 'var(--bg-panel)', border: '1px solid var(--border)',
|
||||
borderRadius: 3, cursor: 'pointer', fontFamily: 'var(--font-mono)', fontSize: 11,
|
||||
color: phd2File ? 'var(--good)' : 'var(--text-lo)',
|
||||
}}>
|
||||
⟳ {phd2File ? phd2File.name : 'Attach PHD2 log (optional)'}
|
||||
<input
|
||||
type="file"
|
||||
accept=".log,.txt,.csv"
|
||||
style={{ display: 'none' }}
|
||||
onChange={e => setPhd2File(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</label>
|
||||
{phd2File && (
|
||||
<button onClick={() => setPhd2File(null)} style={{ color: 'var(--text-lo)', fontSize: 11 }}>✕</button>
|
||||
)}
|
||||
{phd2Result && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--good)' }}>
|
||||
RMS: {phd2Result.rms_total?.toFixed(2)}″
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!duration || createLog.isPending || phd2Uploading}
|
||||
style={{
|
||||
background: 'var(--amber)',
|
||||
color: '#000',
|
||||
borderRadius: 3,
|
||||
padding: '5px 14px',
|
||||
fontSize: 12,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontWeight: 700,
|
||||
opacity: !duration ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{phd2Uploading ? 'Uploading PHD2…' : createLog.isPending ? 'Saving...' : 'Save Session'}
|
||||
</button>
|
||||
<button onClick={() => setExpanded(false)} style={{ color: 'var(--text-mid)', fontSize: 12 }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
interface Props {
|
||||
quality: string;
|
||||
}
|
||||
|
||||
const config: Record<string, { icon: string; label: string }> = {
|
||||
keeper: { icon: '✓', label: 'Keeper' },
|
||||
needs_more: { icon: '→', label: 'Needs More' },
|
||||
rejected: { icon: '✗', label: 'Rejected' },
|
||||
pending: { icon: '·', label: 'Pending' },
|
||||
};
|
||||
|
||||
export default function QualityFlag({ quality }: Props) {
|
||||
const cfg = config[quality] ?? config.pending;
|
||||
return (
|
||||
<span className={`quality-chip ${quality}`}>
|
||||
{cfg.icon} {cfg.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { LogEntry } from '../../api/types';
|
||||
import QualityFlag from './QualityFlag';
|
||||
import { useDeleteLog, useUpdateLog } from '../../hooks/useLog';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
entries: LogEntry[];
|
||||
totalMin?: number;
|
||||
}
|
||||
|
||||
export default function SessionList({ entries, totalMin }: Props) {
|
||||
const deleteLog = useDeleteLog();
|
||||
const updateLog = useUpdateLog();
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editQuality, setEditQuality] = useState('');
|
||||
const [editNotes, setEditNotes] = useState('');
|
||||
|
||||
const hours = totalMin ? Math.floor(totalMin / 60) : 0;
|
||||
const mins = totalMin ? totalMin % 60 : 0;
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12, padding: '12px 0' }}>
|
||||
No sessions logged yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ color: 'var(--text-mid)', fontSize: 12, fontFamily: 'var(--font-mono)', marginBottom: 10 }}>
|
||||
{entries.length} session{entries.length !== 1 ? 's' : ''} · {hours}h {mins}m total
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{entries.map(entry => (
|
||||
<div key={entry.id} style={{
|
||||
background: 'var(--bg-deep)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 4,
|
||||
padding: '8px 12px',
|
||||
}}>
|
||||
{editingId === entry.id ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<select
|
||||
value={editQuality}
|
||||
onChange={e => setEditQuality(e.target.value)}
|
||||
style={{ fontSize: 12, padding: '4px 8px' }}
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="keeper">Keeper</option>
|
||||
<option value="needs_more">Needs More</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
<textarea
|
||||
value={editNotes}
|
||||
onChange={e => setEditNotes(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Notes..."
|
||||
style={{ fontSize: 12, resize: 'vertical' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
updateLog.mutate({ id: entry.id, data: { quality: editQuality as LogEntry['quality'], notes: editNotes } });
|
||||
setEditingId(null);
|
||||
}}
|
||||
style={{
|
||||
background: 'var(--amber)', color: '#000', borderRadius: 3,
|
||||
padding: '3px 10px', fontSize: 11, fontFamily: 'var(--font-mono)',
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button onClick={() => setEditingId(null)} style={{ color: 'var(--text-mid)', fontSize: 11 }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', minWidth: 80 }}>
|
||||
{entry.session_date}
|
||||
</span>
|
||||
<span className={`filter-pill ${entry.filter_id}`}>{entry.filter_id.toUpperCase()}</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-hi)' }}>
|
||||
{entry.integration_min}min
|
||||
</span>
|
||||
<QualityFlag quality={entry.quality} />
|
||||
{entry.guiding_rms != null && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)' }}>
|
||||
RMS {entry.guiding_rms.toFixed(2)}″
|
||||
</span>
|
||||
)}
|
||||
{entry.notes && (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-mid)', flex: 1 }}>
|
||||
{entry.notes}
|
||||
</span>
|
||||
)}
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingId(entry.id);
|
||||
setEditQuality(entry.quality);
|
||||
setEditNotes(entry.notes ?? '');
|
||||
}}
|
||||
style={{ color: 'var(--text-lo)', fontSize: 11, padding: '2px 6px' }}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteLog.mutate(entry.id)}
|
||||
style={{ color: 'var(--danger)', fontSize: 11, padding: '2px 6px' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { api } from '../../api';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
interface Props {
|
||||
onUploaded?: (id: number) => void;
|
||||
}
|
||||
|
||||
export default function PHD2UploadZone({ onUploaded }: Props) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [duplicate, setDuplicate] = useState<{ id: number; message: string } | null>(null);
|
||||
const [result, setResult] = useState<{
|
||||
rms_total: number;
|
||||
rms_ra: number;
|
||||
rms_dec: number;
|
||||
duration_min?: number;
|
||||
camera_name?: string;
|
||||
exposure_ms?: number;
|
||||
mount_name?: string;
|
||||
session_date?: string;
|
||||
} | null>(null);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
setDuplicate(null);
|
||||
setResult(null);
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
|
||||
try {
|
||||
const res = await api.phd2.upload(fd);
|
||||
|
||||
if (res.duplicate) {
|
||||
setDuplicate({
|
||||
id: res.duplicate_id || 0,
|
||||
message: res.message || `Duplicate session detected (ID: ${res.duplicate_id})`
|
||||
});
|
||||
setResult(null);
|
||||
} else {
|
||||
const analysis = res.analysis as any;
|
||||
setResult({
|
||||
rms_total: analysis.rms_total_arcsec,
|
||||
rms_ra: analysis.rms_ra_arcsec,
|
||||
rms_dec: analysis.rms_dec_arcsec,
|
||||
duration_min: analysis.duration_min,
|
||||
camera_name: analysis.camera_name,
|
||||
exposure_ms: analysis.exposure_ms,
|
||||
mount_name: analysis.mount_name,
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ['phd2'] });
|
||||
onUploaded?.(res.id);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(`Parse failed: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
||||
}
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onClick={() => inputRef.current?.click()}
|
||||
style={{
|
||||
border: '1px dashed var(--border)',
|
||||
borderRadius: 3,
|
||||
padding: '10px 14px',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text-lo)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 11,
|
||||
background: 'var(--bg-deep)',
|
||||
}}
|
||||
>
|
||||
{uploading ? 'Parsing PHD2 log...' : '↑ Upload PHD2 log (.log)'}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".log,.csv"
|
||||
style={{ display: 'none' }}
|
||||
onChange={e => e.target.files?.[0] && handleFile(e.target.files[0])}
|
||||
/>
|
||||
{error && <div style={{ color: 'var(--danger)', fontSize: 11, marginTop: 4 }}>{error}</div>}
|
||||
{duplicate && (
|
||||
<div style={{ color: 'var(--warn)', fontSize: 11, marginTop: 4, fontFamily: 'var(--font-mono)' }}>
|
||||
⚠ {duplicate.message}
|
||||
</div>
|
||||
)}
|
||||
{result && (
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--good)', marginTop: 4, lineHeight: '1.5' }}>
|
||||
<div>✓ RMS Total: {result.rms_total.toFixed(2)}″ (RA: {result.rms_ra.toFixed(2)}″ Dec: {result.rms_dec.toFixed(2)}″)</div>
|
||||
{result.session_date && (
|
||||
<div style={{ color: 'var(--text-mid)', marginTop: 4 }}>Date: {result.session_date}</div>
|
||||
)}
|
||||
{result.duration_min !== undefined && (
|
||||
<div style={{ color: 'var(--text-mid)', marginTop: result.session_date ? 2 : 6 }}>Duration: {result.duration_min}m</div>
|
||||
)}
|
||||
{(result.camera_name || result.mount_name) && (
|
||||
<div style={{ color: 'var(--text-lo)', marginTop: 4 }}>
|
||||
{result.camera_name && <div>Camera: {result.camera_name}</div>}
|
||||
{result.mount_name && <div>Mount: {result.mount_name}</div>}
|
||||
{result.exposure_ms && <div>Exposure: {result.exposure_ms}ms</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useRef, useId } from 'react';
|
||||
import A from 'aladin-lite';
|
||||
|
||||
interface Props {
|
||||
ra: number;
|
||||
dec: number;
|
||||
sizeArcmin?: number;
|
||||
fovW?: number;
|
||||
fovH?: number;
|
||||
mosaic?: { panels_w: number; panels_h: number };
|
||||
}
|
||||
|
||||
export default function AladinEmbed({ ra, dec, fovW = 2.75, fovH = 1.84, mosaic }: Props) {
|
||||
const uid = useId().replace(/:/g, '');
|
||||
const containerId = `aladin-${uid}`;
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (initializedRef.current) return;
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
await A.init;
|
||||
initializedRef.current = true;
|
||||
|
||||
const aladin = A.aladin(`#${containerId}`, {
|
||||
survey: 'CDS/P/DSS2/color',
|
||||
fov: Math.max(fovW, fovH) * 3.5,
|
||||
target: `${ra} ${dec}`,
|
||||
showReticle: false,
|
||||
showZoomControl: false,
|
||||
showFullscreenControl: false,
|
||||
showLayersControl: false,
|
||||
showGotoControl: false,
|
||||
showShareControl: false,
|
||||
showStatusBar: false,
|
||||
cooFrame: 'J2000',
|
||||
showCooGrid: false,
|
||||
});
|
||||
|
||||
const overlay = A.graphicOverlay({ color: '#e8832a', lineWidth: 2 });
|
||||
aladin.addOverlay(overlay);
|
||||
|
||||
const halfW = fovW / 2;
|
||||
const halfH = fovH / 2;
|
||||
const decRad = (dec * Math.PI) / 180;
|
||||
const cosD = Math.max(Math.cos(decRad), 0.01);
|
||||
const panels_w = mosaic?.panels_w ?? 1;
|
||||
const panels_h = mosaic?.panels_h ?? 1;
|
||||
const isMultiPanel = panels_w > 1 || panels_h > 1;
|
||||
|
||||
for (let pw = 0; pw < panels_w; pw++) {
|
||||
for (let ph = 0; ph < panels_h; ph++) {
|
||||
const panelRa = ra + ((pw - (panels_w - 1) / 2) * fovW) / cosD;
|
||||
const panelDec = dec + (ph - (panels_h - 1) / 2) * fovH;
|
||||
const corners: [number, number][] = [
|
||||
[panelRa - halfW / cosD, panelDec - halfH],
|
||||
[panelRa + halfW / cosD, panelDec - halfH],
|
||||
[panelRa + halfW / cosD, panelDec + halfH],
|
||||
[panelRa - halfW / cosD, panelDec + halfH],
|
||||
[panelRa - halfW / cosD, panelDec - halfH],
|
||||
];
|
||||
const lineColor = isMultiPanel ? '#e8c030' : '#e8832a';
|
||||
overlay.add(A.polyline(corners, { color: lineColor, lineWidth: 1.5 }));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Aladin init error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={containerId}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 280,
|
||||
background: '#000',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
interface Props {
|
||||
illumination: number; // 0.0–1.0
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export default function MoonPhaseIcon({ illumination, size = 24 }: Props) {
|
||||
// Draw a crescent / disk based on illumination
|
||||
const r = size / 2 - 1;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const pct = illumination;
|
||||
|
||||
// Waxing: 0-0.5 → crescent, 0.5-1 → gibbous
|
||||
const d = (() => {
|
||||
if (pct < 0.01) {
|
||||
// New moon — just a circle outline
|
||||
return `M ${cx} ${cy - r} A ${r} ${r} 0 1 1 ${cx} ${cy + r} A ${r} ${r} 0 1 1 ${cx} ${cy - r}`;
|
||||
}
|
||||
if (pct > 0.99) {
|
||||
// Full moon — filled circle
|
||||
return `M ${cx} ${cy - r} A ${r} ${r} 0 1 1 ${cx} ${cy + r} A ${r} ${r} 0 1 1 ${cx} ${cy - r}`;
|
||||
}
|
||||
// Lit fraction → x offset of inner terminator ellipse
|
||||
const x_offset = r * Math.abs(2 * pct - 1);
|
||||
const waxing = pct <= 0.5;
|
||||
|
||||
if (waxing) {
|
||||
// Crescent: right side lit
|
||||
return `M ${cx} ${cy - r}
|
||||
A ${r} ${r} 0 1 1 ${cx} ${cy + r}
|
||||
A ${x_offset} ${r} 0 1 0 ${cx} ${cy - r}`;
|
||||
} else {
|
||||
// Gibbous: mostly lit, small dark crescent on left
|
||||
return `M ${cx} ${cy - r}
|
||||
A ${r} ${r} 0 1 1 ${cx} ${cy + r}
|
||||
A ${x_offset} ${r} 0 1 1 ${cx} ${cy - r}`;
|
||||
}
|
||||
})();
|
||||
|
||||
const isFull = pct > 0.99;
|
||||
const isNew = pct < 0.01;
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
{isNew ? (
|
||||
<circle cx={cx} cy={cy} r={r} fill="none" stroke="var(--text-mid)" strokeWidth={1} />
|
||||
) : isFull ? (
|
||||
<circle cx={cx} cy={cy} r={r} fill="var(--warn)" />
|
||||
) : (
|
||||
<path d={d} fill="var(--warn)" />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { useState } from 'react';
|
||||
import type { Target, Workflow } from '../../api/types';
|
||||
import { useTargetCurve, useTargetFilters, useTargetVisibility, useTargetWorkflow, useTargetYearly } from '../../hooks/useTargets';
|
||||
import { useTargetLog } from '../../hooks/useLog';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../../api';
|
||||
import AltitudeCurve from '../charts/AltitudeCurve';
|
||||
import YearlyVisibility from '../charts/YearlyVisibility';
|
||||
import AladinEmbed from '../sky/AladinEmbed';
|
||||
import LogForm from '../log/LogForm';
|
||||
import SessionList from '../log/SessionList';
|
||||
import ImageUploadZone from '../gallery/ImageUploadZone';
|
||||
import LightboxView from '../gallery/LightboxView';
|
||||
import { useTonight } from '../../hooks/useTonight';
|
||||
import { useHorizon } from '../../hooks/useHorizon';
|
||||
|
||||
interface Props {
|
||||
target: Target;
|
||||
}
|
||||
|
||||
const TABS = ['Tonight', 'Target', 'Filters & Workflow', 'Log & Gallery', 'Yearly'];
|
||||
|
||||
const WORKFLOW_SHORT: Record<string, string> = {
|
||||
'HA+OIII Dual Narrowband (SV220)': 'HaOIII',
|
||||
'SII+OIII Dual Narrowband (Askar C2)': 'SHO',
|
||||
'Cluster Broadband': 'Broadband Cluster',
|
||||
'Broadband OSC': 'Broadband OSC',
|
||||
};
|
||||
|
||||
function WorkflowCard({ workflow }: { workflow: Workflow }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const shortName = WORKFLOW_SHORT[workflow.name] ?? workflow.name;
|
||||
return (
|
||||
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
|
||||
<div
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px',
|
||||
cursor: 'pointer', borderBottom: expanded ? '1px solid var(--border)' : 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
background: 'var(--amber-glow)', border: '1px solid var(--amber-dim)',
|
||||
color: 'var(--amber)', fontFamily: 'var(--font-mono)', fontSize: 11,
|
||||
fontWeight: 700, padding: '2px 8px', borderRadius: 3, letterSpacing: '0.05em',
|
||||
}}>
|
||||
{shortName}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'var(--font-sans)', fontSize: 12, color: 'var(--text-mid)', flex: 1 }}>
|
||||
{workflow.name}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-lo)', fontSize: 11 }}>{expanded ? '▲' : '▼'} details</span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div style={{ padding: '10px 14px' }}>
|
||||
<ol style={{ paddingLeft: 18, marginBottom: 10 }}>
|
||||
{workflow.steps.map((step, i) => (
|
||||
<li key={i} style={{ color: 'var(--text-hi)', fontSize: 12, fontFamily: 'var(--font-sans)', marginBottom: 3 }}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
{workflow.notes && (
|
||||
<p style={{ fontSize: 11, color: 'var(--text-mid)', fontStyle: 'italic', marginTop: 6 }}>{workflow.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtTime(utc?: string): string {
|
||||
if (!utc) return '—';
|
||||
return new Date(utc).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
|
||||
}
|
||||
|
||||
export default function DetailDrawer({ target }: Props) {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [selectedFilter, setSelectedFilter] = useState('sv220');
|
||||
const [notes, setNotes] = useState<string | null>(null);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: tonight } = useTonight();
|
||||
const { data: visData } = useTargetVisibility(target.id);
|
||||
const { data: curveData } = useTargetCurve(target.id);
|
||||
const { data: filtersData } = useTargetFilters(target.id);
|
||||
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: galleryData } = useQuery({
|
||||
queryKey: ['gallery', target.id],
|
||||
queryFn: () => api.gallery.list(target.id),
|
||||
enabled: tab === 3,
|
||||
});
|
||||
const { data: notesData } = useQuery({
|
||||
queryKey: ['target-notes', target.id],
|
||||
queryFn: () => api.targets.getNotes(target.id),
|
||||
enabled: tab === 3,
|
||||
});
|
||||
const saveNotesMutation = useMutation({
|
||||
mutationFn: (text: string) => api.targets.putNotes(target.id, text),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['target-notes', target.id] }),
|
||||
});
|
||||
|
||||
const dssUrl = `https://archive.stsci.edu/cgi-bin/dss_search?v=poss2ukstu_red&r=${target.ra_deg}&d=${target.dec_deg}&e=J2000&h=${target.size_arcmin_maj ?? 15}&w=${target.size_arcmin_maj ?? 15}&f=gif`;
|
||||
|
||||
return (
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border-hi)', borderRadius: 4, marginTop: 2 }}>
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--border)' }}>
|
||||
{TABS.map((t, i) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(i)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 11,
|
||||
color: tab === i ? 'var(--amber)' : 'var(--text-mid)',
|
||||
borderBottom: tab === i ? '2px solid var(--amber)' : '2px solid transparent',
|
||||
background: 'none',
|
||||
letterSpacing: '0.05em',
|
||||
transition: 'color 0.1s',
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '16px 20px' }}>
|
||||
{/* Tab 1: Tonight */}
|
||||
{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…
|
||||
</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
|
||||
</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;
|
||||
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',
|
||||
}}>
|
||||
<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>
|
||||
);
|
||||
})()}
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 3: Filters & Workflow */}
|
||||
{tab === 2 && (
|
||||
<div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: 20 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{['Filter', 'Suitability', 'Reason', 'Sub exp', 'Frames', 'Total time', 'Sessions'].map(h => (
|
||||
<th key={h} style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 10, textAlign: 'left', paddingBottom: 6, fontWeight: 500, letterSpacing: '0.06em', borderBottom: '1px solid var(--border)' }}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtersData?.recommendations?.map(rec => (
|
||||
<tr key={rec.filter_id} style={{ borderBottom: '1px solid var(--border)', opacity: rec.suitability === 'unsuitable' ? 0.4 : 1 }}>
|
||||
<td style={{ padding: '6px 8px 6px 0' }}>
|
||||
<button
|
||||
onClick={() => setSelectedFilter(rec.filter_id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<span className={`filter-pill ${rec.filter_id}`}>{rec.filter_id.toUpperCase()}</span>
|
||||
</button>
|
||||
</td>
|
||||
<td style={{ fontSize: 11, fontFamily: 'var(--font-mono)', padding: '6px 8px',
|
||||
color: rec.suitability === 'ideal' ? 'var(--good)' : rec.suitability === 'good' ? 'var(--teal)' : rec.suitability === 'marginal' ? 'var(--warn)' : 'var(--muted)'
|
||||
}}>
|
||||
{rec.suitability}
|
||||
</td>
|
||||
<td style={{ fontSize: 11, color: 'var(--text-mid)', padding: '6px 8px' }}>
|
||||
{rec.reason}
|
||||
{rec.warning && <div style={{ color: 'var(--warn)', fontSize: 10 }}>⚠ {rec.warning}</div>}
|
||||
</td>
|
||||
<td style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text-mid)', padding: '6px 8px' }}>
|
||||
{rec.exposure_sec ? `${rec.exposure_sec / 60}min` : '—'}
|
||||
</td>
|
||||
<td style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text-hi)', padding: '6px 8px' }}>
|
||||
{rec.frames_needed ?? '—'}
|
||||
</td>
|
||||
<td style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--amber)', padding: '6px 8px' }}>
|
||||
{rec.est_integration_hours ? `${rec.est_integration_hours}h` : '—'}
|
||||
</td>
|
||||
<td style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text-mid)', padding: '6px 8px' }}>
|
||||
{rec.sessions_needed ? `×${rec.sessions_needed}` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{workflowData && <WorkflowCard workflow={workflowData} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 5: Yearly */}
|
||||
{tab === 4 && (
|
||||
<div>
|
||||
{yearlyData?.points ? (
|
||||
<YearlyVisibility points={yearlyData.points} />
|
||||
) : (
|
||||
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||
Loading yearly data…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 4: Log & Gallery */}
|
||||
{tab === 3 && (
|
||||
<div>
|
||||
{/* Filter breakdown + planning notes row */}
|
||||
{((logData?.filter_breakdown && logData.filter_breakdown.length > 0) || true) && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
|
||||
{/* Filter accumulation */}
|
||||
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 4, padding: '10px 12px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 8 }}>
|
||||
Integration by Filter (keepers only)
|
||||
</div>
|
||||
{(logData?.filter_breakdown ?? []).length === 0 ? (
|
||||
<div style={{ color: 'var(--text-lo)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>No keeper sessions yet</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{(logData?.filter_breakdown ?? []).map(fb => (
|
||||
<div key={fb.filter_id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className={`filter-pill ${fb.filter_id}`} style={{ minWidth: 60 }}>
|
||||
{fb.filter_id.toUpperCase()}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--amber)', fontWeight: 700 }}>
|
||||
{(fb.total_min / 60).toFixed(1)}h
|
||||
</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
|
||||
× {fb.sessions} session{fb.sessions !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Planning notes */}
|
||||
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 4, padding: '10px 12px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 8 }}>
|
||||
Planning Notes
|
||||
</div>
|
||||
<textarea
|
||||
value={notes ?? notesData?.notes ?? ''}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
onBlur={e => { if (notes !== null) saveNotesMutation.mutate(e.target.value); }}
|
||||
placeholder="Field notes, guide star position, framing tips…"
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: 72,
|
||||
background: 'var(--bg-panel)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 3,
|
||||
color: 'var(--text-hi)',
|
||||
fontFamily: 'var(--font-sans)',
|
||||
fontSize: 12,
|
||||
padding: '6px 8px',
|
||||
resize: 'vertical',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
{saveNotesMutation.isSuccess && (
|
||||
<div style={{ fontSize: 10, color: 'var(--good)', fontFamily: 'var(--font-mono)', marginTop: 2 }}>✓ saved</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main log + gallery */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 }}>
|
||||
<div>
|
||||
<LogForm catalogId={target.id} />
|
||||
<SessionList
|
||||
entries={logData?.items ?? []}
|
||||
totalMin={logData?.total_integration_min}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<ImageUploadZone catalogId={target.id} />
|
||||
</div>
|
||||
<LightboxView images={galleryData?.items ?? []} catalogId={target.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { Target } from '../../api/types';
|
||||
import TypeBadge from './TypeBadge';
|
||||
import VisBar from './VisBar';
|
||||
import { useTonight } from '../../hooks/useTonight';
|
||||
import { useHorizon } from '../../hooks/useHorizon';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../../api';
|
||||
|
||||
interface Props {
|
||||
target: Target;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
// Display labels for filter IDs
|
||||
const FILTER_LABELS: Record<string, string> = {
|
||||
sv220: 'HaOIII',
|
||||
c2: 'SII/OIII',
|
||||
sv260: 'LP',
|
||||
uvir: 'UV/IR',
|
||||
};
|
||||
|
||||
// Recommended integration hours per object type and filter (from CLAUDE.md §16.3)
|
||||
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 },
|
||||
};
|
||||
|
||||
function getGoalMin(obj_type: string, recommended_filter?: string): number | null {
|
||||
const byType = GOAL_HOURS[obj_type];
|
||||
if (!byType) return null;
|
||||
const filter = recommended_filter ?? Object.keys(byType)[0];
|
||||
const h = byType[filter] ?? Object.values(byType)[0];
|
||||
return h ? h * 60 : null;
|
||||
}
|
||||
|
||||
function IntegrationProgress({ obj_type, recommended_filter, total_min }: {
|
||||
obj_type: string; recommended_filter?: string; total_min?: number;
|
||||
}) {
|
||||
const goalMin = getGoalMin(obj_type, recommended_filter);
|
||||
if (!goalMin || total_min == null) return null;
|
||||
const pct = Math.min((total_min / goalMin) * 100, 100);
|
||||
const color = pct >= 100 ? 'var(--good)' : pct >= 60 ? 'var(--warn)' : 'var(--danger)';
|
||||
return (
|
||||
<div style={{ width: 60 }}>
|
||||
<div style={{ height: 4, background: 'var(--bg-hover)', borderRadius: 2, overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: color, borderRadius: 2, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: pct >= 100 ? color : 'var(--text-lo)', marginTop: 2, textAlign: 'right' }}>
|
||||
{pct >= 100 ? '✓' : `${Math.round(pct)}%`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtAlt(alt?: number): { text: string; color: string } {
|
||||
if (alt == null) return { text: '—', color: 'var(--text-lo)' };
|
||||
const color = alt >= 30 ? 'var(--good)' : alt >= 15 ? 'var(--warn)' : 'var(--danger)';
|
||||
return { text: `${alt.toFixed(0)}°`, color };
|
||||
}
|
||||
|
||||
function fmtIntegration(min?: number): string {
|
||||
if (!min) return '—';
|
||||
if (min < 60) return `${min}m`;
|
||||
const h = Math.floor(min / 60);
|
||||
const m = min % 60;
|
||||
return m > 0 ? `${h}h${m}m` : `${h}h`;
|
||||
}
|
||||
|
||||
function difficultyDots(d?: number) {
|
||||
if (!d) return null;
|
||||
return (
|
||||
<span style={{ letterSpacing: 2 }}>
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<span key={i} style={{ color: i < d ? 'var(--amber)' : 'var(--muted)', fontSize: 8 }}>●</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TargetRow({ target, expanded, onToggle }: Props) {
|
||||
const { data: tonight } = useTonight();
|
||||
const { data: horizonData } = useHorizon();
|
||||
const alt = fmtAlt(target.max_alt_deg);
|
||||
|
||||
// Fetch visibility curve to check if target is ever above custom horizon
|
||||
const { data: curveData } = useQuery({
|
||||
queryKey: ['curve', target.id],
|
||||
queryFn: () => api.targets.curve(target.id),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled: !target.is_visible_tonight ? false : true, // Only fetch if potentially visible
|
||||
});
|
||||
|
||||
// Check if visible above custom horizon by examining the curve
|
||||
let invisible = !target.is_visible_tonight;
|
||||
if (!invisible && horizonData?.points?.length) {
|
||||
// If we have horizon data and a curve, check if any point is above custom horizon
|
||||
const hasPointAboveHorizon = curveData?.curve?.some(pt => pt.above_custom_horizon);
|
||||
if (curveData && !hasPointAboveHorizon) {
|
||||
invisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
const filterLabel = target.recommended_filter ? (FILTER_LABELS[target.recommended_filter] ?? target.recommended_filter.toUpperCase()) : null;
|
||||
|
||||
return (
|
||||
<tr
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: expanded ? 'var(--bg-hover)' : 'var(--bg-row)',
|
||||
opacity: invisible ? 0.35 : 1,
|
||||
fontStyle: invisible ? 'italic' : 'normal',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '7px 8px 7px 12px', width: 44 }}>
|
||||
<TypeBadge type={target.obj_type} />
|
||||
</td>
|
||||
<td style={{ padding: '7px 8px', minWidth: 160 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||
{target.messier_num != null && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--amber)', fontWeight: 700 }}>
|
||||
M{target.messier_num}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: target.messier_num != null ? 'var(--text-mid)' : 'var(--text-hi)' }}>
|
||||
{target.name}
|
||||
</span>
|
||||
</div>
|
||||
{target.common_name && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-mid)' }}>
|
||||
{target.common_name}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '7px 8px', width: 80, fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)' }}>
|
||||
{target.size_arcmin_maj
|
||||
? `${target.size_arcmin_maj.toFixed(1)}′`
|
||||
: '—'}
|
||||
</td>
|
||||
<td style={{ padding: '7px 8px', width: 50 }}>
|
||||
{target.fov_fill_pct != null && (
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 11,
|
||||
color: target.fov_fill_pct > 80 ? 'var(--good)' : target.fov_fill_pct > 40 ? 'var(--amber)' : 'var(--muted)',
|
||||
}}>
|
||||
{target.fov_fill_pct.toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '7px 8px', width: 50 }}>
|
||||
{target.mosaic_flag && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--warn)' }}>
|
||||
{target.mosaic_panels_w}×{target.mosaic_panels_h}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '7px 8px', width: 42, fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)' }}>
|
||||
{target.mag_v?.toFixed(1) ?? '—'}
|
||||
</td>
|
||||
<td style={{ padding: '7px 8px', width: 32 }}>
|
||||
{difficultyDots(target.difficulty)}
|
||||
</td>
|
||||
<td style={{ padding: '7px 8px', width: 70 }}>
|
||||
{filterLabel && (
|
||||
<span className={`filter-pill ${target.recommended_filter}`}>
|
||||
{filterLabel}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '7px 8px', width: 60 }}>
|
||||
{target.max_alt_deg == null ? (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>not tonight</span>
|
||||
) : (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: alt.color, fontWeight: 600 }}>
|
||||
{alt.text}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '7px 8px', width: 88 }}>
|
||||
{tonight?.astro_dusk_utc && tonight?.astro_dawn_utc && (
|
||||
<VisBar
|
||||
dusk={tonight.astro_dusk_utc}
|
||||
dawn={tonight.astro_dawn_utc}
|
||||
rise={target.best_start_utc}
|
||||
set={target.best_end_utc}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '7px 8px', width: 60 }}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 11,
|
||||
color: (target.total_integration_min ?? 0) > 0 ? 'var(--teal)' : 'var(--text-lo)',
|
||||
}}>
|
||||
{fmtIntegration(target.total_integration_min)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '7px 12px 7px 4px', width: 68 }}>
|
||||
<IntegrationProgress
|
||||
obj_type={target.obj_type}
|
||||
recommended_filter={target.recommended_filter}
|
||||
total_min={target.total_integration_min}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
const LABELS: Record<string, string> = {
|
||||
galaxy: 'GX',
|
||||
emission_nebula: 'EN',
|
||||
planetary_nebula: 'PN',
|
||||
snr: 'SNR',
|
||||
globular_cluster: 'GC',
|
||||
open_cluster: 'OC',
|
||||
reflection_nebula: 'RN',
|
||||
dark_nebula: 'DN',
|
||||
nebula: 'NB',
|
||||
galaxy_group: 'GG',
|
||||
interacting_galaxy: 'IG',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export default function TypeBadge({ type }: Props) {
|
||||
const label = LABELS[type] ?? type.slice(0, 3).toUpperCase();
|
||||
return (
|
||||
<span className={`type-badge ${type.replace(/ /g, '_')}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
interface Props {
|
||||
dusk: string;
|
||||
dawn: string;
|
||||
rise?: string;
|
||||
set?: string;
|
||||
bestStart?: string;
|
||||
bestEnd?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
function toMinutes(utc: string, refUtc: string): number {
|
||||
return (new Date(utc).getTime() - new Date(refUtc).getTime()) / 60000;
|
||||
}
|
||||
|
||||
export default function VisBar({
|
||||
dusk, dawn, rise, set, bestStart, bestEnd, width = 80, height = 14,
|
||||
}: Props) {
|
||||
const totalMin = toMinutes(dawn, dusk);
|
||||
if (totalMin <= 0) return <svg width={width} height={height} />;
|
||||
|
||||
const pct = (utc: string) => (toMinutes(utc, dusk) / totalMin) * width;
|
||||
|
||||
return (
|
||||
<svg width={width} height={height} style={{ display: 'block' }}>
|
||||
{/* Background */}
|
||||
<rect x={0} y={2} width={width} height={height - 4} rx={2} fill="var(--bg-deep)" />
|
||||
|
||||
{/* Rise → Set arc */}
|
||||
{rise && set && (
|
||||
<rect
|
||||
x={Math.max(0, pct(rise))}
|
||||
y={2}
|
||||
width={Math.min(width, pct(set)) - Math.max(0, pct(rise))}
|
||||
height={height - 4}
|
||||
rx={2}
|
||||
fill="var(--warn)"
|
||||
opacity={0.5}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Best window */}
|
||||
{bestStart && bestEnd && (
|
||||
<rect
|
||||
x={Math.max(0, pct(bestStart))}
|
||||
y={2}
|
||||
width={Math.min(width, pct(bestEnd)) - Math.max(0, pct(bestStart))}
|
||||
height={height - 4}
|
||||
rx={2}
|
||||
fill="var(--good)"
|
||||
opacity={0.8}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Now marker */}
|
||||
<line
|
||||
x1={pct(new Date().toISOString())}
|
||||
y1={0}
|
||||
x2={pct(new Date().toISOString())}
|
||||
y2={height}
|
||||
stroke="var(--amber)"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
interface Props {
|
||||
level?: 'warning' | 'critical';
|
||||
temp?: number;
|
||||
dewPoint?: number;
|
||||
}
|
||||
|
||||
export default function DewAlert({ level, temp, dewPoint }: Props) {
|
||||
if (!level) return null;
|
||||
|
||||
const margin = temp != null && dewPoint != null ? (temp - dewPoint).toFixed(1) : null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: level === 'critical' ? 'rgba(224,82,82,0.2)' : 'rgba(232,192,48,0.15)',
|
||||
border: `1px solid ${level === 'critical' ? 'var(--danger)' : 'var(--warn)'}`,
|
||||
borderRadius: 4,
|
||||
padding: '10px 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 12,
|
||||
color: level === 'critical' ? 'var(--danger)' : 'var(--warn)',
|
||||
}}>
|
||||
<span style={{ fontSize: 16 }}>⚠</span>
|
||||
<span>
|
||||
DEW POINT ALERT — {level === 'critical' ? 'CRITICAL' : 'WARNING'}
|
||||
{margin && ` — Margin: ${margin}°C`}
|
||||
{level === 'critical'
|
||||
? ' — Condensation imminent. Protect optics immediately.'
|
||||
: ' — Risk of dew forming. Enable dew heaters.'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
interface Props {
|
||||
status?: 'go' | 'marginal' | 'nogo' | null;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const config = {
|
||||
go: { color: 'var(--good)', label: 'GO', bg: 'rgba(61,186,114,0.15)' },
|
||||
marginal: { color: 'var(--warn)', label: 'MARGINAL', bg: 'rgba(232,192,48,0.15)' },
|
||||
nogo: { color: 'var(--danger)', label: 'NO-GO', bg: 'rgba(224,82,82,0.15)' },
|
||||
};
|
||||
|
||||
export default function GoNogo({ status, compact }: Props) {
|
||||
const cfg = status ? config[status] : null;
|
||||
|
||||
if (!cfg) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
padding: compact ? '2px 8px' : '6px 14px',
|
||||
borderRadius: 4,
|
||||
background: 'var(--bg-panel)',
|
||||
color: 'var(--text-lo)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: compact ? 10 : 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.1em',
|
||||
}}>
|
||||
UNKNOWN
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
padding: compact ? '2px 8px' : '8px 18px',
|
||||
borderRadius: 4,
|
||||
background: cfg.bg,
|
||||
color: cfg.color,
|
||||
border: `1px solid ${cfg.color}`,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: compact ? 10 : 13,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.15em',
|
||||
}}>
|
||||
{cfg.label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react';
|
||||
import type { WeatherData } from '../../api/types';
|
||||
import GoNogo from './GoNogo';
|
||||
|
||||
interface Props {
|
||||
weather: WeatherData;
|
||||
}
|
||||
|
||||
const SEEING_LABELS: Record<number, string> = {
|
||||
1: '0.5″ (Excellent)', 2: '0.75″ (Good)', 3: '1.0″ (Good)',
|
||||
4: '1.25″ (Average)', 5: '1.5″ (Average)', 6: '2.0″ (Poor)',
|
||||
7: '2.5″ (Poor)', 8: '>3″ (Bad)',
|
||||
};
|
||||
|
||||
const TRANSP_LABELS: Record<number, string> = {
|
||||
1: 'Excellent', 2: 'Good', 3: 'Good', 4: 'Average',
|
||||
5: 'Average', 6: 'Poor', 7: 'Poor', 8: 'Bad',
|
||||
};
|
||||
|
||||
const CC_LABELS: Record<number, string> = {
|
||||
1: '0–6% (Clear)', 2: '6–19%', 3: '19–31%', 4: '31–44%',
|
||||
5: '44–56%', 6: '56–69%', 7: '69–81%', 8: '81–94%', 9: '94–100% (Overcast)',
|
||||
};
|
||||
|
||||
const WIND_DIRS: Record<string, string> = {
|
||||
N: '↑N', NE: '↗NE', E: '→E', SE: '↘SE',
|
||||
S: '↓S', SW: '↙SW', W: '←W', NW: '↖NW',
|
||||
};
|
||||
|
||||
export default function WeatherCard({ weather }: Props) {
|
||||
const [showReasons, setShowReasons] = useState(false);
|
||||
|
||||
const margin = weather.temp_c != null && weather.dew_point_c != null
|
||||
? weather.temp_c - weather.dew_point_c
|
||||
: null;
|
||||
|
||||
const windStr = weather.wind10m
|
||||
? `${WIND_DIRS[weather.wind10m.direction] ?? weather.wind10m.direction} ${weather.wind10m.speed} m/s`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'var(--bg-panel)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
padding: 16,
|
||||
}}>
|
||||
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<GoNogo status={weather.go_nogo} />
|
||||
{weather.go_nogo === 'marginal' && weather.go_nogo_reasons && weather.go_nogo_reasons.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowReasons(v => !v)}
|
||||
title="Why marginal?"
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--warn)',
|
||||
border: '1px solid var(--warn)', borderRadius: 3, padding: '1px 6px', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
why?
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showReasons && weather.go_nogo_reasons && (
|
||||
<div style={{
|
||||
background: 'rgba(232,192,48,0.08)', border: '1px solid var(--warn)',
|
||||
borderRadius: 3, padding: '6px 10px', marginBottom: 10,
|
||||
}}>
|
||||
{weather.go_nogo_reasons.map((r, i) => (
|
||||
<div key={i} style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--warn)', marginBottom: 2 }}>
|
||||
⚠ {r}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
{([
|
||||
['Temperature', weather.temp_c != null ? `${weather.temp_c.toFixed(1)}°C` : null],
|
||||
['Humidity', weather.humidity_pct != null ? `${weather.humidity_pct.toFixed(0)}%` : null],
|
||||
['Dew Point', weather.dew_point_c != null ? `${weather.dew_point_c.toFixed(1)}°C` : null],
|
||||
['Dew Margin', margin != null ? `${margin.toFixed(1)}°C${margin < 4 ? ' ⚠' : ' ✓'}` : null],
|
||||
['Cloud cover', weather.cloudcover ? CC_LABELS[weather.cloudcover] ?? `${weather.cloudcover}/9` : null],
|
||||
['Seeing', weather.seeing ? SEEING_LABELS[weather.seeing] ?? `${weather.seeing}/8` : null],
|
||||
['Transparency', weather.transparency ? TRANSP_LABELS[weather.transparency] ?? `${weather.transparency}/8` : null],
|
||||
['Wind', windStr],
|
||||
['Atm. stability', weather.lifted_index != null ? `LI ${weather.lifted_index}${weather.lifted_index < -2 ? ' (unstable)' : weather.lifted_index >= 2 ? ' (stable)' : ''}` : null],
|
||||
] as [string, string | null][]).filter(([, v]) => v != null).map(([label, value]) => (
|
||||
<tr key={label}>
|
||||
<td style={{
|
||||
color: 'var(--text-lo)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 12,
|
||||
paddingBottom: 5,
|
||||
width: '45%',
|
||||
}}>{label}</td>
|
||||
<td style={{
|
||||
color: (label === 'Dew Margin' && margin != null && margin < 4) ? 'var(--danger)'
|
||||
: (label === 'Atm. stability' && (weather.lifted_index ?? 0) < -2) ? 'var(--warn)'
|
||||
: 'var(--text-hi)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 12,
|
||||
textAlign: 'right',
|
||||
}}>{value as string}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../api';
|
||||
|
||||
export function useCalendar(months?: number) {
|
||||
return useQuery({
|
||||
queryKey: ['calendar', months],
|
||||
queryFn: () => api.calendar.get(months),
|
||||
staleTime: 60 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCalendarDate(date: string) {
|
||||
return useQuery({
|
||||
queryKey: ['calendar-date', date],
|
||||
queryFn: () => api.calendar.getDate(date),
|
||||
enabled: !!date,
|
||||
staleTime: 60 * 60_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../api';
|
||||
|
||||
export function useHorizon() {
|
||||
return useQuery({
|
||||
queryKey: ['horizon'],
|
||||
queryFn: () => api.horizon.get(),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../api';
|
||||
import type { LogEntry } from '../api/types';
|
||||
|
||||
export function useLog(page?: number) {
|
||||
return useQuery({
|
||||
queryKey: ['log', page],
|
||||
queryFn: () => api.log.list(page),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTargetLog(catalogId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['target-log', catalogId],
|
||||
queryFn: () => api.log.forTarget(catalogId),
|
||||
enabled: !!catalogId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateLog() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (entry: Omit<LogEntry, 'id' | 'created_at' | 'target_name' | 'target_common_name' | 'target_obj_type'>) =>
|
||||
api.log.create(entry),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['log'] });
|
||||
qc.invalidateQueries({ queryKey: ['target-log'] });
|
||||
qc.invalidateQueries({ queryKey: ['stats'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateLog() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<LogEntry> }) =>
|
||||
api.log.update(id, data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['log'] });
|
||||
qc.invalidateQueries({ queryKey: ['target-log'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteLog() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => api.log.delete(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['log'] });
|
||||
qc.invalidateQueries({ queryKey: ['target-log'] });
|
||||
qc.invalidateQueries({ queryKey: ['stats'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../api';
|
||||
|
||||
export function useStats() {
|
||||
return useQuery({
|
||||
queryKey: ['stats'],
|
||||
queryFn: () => api.stats.get(),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, type TargetsParams } from '../api';
|
||||
|
||||
export function useTargets(params: TargetsParams = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['targets', params],
|
||||
queryFn: () => api.targets.list(params),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTarget(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['target', id],
|
||||
queryFn: () => api.targets.get(id),
|
||||
enabled: !!id,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTargetVisibility(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['target-visibility', id],
|
||||
queryFn: () => api.targets.visibility(id),
|
||||
enabled: !!id,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTargetCurve(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['target-curve', id],
|
||||
queryFn: () => api.targets.curve(id),
|
||||
enabled: !!id,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTargetFilters(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['target-filters', id],
|
||||
queryFn: () => api.targets.filters(id),
|
||||
enabled: !!id,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTargetWorkflow(id: string, filterId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['target-workflow', id, filterId],
|
||||
queryFn: () => api.targets.workflow(id, filterId),
|
||||
enabled: !!id && !!filterId,
|
||||
staleTime: 60 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTargetYearly(id: string, enabled = false) {
|
||||
return useQuery({
|
||||
queryKey: ['target-yearly', id],
|
||||
queryFn: () => api.targets.yearly(id),
|
||||
enabled: !!id && enabled,
|
||||
staleTime: 60 * 60_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../api';
|
||||
|
||||
export function useTonight() {
|
||||
return useQuery({
|
||||
queryKey: ['tonight'],
|
||||
queryFn: () => api.tonight.get(),
|
||||
staleTime: 60_000,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../api';
|
||||
|
||||
export function useWeather() {
|
||||
return useQuery({
|
||||
queryKey: ['weather'],
|
||||
queryFn: () => api.weather.get(),
|
||||
staleTime: 15 * 60_000,
|
||||
refetchInterval: 15 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useForecast() {
|
||||
return useQuery({
|
||||
queryKey: ['forecast'],
|
||||
queryFn: () => api.weather.forecast(),
|
||||
staleTime: 3 * 60 * 60_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
import './styles/global.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,458 @@
|
||||
import { useState } from 'react';
|
||||
import { useCalendar, useCalendarDate } from '../hooks/useCalendar';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../api';
|
||||
import MoonPhaseIcon from '../components/sky/MoonPhaseIcon';
|
||||
import { format, startOfMonth, endOfMonth, eachDayOfInterval } from 'date-fns';
|
||||
|
||||
const FILTER_LABELS: Record<string, string> = {
|
||||
sv220: 'HaOIII', c2: 'SII/OIII', sv260: 'LP', uvir: 'UV/IR',
|
||||
};
|
||||
|
||||
function fmtTime(utc?: string): string {
|
||||
if (!utc) return '—';
|
||||
return new Date(utc).toLocaleTimeString('fr-FR', {
|
||||
hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris',
|
||||
});
|
||||
}
|
||||
|
||||
type CalDay = {
|
||||
date: string;
|
||||
moon_illumination?: number;
|
||||
max_usable_min?: number;
|
||||
avg_max_alt?: number;
|
||||
};
|
||||
|
||||
/** 12-month horizontal lunar cycle timeline */
|
||||
function NewMoonTimeline({ days }: { days: CalDay[] }) {
|
||||
const { data: nmData } = useQuery({
|
||||
queryKey: ['new-moon-windows'],
|
||||
queryFn: () => api.calendar.getNewMoonWindows(),
|
||||
staleTime: 24 * 60 * 60_000,
|
||||
});
|
||||
|
||||
// Build a map of new moon date → top targets
|
||||
const nmTargetMap = new Map<string, { id: string; name: string; common_name?: string; max_alt_deg?: number; recommended_filter?: string }[]>();
|
||||
for (const w of nmData?.windows ?? []) {
|
||||
nmTargetMap.set(w.date, w.top_targets);
|
||||
}
|
||||
|
||||
if (days.length === 0) {
|
||||
return <div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>Loading…</div>;
|
||||
}
|
||||
|
||||
// Chunk into lunar cycles: whenever illumination crosses a local minimum (new moon ≈ < 5%)
|
||||
// For display, just show month-by-month rows with a continuous illumination bar.
|
||||
const today = format(new Date(), 'yyyy-MM-dd');
|
||||
|
||||
// Group days into months
|
||||
const monthMap = new Map<string, CalDay[]>();
|
||||
for (const d of days) {
|
||||
const month = d.date.slice(0, 7); // "2026-04"
|
||||
if (!monthMap.has(month)) monthMap.set(month, []);
|
||||
monthMap.get(month)!.push(d);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 12, fontSize: 10, fontFamily: 'var(--font-mono)', color: 'var(--text-lo)' }}>
|
||||
<span><span style={{ display: 'inline-block', width: 12, height: 8, background: 'rgba(61,186,114,0.35)', borderRadius: 2, marginRight: 4 }} />{'< 20% moon — prime narrowband'}</span>
|
||||
<span><span style={{ display: 'inline-block', width: 12, height: 8, background: 'rgba(232,131,42,0.3)', borderRadius: 2, marginRight: 4 }} />{'20–50% — broadband OK'}</span>
|
||||
<span><span style={{ display: 'inline-block', width: 12, height: 8, background: 'rgba(224,82,82,0.25)', borderRadius: 2, marginRight: 4 }} />{'> 50% — challenging'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Array.from(monthMap.entries()).map(([month, mDays]) => {
|
||||
// Find new moon dates in this month (local illumination minimum, < 5%)
|
||||
const newMoonDates = mDays.filter((d, i) => {
|
||||
const illum = d.moon_illumination ?? 0.5;
|
||||
const prev = mDays[i - 1]?.moon_illumination ?? illum;
|
||||
const next = mDays[i + 1]?.moon_illumination ?? illum;
|
||||
return illum < 0.05 && illum <= prev && illum <= next;
|
||||
});
|
||||
|
||||
// Days in this month ordered
|
||||
const sorted = [...mDays].sort((a, b) => a.date.localeCompare(b.date));
|
||||
const daysInMonth = sorted.length;
|
||||
|
||||
return (
|
||||
<div key={month} style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 11,
|
||||
color: 'var(--text-mid)',
|
||||
width: 80,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{format(new Date(month + '-01'), 'MMM yyyy')}
|
||||
</span>
|
||||
|
||||
{/* Moon illumination bar */}
|
||||
<div style={{ flex: 1, position: 'relative', height: 28, display: 'flex' }}>
|
||||
{sorted.map(d => {
|
||||
const illum = d.moon_illumination ?? 0;
|
||||
const isToday = d.date === today;
|
||||
const isNewMoon = newMoonDates.some(nm => nm.date === d.date);
|
||||
let bg: string;
|
||||
if (illum < 0.2) bg = 'rgba(61,186,114,0.35)';
|
||||
else if (illum < 0.5) bg = 'rgba(232,131,42,0.30)';
|
||||
else bg = 'rgba(224,82,82,0.25)';
|
||||
|
||||
return (
|
||||
<div key={d.date} title={`${d.date} — ${Math.round(illum * 100)}%`}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: bg,
|
||||
borderLeft: isToday ? '2px solid var(--amber)' : '1px solid transparent',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
{/* New moon marker */}
|
||||
{isNewMoon && (
|
||||
<div style={{
|
||||
width: 4, height: 4,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--text-hi)',
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
}} />
|
||||
)}
|
||||
{/* Full moon marker */}
|
||||
{illum > 0.95 && (
|
||||
<div style={{
|
||||
width: 4, height: 4,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.5)',
|
||||
border: '1px solid rgba(255,255,255,0.7)',
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
}} />
|
||||
)}
|
||||
{/* Illumination curve as height */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 0, left: 0, right: 0,
|
||||
height: `${illum * 100}%`,
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Day count axis: show 1, 8, 15, 22, 28 */}
|
||||
{sorted.filter((_, i) => [0, 7, 14, 21, 27].includes(i)).map(d => {
|
||||
const idx = sorted.indexOf(d);
|
||||
const pct = (idx / daysInMonth) * 100;
|
||||
return (
|
||||
<div key={`lbl-${d.date}`} style={{
|
||||
position: 'absolute',
|
||||
left: `${pct}%`,
|
||||
bottom: -13,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 8,
|
||||
color: 'var(--text-lo)',
|
||||
transform: 'translateX(-50%)',
|
||||
}}>
|
||||
{parseInt(d.date.slice(8))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* New moon windows: date + top 3 emission targets */}
|
||||
<div style={{ width: 200, flexShrink: 0 }}>
|
||||
{newMoonDates.map(d => {
|
||||
const targets = nmTargetMap.get(d.date) ?? [];
|
||||
return (
|
||||
<div key={d.date} style={{ marginBottom: 4 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--amber)', marginBottom: 2 }}>
|
||||
● {d.date.slice(5)}
|
||||
</div>
|
||||
{targets.map(t => (
|
||||
<div key={t.id} style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--text-lo)', paddingLeft: 8 }}>
|
||||
{t.common_name ?? t.name}
|
||||
{t.max_alt_deg != null && (
|
||||
<span style={{ color: 'var(--text-lo)', marginLeft: 4 }}>{t.max_alt_deg.toFixed(0)}°</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div style={{ marginTop: 12, fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
|
||||
● = new moon · vertical line = today · hover a day for date + illumination %
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Calendar() {
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
const [newMoonView, setNewMoonView] = useState(false);
|
||||
const { data: calData3 } = useCalendar(3);
|
||||
const { data: calData12 } = useCalendar(12);
|
||||
const { data: dateData } = useCalendarDate(selectedDate ?? '');
|
||||
|
||||
const calData = newMoonView ? calData12 : calData3;
|
||||
|
||||
const today = new Date();
|
||||
|
||||
const dayMap = new Map<string, CalDay>(
|
||||
(calData?.days ?? []).map(d => [d.date, d])
|
||||
);
|
||||
|
||||
const months = [today, new Date(today.getFullYear(), today.getMonth() + 1), new Date(today.getFullYear(), today.getMonth() + 2)];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}>
|
||||
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 22 }}>Calendar</h1>
|
||||
<button
|
||||
onClick={() => { setNewMoonView(v => !v); setSelectedDate(null); }}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${newMoonView ? 'var(--amber)' : 'var(--border)'}`,
|
||||
background: newMoonView ? 'var(--amber-glow)' : 'var(--bg-panel)',
|
||||
color: newMoonView ? 'var(--amber)' : 'var(--text-mid)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 11,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
● New Moon View
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{newMoonView ? (
|
||||
<NewMoonTimeline days={calData12?.days ?? []} />
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: selectedDate ? '2fr 1fr' : '1fr', gap: 20 }}>
|
||||
<div>
|
||||
{months.map(month => {
|
||||
const monthStart = startOfMonth(month);
|
||||
const monthEnd = endOfMonth(month);
|
||||
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||
const firstDow = monthStart.getDay();
|
||||
|
||||
return (
|
||||
<div key={month.toISOString()} style={{ marginBottom: 28 }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 16, fontWeight: 700, marginBottom: 10, color: 'var(--text-hi)' }}>
|
||||
{format(month, 'MMMM yyyy')}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 3 }}>
|
||||
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(d => (
|
||||
<div key={d} style={{ textAlign: 'center', fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--text-lo)', paddingBottom: 4 }}>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
{Array.from({ length: (firstDow + 6) % 7 }).map((_, i) => (
|
||||
<div key={`e${i}`} />
|
||||
))}
|
||||
{days.map(day => {
|
||||
const dateStr = format(day, 'yyyy-MM-dd');
|
||||
const info = dayMap.get(dateStr);
|
||||
const usable = info?.max_usable_min ?? 0;
|
||||
const moonIllum = info?.moon_illumination;
|
||||
const isSelected = selectedDate === dateStr;
|
||||
const isToday = dateStr === format(today, 'yyyy-MM-dd');
|
||||
const isNarrowbandNight = moonIllum != null && moonIllum < 0.2;
|
||||
|
||||
let bg = 'var(--bg-panel)';
|
||||
if (usable > 240) bg = 'rgba(42,184,160,0.2)';
|
||||
else if (usable > 60) bg = 'rgba(232,131,42,0.15)';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dateStr}
|
||||
onClick={() => setSelectedDate(isSelected ? null : dateStr)}
|
||||
style={{
|
||||
background: isSelected ? 'var(--amber-glow)' : bg,
|
||||
border: `1px solid ${isSelected ? 'var(--amber)' : isNarrowbandNight ? 'var(--amber)' : isToday ? 'var(--text-lo)' : 'var(--border)'}`,
|
||||
borderRadius: 3,
|
||||
padding: '4px',
|
||||
cursor: 'pointer',
|
||||
minHeight: 56,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 11,
|
||||
color: isToday ? 'var(--amber)' : 'var(--text-mid)',
|
||||
marginBottom: 2,
|
||||
fontWeight: isToday ? 700 : 400,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<span>{format(day, 'd')}</span>
|
||||
{moonIllum != null && (
|
||||
<MoonPhaseIcon illumination={moonIllum} size={12} />
|
||||
)}
|
||||
</div>
|
||||
{usable > 0 && (
|
||||
<div style={{
|
||||
height: 3,
|
||||
background: usable > 240 ? 'var(--teal)' : 'var(--amber)',
|
||||
borderRadius: 2,
|
||||
width: `${Math.min(100, (usable / 480) * 100)}%`,
|
||||
marginBottom: 2,
|
||||
}} />
|
||||
)}
|
||||
{moonIllum != null && (
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 9,
|
||||
color: isNarrowbandNight ? 'var(--amber)' : 'var(--text-lo)',
|
||||
}}>
|
||||
{Math.round(moonIllum * 100)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Side panel for selected date */}
|
||||
{selectedDate && (
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: 16, height: 'fit-content', position: 'sticky', top: 8 }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 16, fontWeight: 700, marginBottom: 12 }}>
|
||||
{selectedDate}
|
||||
</div>
|
||||
|
||||
{/* Moon + dark window */}
|
||||
{(() => {
|
||||
const illum = dateData?.moon_illumination ?? dayMap.get(selectedDate)?.moon_illumination;
|
||||
const info = dayMap.get(selectedDate);
|
||||
return illum != null ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
||||
<MoonPhaseIcon illumination={illum} size={28} />
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text-hi)', fontWeight: 700 }}>
|
||||
{Math.round(illum * 100)}%
|
||||
</div>
|
||||
{illum < 0.2 && (
|
||||
<div style={{ fontSize: 10, color: 'var(--amber)', fontFamily: 'var(--font-mono)' }}>
|
||||
★ Prime narrowband night
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{info?.max_usable_min ? (
|
||||
<div style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--teal)' }}>
|
||||
{Math.floor(info.max_usable_min / 60)}h {info.max_usable_min % 60}m dark
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{/* Tonight summary */}
|
||||
{dateData?.tonight && (
|
||||
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 10px', marginBottom: 10 }}>
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
|
||||
<tbody>
|
||||
{[
|
||||
['Dusk', dateData.tonight.astro_dusk_utc ? fmtTime(dateData.tonight.astro_dusk_utc) : '—'],
|
||||
['Dawn', dateData.tonight.astro_dawn_utc ? fmtTime(dateData.tonight.astro_dawn_utc) : '—'],
|
||||
['True dark', dateData.tonight.true_dark_start_utc && dateData.tonight.true_dark_end_utc
|
||||
? `${fmtTime(dateData.tonight.true_dark_start_utc)} – ${fmtTime(dateData.tonight.true_dark_end_utc)}`
|
||||
: '—'],
|
||||
['Moon', dateData.tonight.moon_phase_name ?? '—'],
|
||||
].map(([label, val]) => (
|
||||
<tr key={label}>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', paddingBottom: 3, width: 70 }}>{label}</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-hi)' }}>{val}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Weather */}
|
||||
{dateData?.weather && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10, padding: '6px 10px', background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 4 }}>
|
||||
{dateData.weather.go_nogo && (
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: dateData.weather.go_nogo === 'go' ? 'var(--good)' : dateData.weather.go_nogo === 'marginal' ? 'var(--warn)' : 'var(--danger)',
|
||||
}}>
|
||||
{dateData.weather.go_nogo.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
{dateData.weather.temp_c != null && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-mid)' }}>
|
||||
{dateData.weather.temp_c.toFixed(0)}°C
|
||||
</span>
|
||||
)}
|
||||
{dateData.weather.cloudcover != null && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
|
||||
Cloud {dateData.weather.cloudcover}/9
|
||||
</span>
|
||||
)}
|
||||
{dateData.weather.seeing != null && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
|
||||
Seeing {dateData.weather.seeing}/8
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)', marginBottom: 8, letterSpacing: '0.06em', textTransform: 'uppercase' }}>
|
||||
Top Targets
|
||||
</div>
|
||||
{!dateData && (
|
||||
<div style={{ color: 'var(--text-lo)', fontSize: 12 }}>No precomputed data for this date.</div>
|
||||
)}
|
||||
{dateData?.top_targets?.length === 0 && (
|
||||
<div style={{ color: 'var(--text-lo)', fontSize: 12 }}>No visible targets.</div>
|
||||
)}
|
||||
{dateData?.top_targets?.map((t, i) => (
|
||||
<div key={t.id} style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
padding: '5px 0',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<span style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 10, width: 16 }}>{i + 1}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-hi)' }}>
|
||||
{t.common_name ?? t.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text-lo)' }}>{t.name}</div>
|
||||
</div>
|
||||
{t.recommended_filter && (
|
||||
<span className={`filter-pill ${t.recommended_filter}`} style={{ fontSize: 9 }}>
|
||||
{FILTER_LABELS[t.recommended_filter] ?? ''}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--good)' }}>
|
||||
{t.max_alt_deg?.toFixed(0)}°
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useState } from 'react';
|
||||
import { useTonight } from '../hooks/useTonight';
|
||||
import { useWeather, useForecast } from '../hooks/useWeather';
|
||||
import { useTargets } from '../hooks/useTargets';
|
||||
import { useStats } from '../hooks/useStats';
|
||||
import GoNogo from '../components/weather/GoNogo';
|
||||
import DewAlert from '../components/weather/DewAlert';
|
||||
import MoonPhaseIcon from '../components/sky/MoonPhaseIcon';
|
||||
import DetailDrawer from '../components/targets/DetailDrawer';
|
||||
import type { Target } from '../api/types';
|
||||
|
||||
const FILTER_LABELS: Record<string, string> = {
|
||||
sv220: 'HaOIII', c2: 'SII/OIII', sv260: 'LP', uvir: 'UV/IR',
|
||||
};
|
||||
const CC_LABELS: Record<number, string> = {
|
||||
1: 'Clear', 2: 'Clear', 3: 'Mostly clear', 4: 'Partly cloudy',
|
||||
5: 'Partly cloudy', 6: 'Cloudy', 7: 'Mostly cloudy', 8: 'Overcast', 9: 'Overcast',
|
||||
};
|
||||
const CC_COLOR = (n: number) => n <= 2 ? 'var(--good)' : n <= 4 ? 'var(--teal)' : n <= 6 ? 'var(--warn)' : 'var(--danger)';
|
||||
|
||||
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 fmtDuration(min?: number): string {
|
||||
if (!min) return '—';
|
||||
const h = Math.floor(min / 60);
|
||||
const m = min % 60;
|
||||
return `${h}h ${m < 10 ? '0' : ''}${m}m`;
|
||||
}
|
||||
function fmtIntTotal(min: number): string {
|
||||
if (min < 60) return `${min} min`;
|
||||
const h = (min / 60).toFixed(1);
|
||||
return `${h} h`;
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { data: tonight } = useTonight();
|
||||
const { data: weather } = useWeather();
|
||||
const { data: forecast } = useForecast();
|
||||
const { data: targets } = useTargets({ tonight: true, limit: 5 });
|
||||
const { data: stats } = useStats();
|
||||
const [expandedTarget, setExpandedTarget] = useState<Target | null>(null);
|
||||
|
||||
const moonPct = tonight?.moon_illumination != null
|
||||
? `${Math.round(tonight.moon_illumination * 100)}%`
|
||||
: '—';
|
||||
|
||||
// Next 4 forecast slots for mini weather bar (3h each = 12h ahead)
|
||||
const slots = (forecast as { dataseries?: { cloudcover?: number; seeing?: number; timepoint?: number }[] })?.dataseries?.slice(0, 8) ?? [];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px 28px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 20 }}>
|
||||
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 20, fontWeight: 700, color: 'var(--text-hi)', letterSpacing: '0.04em' }}>
|
||||
Dashboard
|
||||
</h1>
|
||||
{tonight?.date && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-lo)' }}>
|
||||
{tonight.date}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dew alert banner */}
|
||||
{weather?.dew_alert && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<DewAlert level={weather.dew_alert} temp={weather.temp_c} dewPoint={weather.dew_point_c} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stat cards row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12, marginBottom: 20 }}>
|
||||
{/* Go/No-go */}
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 8 }}>Tonight</div>
|
||||
<GoNogo status={weather?.go_nogo} />
|
||||
{weather?.temp_c != null && (
|
||||
<div style={{ marginTop: 8, fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)' }}>
|
||||
{weather.temp_c.toFixed(1)}°C · {weather.humidity_pct?.toFixed(0)}% RH
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Moon */}
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 8 }}>Moon</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{tonight?.moon_illumination != null && <MoonPhaseIcon illumination={tonight.moon_illumination} size={32} />}
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 22, fontWeight: 600, color: 'var(--text-hi)', lineHeight: 1 }}>{moonPct}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 3 }}>{tonight?.moon_phase_name ?? '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* True dark */}
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 8 }}>True Dark</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 20, fontWeight: 600, color: 'var(--teal)', lineHeight: 1 }}>
|
||||
{fmtDuration(tonight?.true_dark_minutes)}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 4 }}>
|
||||
{tonight?.true_dark_start_utc
|
||||
? `${fmtTime(tonight.true_dark_start_utc)} – ${fmtTime(tonight.true_dark_end_utc)}`
|
||||
: 'No full dark tonight'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats summary */}
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 8 }}>Logbook</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 20, fontWeight: 600, color: 'var(--amber)', lineHeight: 1 }}>
|
||||
{stats ? fmtIntTotal(stats.total_integration_min) : '—'}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 4 }}>
|
||||
{stats ? `${stats.total_sessions} sessions · ${stats.objects_with_keeper} keepers` : 'No data yet'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tonight timing + top targets + forecast */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr 1fr', gap: 16, marginBottom: 20 }}>
|
||||
|
||||
{/* Tonight timing */}
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 10 }}>Tonight's Window</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
{[
|
||||
['Dusk', fmtTime(tonight?.astro_dusk_utc)],
|
||||
['Dawn', fmtTime(tonight?.astro_dawn_utc)],
|
||||
['Moon rise', fmtTime(tonight?.moon_rise_utc)],
|
||||
['Moon set', fmtTime(tonight?.moon_set_utc)],
|
||||
['Dark start', fmtTime(tonight?.true_dark_start_utc)],
|
||||
['Dark end', fmtTime(tonight?.true_dark_end_utc)],
|
||||
].map(([label, value]) => (
|
||||
<tr key={label}>
|
||||
<td style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11, paddingBottom: 4, paddingRight: 8 }}>{label}</td>
|
||||
<td style={{ color: 'var(--text-hi)', fontFamily: 'var(--font-mono)', fontSize: 11, textAlign: 'right' }}>{value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Top targets */}
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 10 }}>Top Targets Tonight</div>
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
|
||||
{!targets?.items?.length && (
|
||||
<div style={{ padding: 16, color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||
Catalog loading…
|
||||
</div>
|
||||
)}
|
||||
{targets?.items?.map((t, i) => (
|
||||
<div key={t.id}>
|
||||
<div
|
||||
onClick={() => setExpandedTarget(expandedTarget?.id === t.id ? null : t)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '8px 14px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
gap: 10,
|
||||
cursor: 'pointer',
|
||||
background: expandedTarget?.id === t.id ? 'var(--bg-hover)' : 'transparent',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', width: 14 }}>{i + 1}</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--amber)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{t.common_name ?? t.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-lo)' }}>
|
||||
{t.name} · {t.usable_min ? `${t.usable_min}min` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600, color: (t.max_alt_deg ?? 0) >= 30 ? 'var(--good)' : 'var(--warn)' }}>
|
||||
{t.max_alt_deg?.toFixed(0)}°
|
||||
</span>
|
||||
{t.recommended_filter && (
|
||||
<span className={`filter-pill ${t.recommended_filter}`} style={{ fontSize: 9 }}>
|
||||
{FILTER_LABELS[t.recommended_filter] ?? t.recommended_filter.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{expandedTarget?.id === t.id && <DetailDrawer target={t} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Forecast mini bars */}
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 10 }}>24h Forecast</div>
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '12px 14px' }}>
|
||||
{slots.length === 0 && (
|
||||
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>No forecast data</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{slots.map((slot, i) => {
|
||||
const cc = slot.cloudcover ?? 5;
|
||||
const seeing = slot.seeing ?? 5;
|
||||
const hoursAhead = (i + 1) * 3;
|
||||
const label = `+${hoursAhead}h`;
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', width: 28 }}>{label}</span>
|
||||
<div style={{ flex: 1, background: 'var(--bg-deep)', borderRadius: 2, height: 6, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: `${((9 - cc) / 8) * 100}%`,
|
||||
background: CC_COLOR(cc),
|
||||
borderRadius: 2,
|
||||
transition: 'width 0.3s',
|
||||
}} />
|
||||
</div>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: CC_COLOR(cc), width: 80 }}>
|
||||
{CC_LABELS[cc] ?? '—'}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: seeing <= 3 ? 'var(--good)' : seeing <= 5 ? 'var(--warn)' : 'var(--danger)', width: 24, textAlign: 'right' }}>
|
||||
{['', '0.5″', '0.75″', '1.0″', '1.25″', '1.5″', '2.0″', '2.5″', '>3″'][seeing] ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../api';
|
||||
import type { GalleryImage } from '../api/types';
|
||||
|
||||
type GalleryImageWithTarget = GalleryImage & {
|
||||
target_name?: string;
|
||||
target_common_name?: string;
|
||||
};
|
||||
|
||||
function fmtDate(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleDateString('fr-FR', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export default function Gallery() {
|
||||
const [lightbox, setLightbox] = useState<GalleryImageWithTarget | null>(null);
|
||||
const [filterTarget, setFilterTarget] = useState('');
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['gallery-all'],
|
||||
queryFn: () => api.gallery.listAll(),
|
||||
});
|
||||
|
||||
const images = data?.items ?? [];
|
||||
|
||||
// Unique targets for filter
|
||||
const targets = Array.from(
|
||||
new Map(images.map(img => [img.catalog_id, img.target_common_name ?? img.target_name ?? img.catalog_id])).entries()
|
||||
).sort((a, b) => a[1].localeCompare(b[1]));
|
||||
|
||||
const filtered = filterTarget
|
||||
? images.filter(img => img.catalog_id === filterTarget)
|
||||
: images;
|
||||
|
||||
// Group by target
|
||||
const grouped: Record<string, GalleryImageWithTarget[]> = {};
|
||||
for (const img of filtered) {
|
||||
const key = img.catalog_id;
|
||||
if (!grouped[key]) grouped[key] = [];
|
||||
grouped[key].push(img);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px 28px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 20, marginBottom: 24 }}>
|
||||
<h1 style={{
|
||||
fontFamily: 'var(--font-display)',
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-hi)',
|
||||
letterSpacing: '0.04em',
|
||||
}}>
|
||||
Gallery
|
||||
</h1>
|
||||
<span style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||
{images.length} image{images.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
|
||||
{targets.length > 1 && (
|
||||
<select
|
||||
value={filterTarget}
|
||||
onChange={e => setFilterTarget(e.target.value)}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
background: 'var(--bg-deep)',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--text-mid)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 11,
|
||||
padding: '5px 10px',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
<option value="">All targets</option>
|
||||
{targets.map(([id, label]) => (
|
||||
<option key={id} value={id}>{label} ({id})</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||
Loading images…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && images.length === 0 && (
|
||||
<div style={{
|
||||
color: 'var(--text-lo)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 12,
|
||||
padding: '40px 0',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
No images yet. Upload images from the Targets page → Log & Gallery tab.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.entries(grouped).map(([catalogId, imgs]) => {
|
||||
const first = imgs[0];
|
||||
const targetLabel = first.target_common_name ?? first.target_name ?? catalogId;
|
||||
return (
|
||||
<div key={catalogId} style={{ marginBottom: 32 }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 10,
|
||||
marginBottom: 12,
|
||||
borderBottom: '1px solid var(--border)',
|
||||
paddingBottom: 8,
|
||||
}}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-display)',
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-hi)',
|
||||
}}>
|
||||
{targetLabel}
|
||||
</span>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 11,
|
||||
color: 'var(--amber)',
|
||||
}}>
|
||||
{catalogId}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)', marginLeft: 'auto' }}>
|
||||
{imgs.length} image{imgs.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
|
||||
gap: 8,
|
||||
}}>
|
||||
{imgs.map(img => (
|
||||
<div
|
||||
key={img.id}
|
||||
onClick={() => setLightbox(img)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
background: 'var(--bg-deep)',
|
||||
border: '1px solid var(--border)',
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.borderColor = 'var(--border-hi)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.borderColor = 'var(--border)')}
|
||||
>
|
||||
<div style={{ aspectRatio: '4/3', overflow: 'hidden' }}>
|
||||
<img
|
||||
src={img.url}
|
||||
alt={img.caption ?? img.filename}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ padding: '6px 8px' }}>
|
||||
{img.caption && (
|
||||
<div style={{
|
||||
color: 'var(--text-mid)',
|
||||
fontFamily: 'var(--font-sans)',
|
||||
fontSize: 11,
|
||||
marginBottom: 2,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{img.caption}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 10 }}>
|
||||
{fmtDate(img.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Lightbox */}
|
||||
{lightbox && (
|
||||
<div
|
||||
onClick={() => setLightbox(null)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.94)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}
|
||||
>
|
||||
<img
|
||||
src={lightbox.url}
|
||||
alt={lightbox.caption ?? lightbox.filename}
|
||||
style={{ maxWidth: '100%', maxHeight: '85vh', borderRadius: 4, display: 'block' }}
|
||||
/>
|
||||
<div style={{
|
||||
marginTop: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ color: 'var(--text-hi)', fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 600 }}>
|
||||
{lightbox.target_common_name ?? lightbox.target_name ?? lightbox.catalog_id}
|
||||
<span style={{ color: 'var(--amber)', fontFamily: 'var(--font-mono)', fontSize: 11, marginLeft: 8 }}>
|
||||
{lightbox.catalog_id}
|
||||
</span>
|
||||
</div>
|
||||
{lightbox.caption && (
|
||||
<div style={{ color: 'var(--text-mid)', fontFamily: 'var(--font-sans)', fontSize: 12, marginTop: 2 }}>
|
||||
{lightbox.caption}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11, marginTop: 2 }}>
|
||||
{fmtDate(lightbox.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
api.gallery.delete(lightbox.id).then(() => {
|
||||
qc.invalidateQueries({ queryKey: ['gallery-all'] });
|
||||
qc.invalidateQueries({ queryKey: ['gallery', lightbox.catalog_id] });
|
||||
setLightbox(null);
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
color: 'var(--danger)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 12,
|
||||
border: '1px solid var(--danger)',
|
||||
borderRadius: 3,
|
||||
padding: '4px 10px',
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setLightbox(null)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -12,
|
||||
color: 'var(--text-hi)',
|
||||
fontSize: 18,
|
||||
background: 'var(--bg-panel)',
|
||||
border: '1px solid var(--border-hi)',
|
||||
borderRadius: '50%',
|
||||
width: 28,
|
||||
height: 28,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../api';
|
||||
import type { HorizonPoint } from '../api/types';
|
||||
|
||||
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 (
|
||||
<svg width={size} height={size} style={{ display: 'block' }}>
|
||||
{/* Grid circles */}
|
||||
{[15, 30, 45, 60, 75, 90].map(alt => {
|
||||
const pr = (1 - alt / 90) * r;
|
||||
return (
|
||||
<g key={alt}>
|
||||
<circle cx={cx} cy={cy} r={pr}
|
||||
fill="none" stroke="var(--border)" strokeWidth={1} strokeDasharray={alt === 15 ? '4 4' : '2 4'}
|
||||
/>
|
||||
<text x={cx + 3} y={cy - pr - 2} fill="var(--text-lo)" fontSize={8} fontFamily="IBM Plex Mono">{alt}°</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* Cardinal lines */}
|
||||
{[0, 90, 180, 270].map(az => {
|
||||
const azRad = (az - 90) * (Math.PI / 180);
|
||||
return (
|
||||
<line key={az}
|
||||
x1={cx} y1={cy}
|
||||
x2={cx + r * Math.cos(azRad)} y2={cy + r * Math.sin(azRad)}
|
||||
stroke="var(--border)" strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* Labels */}
|
||||
{[['N', 0], ['E', 90], ['S', 180], ['W', 270]].map(([label, az]) => {
|
||||
const azRad = ((az as number) - 90) * (Math.PI / 180);
|
||||
return (
|
||||
<text key={label as string}
|
||||
x={cx + (r + 14) * Math.cos(azRad)}
|
||||
y={cy + (r + 14) * Math.sin(azRad) + 4}
|
||||
textAnchor="middle"
|
||||
fill="var(--text-lo)"
|
||||
fontSize={10}
|
||||
fontFamily="IBM Plex Mono"
|
||||
>
|
||||
{label as string}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
{/* Horizon profile */}
|
||||
{pathParts.length > 0 && (
|
||||
<path d={pathParts.join(' ')} fill="rgba(232,131,42,0.15)" stroke="var(--amber)" strokeWidth={1.5} />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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<any>(null);
|
||||
|
||||
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 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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div>
|
||||
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 22, marginBottom: 24 }}>Settings</h1>
|
||||
|
||||
{/* Custom Horizon */}
|
||||
<section style={{ marginBottom: 32 }}>
|
||||
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 16, marginBottom: 14, color: 'var(--text-hi)' }}>
|
||||
Custom Horizon Profile
|
||||
</h2>
|
||||
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}>
|
||||
{horizonData?.points && <HorizonPolarChart points={horizonData.points} />}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-mid)', maxWidth: 300 }}>
|
||||
Upload a CSV file with columns <code>az_deg,alt_deg</code>, one row per degree (360 rows total).
|
||||
</div>
|
||||
<label style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
padding: '6px 14px',
|
||||
background: 'var(--bg-panel)',
|
||||
border: '1px solid var(--border-hi)',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 12,
|
||||
color: 'var(--text-hi)',
|
||||
}}>
|
||||
↑ Upload CSV
|
||||
<input type="file" accept=".csv" style={{ display: 'none' }} onChange={handleHorizonCSV} />
|
||||
</label>
|
||||
<button
|
||||
onClick={resetHorizon}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
background: 'var(--bg-deep)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 4,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 12,
|
||||
color: 'var(--text-mid)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Reset to Flat 15°
|
||||
</button>
|
||||
{setHorizon.isSuccess && (
|
||||
<div style={{ color: 'var(--good)', fontSize: 12, fontFamily: 'var(--font-mono)' }}>
|
||||
✓ Horizon updated
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* App Info */}
|
||||
<section>
|
||||
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 16, marginBottom: 14, color: 'var(--text-hi)' }}>
|
||||
App Info
|
||||
</h2>
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 18px', maxWidth: 440 }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: 12 }}>
|
||||
<tbody>
|
||||
{[
|
||||
['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]) => (
|
||||
<tr key={label}>
|
||||
<td style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12, paddingBottom: 8, width: '45%' }}>{label}</td>
|
||||
<td style={{ color: 'var(--text-hi)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>{value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={triggerRebuild}
|
||||
disabled={rebuilding}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
background: 'var(--blue)',
|
||||
color: '#fff',
|
||||
borderRadius: 4,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 12,
|
||||
opacity: rebuilding ? 0.6 : 1,
|
||||
cursor: rebuilding ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{rebuilding ? 'Rebuilding…' : 'Rebuild Catalog'}
|
||||
</button>
|
||||
<button
|
||||
onClick={triggerRecompute}
|
||||
disabled={recomputing}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
background: 'var(--bg-deep)',
|
||||
border: '1px solid var(--border-hi)',
|
||||
color: 'var(--text-hi)',
|
||||
borderRadius: 4,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 12,
|
||||
opacity: recomputing ? 0.6 : 1,
|
||||
cursor: recomputing ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{recomputing ? 'Starting…' : 'Recompute Tonight'}
|
||||
</button>
|
||||
</div>
|
||||
{recomputeMsg && (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text-mid)', fontFamily: 'var(--font-mono)' }}>
|
||||
{recomputeMsg}
|
||||
</div>
|
||||
)}
|
||||
{rebuildResult && (
|
||||
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--text-mid)', fontFamily: 'var(--font-mono)', background: 'var(--bg-row)', padding: '10px 12px', borderRadius: 4 }}>
|
||||
{rebuildResult.error ? (
|
||||
<div style={{ color: 'var(--danger)' }}>{rebuildResult.error}</div>
|
||||
) : rebuildResult.message ? (
|
||||
<div style={{ color: 'var(--good)' }}>{rebuildResult.message}</div>
|
||||
) : (
|
||||
<>
|
||||
<div><strong>Total:</strong> {rebuildResult.total?.toLocaleString() || '?'} entries</div>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<strong>By Type:</strong>
|
||||
{rebuildResult.by_type && Object.entries(rebuildResult.by_type).length > 0 ? (
|
||||
<div style={{ marginLeft: 12, marginTop: 4 }}>
|
||||
{Object.entries(rebuildResult.by_type).map(([type, count]: [string, any]) => (
|
||||
<div key={type} style={{ fontSize: 10 }}>{type}: {count}</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginLeft: 12, marginTop: 4, fontSize: 10 }}>None</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<strong>Messier:</strong> {rebuildResult.messier_count || 0}
|
||||
<span style={{ color: 'var(--text-lo)', marginLeft: 8 }}>({rebuildResult.has_sizes || 0} with size data)</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
const base = '/api';
|
||||
|
||||
interface SSOObject {
|
||||
id: string;
|
||||
name: string;
|
||||
obj_type: string;
|
||||
ra_deg: number;
|
||||
dec_deg: number;
|
||||
ra_h: string;
|
||||
dec_dms: string;
|
||||
alt_deg: number;
|
||||
az_deg: number;
|
||||
airmass: number;
|
||||
mag_v?: number;
|
||||
angular_size_arcsec?: number;
|
||||
phase_pct?: number;
|
||||
distance_au?: number;
|
||||
elongation_deg?: number;
|
||||
is_visible: boolean;
|
||||
}
|
||||
|
||||
interface CustomTarget {
|
||||
id: string;
|
||||
name: string;
|
||||
obj_type: string;
|
||||
ra_deg?: number;
|
||||
dec_deg?: number;
|
||||
ra_h?: string;
|
||||
dec_dms?: string;
|
||||
alt_deg?: number;
|
||||
az_deg?: number;
|
||||
has_tle: boolean;
|
||||
notes?: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
function altColor(alt: number) {
|
||||
if (alt >= 30) return 'var(--good)';
|
||||
if (alt >= 15) return 'var(--warn)';
|
||||
return 'var(--danger)';
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
planet: 'var(--blue)',
|
||||
moon: '#aaa',
|
||||
star: '#ffe066',
|
||||
custom: 'var(--teal)',
|
||||
satellite: 'var(--amber)',
|
||||
comet: '#c39dde',
|
||||
};
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
background: colors[type] ?? 'var(--muted)',
|
||||
color: '#111',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
padding: '1px 6px',
|
||||
borderRadius: 3,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}>
|
||||
{type}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SolarSystem() {
|
||||
const qc = useQueryClient();
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
id: '', name: '', ra_h: '', dec_dms: '', notes: '', obj_type: 'custom',
|
||||
tle_line1: '', tle_line2: '',
|
||||
});
|
||||
const [coordMode, setCoordMode] = useState<'radec' | 'tle'>('radec');
|
||||
|
||||
const { data: ssoData, isLoading } = useQuery({
|
||||
queryKey: ['solar-system'],
|
||||
queryFn: () => fetch(`${base}/solar-system`).then(r => r.json()) as Promise<{ objects: SSOObject[]; computed_at: string }>,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const { data: customData } = useQuery({
|
||||
queryKey: ['custom-targets'],
|
||||
queryFn: () => fetch(`${base}/custom-targets`).then(r => r.json()) as Promise<{ items: CustomTarget[] }>,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => fetch(`${base}/custom-targets/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['custom-targets'] }),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (body: typeof form) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
id: body.id, name: body.name, obj_type: body.obj_type, notes: body.notes || undefined,
|
||||
};
|
||||
if (coordMode === 'tle') {
|
||||
if (body.tle_line1.trim()) payload.tle_line1 = body.tle_line1.trim();
|
||||
if (body.tle_line2.trim()) payload.tle_line2 = body.tle_line2.trim();
|
||||
} else {
|
||||
const ra = parseCoord(body.ra_h, 'ra');
|
||||
const dec = parseCoord(body.dec_dms, 'dec');
|
||||
if (ra !== null) payload.ra_deg = ra;
|
||||
if (dec !== null) payload.dec_deg = dec;
|
||||
}
|
||||
return fetch(`${base}/custom-targets`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['custom-targets'] });
|
||||
setShowAddForm(false);
|
||||
setForm({ id: '', name: '', ra_h: '', dec_dms: '', notes: '', obj_type: 'custom', tle_line1: '', tle_line2: '' });
|
||||
},
|
||||
});
|
||||
|
||||
function parseCoord(s: string, type: 'ra' | 'dec'): number | null {
|
||||
const trimmed = s.trim();
|
||||
if (!trimmed) return null;
|
||||
// Try decimal
|
||||
const dec = parseFloat(trimmed);
|
||||
if (!isNaN(dec)) return dec;
|
||||
// Try HH:MM:SS or DD:MM:SS
|
||||
const parts = trimmed.replace(/[hdm°′"s]/g, ':').split(':').map(p => parseFloat(p.trim()));
|
||||
if (parts.length >= 2 && !parts.some(isNaN)) {
|
||||
const sign = trimmed.startsWith('-') ? -1 : 1;
|
||||
const abs = Math.abs(parts[0]) + (parts[1] ?? 0) / 60 + (parts[2] ?? 0) / 3600;
|
||||
return type === 'ra' ? abs * 15 : sign * abs;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const ssoObjects = ssoData?.objects ?? [];
|
||||
const customObjects = customData?.items ?? [];
|
||||
|
||||
const colStyle = {
|
||||
padding: '6px 10px',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 11,
|
||||
textAlign: 'left' as const,
|
||||
borderBottom: '1px solid var(--border)',
|
||||
};
|
||||
const headStyle = {
|
||||
...colStyle,
|
||||
fontSize: 10,
|
||||
color: 'var(--text-lo)',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.07em',
|
||||
textTransform: 'uppercase' as const,
|
||||
borderBottom: '1px solid var(--border-hi)',
|
||||
padding: '5px 10px',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px 28px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 20 }}>
|
||||
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 20, fontWeight: 700, color: 'var(--text-hi)', letterSpacing: '0.04em' }}>
|
||||
Solar System
|
||||
</h1>
|
||||
{ssoData?.computed_at && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)' }}>
|
||||
computed {new Date(ssoData.computed_at).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' })}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', marginLeft: 4 }}>
|
||||
· updates every 60s
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>Computing positions…</div>
|
||||
)}
|
||||
|
||||
{/* Planets & Moon table */}
|
||||
{ssoObjects.length > 0 && (
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, marginBottom: 24, overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{['', 'Object', 'RA', 'Dec', 'Alt', 'Az', 'Airmass', 'Mag', 'Size″', 'Phase', 'Dist AU', 'Elong'].map(h => (
|
||||
<th key={h} style={headStyle}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ssoObjects.filter(o => o.id !== 'sun').map(obj => (
|
||||
<tr key={obj.id} style={{
|
||||
background: 'var(--bg-row)',
|
||||
opacity: obj.is_visible ? 1 : 0.4,
|
||||
transition: 'background 0.1s',
|
||||
}}>
|
||||
<td style={{ ...colStyle, width: 60 }}><TypeBadge type={obj.obj_type} /></td>
|
||||
<td style={{ ...colStyle, fontWeight: 600, color: 'var(--text-hi)', minWidth: 80 }}>{obj.name}</td>
|
||||
<td style={{ ...colStyle, color: 'var(--text-mid)' }}>{obj.ra_h}</td>
|
||||
<td style={{ ...colStyle, color: 'var(--text-mid)' }}>{obj.dec_dms}</td>
|
||||
<td style={{ ...colStyle, fontWeight: 600, color: altColor(obj.alt_deg) }}>
|
||||
{obj.alt_deg.toFixed(1)}°
|
||||
{!obj.is_visible && obj.alt_deg > 0 && <span style={{ color: 'var(--text-lo)', fontSize: 10 }}> ↑</span>}
|
||||
{obj.alt_deg < 0 && <span style={{ color: 'var(--text-lo)', fontSize: 10 }}> ↓</span>}
|
||||
</td>
|
||||
<td style={{ ...colStyle, color: 'var(--text-mid)' }}>{obj.az_deg.toFixed(1)}°</td>
|
||||
<td style={{ ...colStyle, color: 'var(--text-mid)' }}>
|
||||
{obj.is_visible ? obj.airmass.toFixed(2) : '—'}
|
||||
</td>
|
||||
<td style={{ ...colStyle, color: 'var(--text-hi)' }}>
|
||||
{obj.mag_v != null ? obj.mag_v.toFixed(1) : '—'}
|
||||
</td>
|
||||
<td style={{ ...colStyle, color: 'var(--text-mid)' }}>
|
||||
{obj.angular_size_arcsec != null ? obj.angular_size_arcsec.toFixed(1) : '—'}
|
||||
</td>
|
||||
<td style={{ ...colStyle, color: 'var(--text-mid)' }}>
|
||||
{obj.phase_pct != null ? `${obj.phase_pct.toFixed(0)}%` : '—'}
|
||||
</td>
|
||||
<td style={{ ...colStyle, color: 'var(--text-mid)' }}>
|
||||
{obj.distance_au != null ? obj.distance_au.toFixed(2) : '—'}
|
||||
</td>
|
||||
<td style={{ ...colStyle, color: (obj.elongation_deg ?? 0) < 30 ? 'var(--warn)' : 'var(--text-mid)' }}>
|
||||
{obj.elongation_deg != null ? `${obj.elongation_deg.toFixed(1)}°` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom targets section */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 14, fontWeight: 700, color: 'var(--text-mid)' }}>
|
||||
Custom Targets
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowAddForm(v => !v)}
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 11,
|
||||
color: 'var(--amber)',
|
||||
border: '1px solid var(--amber-dim)',
|
||||
borderRadius: 3,
|
||||
padding: '3px 10px',
|
||||
background: 'var(--amber-glow)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
+ Add target
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add form */}
|
||||
{showAddForm && (
|
||||
<div style={{
|
||||
background: 'var(--bg-panel)',
|
||||
border: '1px solid var(--border-hi)',
|
||||
borderRadius: 6,
|
||||
padding: '16px 20px',
|
||||
marginBottom: 16,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: 12,
|
||||
}}>
|
||||
<div>
|
||||
<label style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', display: 'block', marginBottom: 4 }}>ID (unique)</label>
|
||||
<input value={form.id} onChange={e => setForm(f => ({ ...f, id: e.target.value }))} placeholder="ISS or MyComet" style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', display: 'block', marginBottom: 4 }}>Name</label>
|
||||
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="My Object" style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', display: 'block', marginBottom: 4 }}>Type</label>
|
||||
<select value={form.obj_type} onChange={e => setForm(f => ({ ...f, obj_type: e.target.value }))} style={{ width: '100%' }}>
|
||||
<option value="custom">Custom</option>
|
||||
<option value="satellite">Satellite</option>
|
||||
<option value="comet">Comet</option>
|
||||
<option value="asteroid">Asteroid</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', display: 'block', marginBottom: 4 }}>Notes</label>
|
||||
<input value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} placeholder="optional notes" style={{ width: '100%' }} />
|
||||
</div>
|
||||
|
||||
{/* Coordinate mode toggle */}
|
||||
<div style={{ gridColumn: '1/-1' }}>
|
||||
<div style={{ display: 'flex', gap: 0, marginBottom: 10 }}>
|
||||
{(['radec', 'tle'] as const).map(mode => (
|
||||
<button key={mode} onClick={() => setCoordMode(mode)} style={{
|
||||
padding: '4px 14px',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 11,
|
||||
background: coordMode === mode ? 'var(--amber-glow)' : 'var(--bg-deep)',
|
||||
color: coordMode === mode ? 'var(--amber)' : 'var(--text-lo)',
|
||||
border: `1px solid ${coordMode === mode ? 'var(--amber-dim)' : 'var(--border)'}`,
|
||||
borderRadius: mode === 'radec' ? '3px 0 0 3px' : '0 3px 3px 0',
|
||||
cursor: 'pointer',
|
||||
}}>
|
||||
{mode === 'radec' ? 'RA/Dec coordinates' : 'TLE (satellite / comet)'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{coordMode === 'radec' ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', display: 'block', marginBottom: 4 }}>RA (decimal° or HH:MM:SS)</label>
|
||||
<input value={form.ra_h} onChange={e => setForm(f => ({ ...f, ra_h: e.target.value }))} placeholder="10.684 or 00:42:44" style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', display: 'block', marginBottom: 4 }}>Dec (decimal° or ±DD:MM:SS)</label>
|
||||
<input value={form.dec_dms} onChange={e => setForm(f => ({ ...f, dec_dms: e.target.value }))} placeholder="41.269 or +41:16:09" style={{ width: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', marginBottom: 2 }}>
|
||||
Paste TLE from <a href="https://celestrak.org/SOCRATES/" target="_blank" rel="noreferrer" style={{ color: 'var(--blue)' }}>CelesTrak</a> or <a href="https://heavens-above.com" target="_blank" rel="noreferrer" style={{ color: 'var(--blue)' }}>Heavens-Above</a>. Position updates every 60s.
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', display: 'block', marginBottom: 4 }}>TLE Line 1</label>
|
||||
<input
|
||||
value={form.tle_line1}
|
||||
onChange={e => setForm(f => ({ ...f, tle_line1: e.target.value }))}
|
||||
placeholder="1 25544U 98067A 24001.50000000 .00001234 00000-0 12345-4 0 9999"
|
||||
style={{ width: '100%', fontFamily: 'var(--font-mono)', fontSize: 10 }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', display: 'block', marginBottom: 4 }}>TLE Line 2</label>
|
||||
<input
|
||||
value={form.tle_line2}
|
||||
onChange={e => setForm(f => ({ ...f, tle_line2: e.target.value }))}
|
||||
placeholder="2 25544 51.6416 12.3456 0001234 12.3456 347.6543 15.49507895123456"
|
||||
style={{ width: '100%', fontFamily: 'var(--font-mono)', fontSize: 10 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ gridColumn: '1/-1', display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => setShowAddForm(false)} style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', padding: '4px 14px', border: '1px solid var(--border)', borderRadius: 3, cursor: 'pointer' }}>Cancel</button>
|
||||
<button
|
||||
onClick={() => createMutation.mutate(form)}
|
||||
disabled={!form.id || !form.name}
|
||||
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: '#111', background: 'var(--amber)', padding: '4px 14px', borderRadius: 3, cursor: 'pointer', opacity: (!form.id || !form.name) ? 0.5 : 1 }}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom targets table */}
|
||||
{customObjects.length > 0 && (
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{['Type', 'Name', 'RA', 'Dec', 'Alt now', 'Notes', ''].map(h => (
|
||||
<th key={h} style={headStyle}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{customObjects.map(obj => (
|
||||
<tr key={obj.id} style={{ background: 'var(--bg-row)', borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={colStyle}><TypeBadge type={obj.obj_type} /></td>
|
||||
<td style={{ ...colStyle, fontWeight: 600, color: 'var(--text-hi)' }}>
|
||||
{obj.name}
|
||||
<div style={{ fontSize: 10, color: 'var(--text-lo)', display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
{obj.id}
|
||||
{obj.has_tle && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 8, background: 'var(--amber-glow)', border: '1px solid var(--amber-dim)', color: 'var(--amber)', padding: '0 4px', borderRadius: 2 }}>
|
||||
TLE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ ...colStyle, color: 'var(--text-mid)' }}>{obj.ra_h ?? (obj.has_tle ? <span style={{ color: 'var(--danger)', fontSize: 10 }}>TLE error</span> : '—')}</td>
|
||||
<td style={{ ...colStyle, color: 'var(--text-mid)' }}>{obj.dec_dms ?? '—'}</td>
|
||||
<td style={{ ...colStyle }}>
|
||||
{obj.alt_deg != null ? (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600, color: altColor(obj.alt_deg) }}>
|
||||
{obj.alt_deg.toFixed(1)}°
|
||||
</span>
|
||||
) : <span style={{ color: 'var(--text-lo)' }}>—</span>}
|
||||
</td>
|
||||
<td style={{ ...colStyle, color: 'var(--text-lo)', fontSize: 11 }}>{obj.notes ?? ''}</td>
|
||||
<td style={{ ...colStyle, width: 60 }}>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(obj.id)}
|
||||
style={{ color: 'var(--danger)', fontFamily: 'var(--font-mono)', fontSize: 11, cursor: 'pointer' }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{customObjects.length === 0 && !showAddForm && (
|
||||
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12, padding: '12px 0' }}>
|
||||
No custom targets yet. Add any RA/Dec object or comet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||
PieChart, Pie, Cell, ScatterChart, Scatter, CartesianGrid, LineChart, Line,
|
||||
} from 'recharts';
|
||||
import { useStats } from '../hooks/useStats';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../api';
|
||||
import type { Phd2Log } from '../api/types';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
const FILTER_COLORS: Record<string, string> = {
|
||||
sv220: '#9b59b6',
|
||||
c2: '#4d9de0',
|
||||
sv260: '#e8832a',
|
||||
uvir: '#3dba72',
|
||||
};
|
||||
|
||||
function PHD2Section() {
|
||||
const qc = useQueryClient();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadResult, setUploadResult] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState<number | null>(null);
|
||||
const { data } = useQuery({
|
||||
queryKey: ['phd2'],
|
||||
queryFn: () => api.phd2.list(),
|
||||
});
|
||||
const items = data?.items ?? [];
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
setUploading(true);
|
||||
setUploadResult(null);
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
try {
|
||||
const res = await api.phd2.upload(fd) as any;
|
||||
if (res.duplicate) {
|
||||
setUploadResult('⚠ Duplicate session - not imported');
|
||||
} else {
|
||||
const analysis = res.analysis;
|
||||
const details: string[] = [];
|
||||
details.push(`RMS ${analysis.rms_total_arcsec.toFixed(2)}″`);
|
||||
if (analysis.duration_min) details.push(`${analysis.duration_min}m`);
|
||||
if (analysis.camera_name) details.push(analysis.camera_name);
|
||||
setUploadResult(`✓ Uploaded: ${details.join(' · ')}`);
|
||||
qc.invalidateQueries({ queryKey: ['phd2'] });
|
||||
qc.invalidateQueries({ queryKey: ['stats'] });
|
||||
}
|
||||
} catch {
|
||||
setUploadResult('✗ Upload failed');
|
||||
}
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Delete this PHD2 session?')) return;
|
||||
setDeleting(id);
|
||||
try {
|
||||
await api.phd2.delete(id);
|
||||
qc.invalidateQueries({ queryKey: ['phd2'] });
|
||||
qc.invalidateQueries({ queryKey: ['stats'] });
|
||||
} catch (e) {
|
||||
alert(`Failed to delete: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
||||
}
|
||||
setDeleting(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden', marginTop: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700, flex: 1 }}>PHD2 Guiding Sessions</div>
|
||||
<div
|
||||
onClick={() => !uploading && inputRef.current?.click()}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 3,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 11,
|
||||
color: 'var(--text-mid)',
|
||||
cursor: uploading ? 'default' : 'pointer',
|
||||
background: 'var(--bg-deep)',
|
||||
}}
|
||||
>
|
||||
{uploading ? 'Parsing…' : '↑ Upload .log'}
|
||||
</div>
|
||||
<input ref={inputRef} type="file" accept=".log,.csv" style={{ display: 'none' }}
|
||||
onChange={e => e.target.files?.[0] && handleFile(e.target.files[0])} />
|
||||
</div>
|
||||
{uploadResult && (
|
||||
<div style={{ padding: '6px 16px', fontFamily: 'var(--font-mono)', fontSize: 11,
|
||||
color: uploadResult.startsWith('✓') ? 'var(--good)' : uploadResult.startsWith('⚠') ? 'var(--warn)' : 'var(--danger)',
|
||||
borderBottom: '1px solid var(--border)', background: 'var(--bg-deep)' }}>
|
||||
{uploadResult}
|
||||
</div>
|
||||
)}
|
||||
{items.length === 0 ? (
|
||||
<div style={{ padding: 16, color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||
No PHD2 logs imported yet. Upload a .log file to analyze guiding performance.
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
{['Date', 'File', 'Duration', 'RMS Total', 'RMS RA', 'RMS Dec', 'Peak', 'Lost', 'SNR', ''].map(h => (
|
||||
<th key={h} style={{ padding: '6px 10px', textAlign: 'left', fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', fontWeight: 500, letterSpacing: '0.05em', whiteSpace: 'nowrap' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((log: Phd2Log) => {
|
||||
const rms = log.rms_total ?? 0;
|
||||
const rmsColor = rms < 0.7 ? 'var(--good)' : rms < 1.2 ? 'var(--warn)' : 'var(--danger)';
|
||||
return (
|
||||
<tr key={log.id} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-hi)', whiteSpace: 'nowrap' }}>{log.session_date}</td>
|
||||
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
title={log.filename}>{log.filename}</td>
|
||||
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', whiteSpace: 'nowrap' }}>
|
||||
{log.duration_min ? `${log.duration_min}m` : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 700, color: rmsColor, whiteSpace: 'nowrap' }}>
|
||||
{log.rms_total ? `${log.rms_total.toFixed(2)}″` : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', whiteSpace: 'nowrap' }}>
|
||||
{log.rms_ra ? `${log.rms_ra.toFixed(2)}″` : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', whiteSpace: 'nowrap' }}>
|
||||
{log.rms_dec ? `${log.rms_dec.toFixed(2)}″` : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: (log.peak_error ?? 0) > 2 ? 'var(--warn)' : 'var(--text-mid)', whiteSpace: 'nowrap' }}>
|
||||
{log.peak_error ? `${log.peak_error.toFixed(2)}″` : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: (log.star_lost_count ?? 0) > 5 ? 'var(--danger)' : 'var(--text-mid)', whiteSpace: 'nowrap' }}>
|
||||
{log.star_lost_count ?? 0}
|
||||
</td>
|
||||
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', whiteSpace: 'nowrap' }}>
|
||||
{log.guide_star_snr ? log.guide_star_snr.toFixed(1) : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<button
|
||||
onClick={() => handleDelete(log.id)}
|
||||
disabled={deleting === log.id}
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
background: 'var(--danger)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 2,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 10,
|
||||
cursor: deleting === log.id ? 'default' : 'pointer',
|
||||
opacity: deleting === log.id ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{deleting === log.id ? '…' : '×'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Stats() {
|
||||
const { data: stats, isLoading } = useStats();
|
||||
|
||||
if (isLoading || !stats) {
|
||||
return (
|
||||
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||
Loading statistics…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalH = (stats.total_integration_min / 60).toFixed(1);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}>
|
||||
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 22 }}>Statistics</h1>
|
||||
<button
|
||||
onClick={() => api.log.exportCsv()}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
padding: '5px 14px',
|
||||
background: 'var(--bg-panel)',
|
||||
border: '1px solid var(--border-hi)',
|
||||
borderRadius: 4,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 11,
|
||||
color: 'var(--text-mid)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
↓ Export Log CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Header stat cards */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 14, marginBottom: 28 }}>
|
||||
{[
|
||||
{ label: 'Total Sessions', value: stats.total_sessions },
|
||||
{ label: 'Integration Time', value: `${totalH}h` },
|
||||
{ label: 'Objects Imaged', value: stats.objects_with_keeper },
|
||||
{ label: 'Filter Types Used', value: stats.filter_usage.length },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 18px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 6 }}>{label}</div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 24, fontWeight: 700 }}>{value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20, marginBottom: 24 }}>
|
||||
{/* Integration per month */}
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700, marginBottom: 12 }}>Integration per Month</div>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={stats.monthly}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="month" tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }} tickLine={false} />
|
||||
<YAxis tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }} tickLine={false} tickFormatter={v => `${Math.round(v/60)}h`} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', fontFamily: 'IBM Plex Mono', fontSize: 11 }}
|
||||
formatter={(v: number) => [`${(v / 60).toFixed(1)}h`, 'Integration']}
|
||||
/>
|
||||
<Bar dataKey="total_min" fill="var(--amber)" radius={[2, 2, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Filter usage pie */}
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700, marginBottom: 12 }}>Filter Usage</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<ResponsiveContainer width={160} height={160}>
|
||||
<PieChart>
|
||||
<Pie data={stats.filter_usage} dataKey="total_min" nameKey="filter_id" cx="50%" cy="50%" outerRadius={70} innerRadius={40}>
|
||||
{stats.filter_usage.map((entry) => (
|
||||
<Cell key={entry.filter_id} fill={FILTER_COLORS[entry.filter_id] ?? '#888'} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: number) => `${(v / 60).toFixed(1)}h`} contentStyle={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', fontSize: 11 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div>
|
||||
{stats.filter_usage.map(f => (
|
||||
<div key={f.filter_id} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 2, background: FILTER_COLORS[f.filter_id] ?? '#888' }} />
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)' }}>
|
||||
{f.filter_id.toUpperCase()} — {(f.total_min / 60).toFixed(1)}h
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Object type breakdown */}
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700, marginBottom: 12 }}>By Object Type</div>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={stats.by_type} layout="vertical">
|
||||
<XAxis type="number" tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }} tickLine={false} tickFormatter={v => `${Math.round(v/60)}h`} />
|
||||
<YAxis type="category" dataKey="obj_type" tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }} tickLine={false} width={90} />
|
||||
<Tooltip contentStyle={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', fontSize: 11 }} formatter={(v: number) => `${(v / 60).toFixed(1)}h`} />
|
||||
<Bar dataKey="total_min" fill="var(--teal)" radius={[0, 2, 2, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Guiding RMS over time */}
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700, marginBottom: 12 }}>Guiding RMS over Time</div>
|
||||
{stats.guiding.length === 0 ? (
|
||||
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>No PHD2 logs imported yet.</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<LineChart data={stats.guiding.map(g => ({ ...g, rms_total: g.rms_total ?? null, rms_ra: g.rms_ra ?? null, rms_dec: g.rms_dec ?? null }))}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="date" tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }} tickLine={false} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }} tickLine={false} tickFormatter={v => `${v}″`} />
|
||||
<Tooltip contentStyle={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', fontFamily: 'IBM Plex Mono', fontSize: 11 }} formatter={(v: number) => `${v.toFixed(2)}″`} />
|
||||
<Line type="monotone" dataKey="rms_total" stroke="var(--blue)" strokeWidth={2} dot={{ r: 3, fill: 'var(--blue)' }} name="Total RMS" connectNulls={false} />
|
||||
<Line type="monotone" dataKey="rms_ra" stroke="var(--amber)" strokeWidth={1} dot={false} strokeDasharray="3 2" name="RA RMS" connectNulls={false} />
|
||||
<Line type="monotone" dataKey="rms_dec" stroke="var(--teal)" strokeWidth={1} dot={false} strokeDasharray="3 2" name="Dec RMS" connectNulls={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top targets */}
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700 }}>
|
||||
Most Integrated Targets
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
{['Name', 'Type', 'Sessions', 'Integration'].map(h => (
|
||||
<th key={h} style={{ padding: '6px 12px', textAlign: 'left', fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', fontWeight: 500, letterSpacing: '0.06em' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.top_targets.map(t => (
|
||||
<tr key={t.id} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '7px 12px', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-hi)' }}>
|
||||
{t.common_name ?? t.name}
|
||||
{t.common_name && <span style={{ color: 'var(--text-lo)', marginLeft: 6 }}>{t.name}</span>}
|
||||
</td>
|
||||
<td style={{ padding: '7px 12px', fontSize: 11, color: 'var(--text-mid)' }}>{t.obj_type}</td>
|
||||
<td style={{ padding: '7px 12px', fontFamily: 'var(--font-mono)', fontSize: 11 }}>{t.sessions}</td>
|
||||
<td style={{ padding: '7px 12px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--amber)' }}>
|
||||
{(t.total_min / 60).toFixed(1)}h
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Quality breakdown */}
|
||||
{stats.quality && stats.quality.length > 0 && (
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden', marginTop: 20 }}>
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700 }}>
|
||||
Session Quality Breakdown
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 0 }}>
|
||||
{stats.quality.map(q => {
|
||||
const colorMap: Record<string, string> = {
|
||||
keeper: 'var(--good)',
|
||||
needs_more: 'var(--blue)',
|
||||
rejected: 'var(--danger)',
|
||||
pending: 'var(--muted)',
|
||||
};
|
||||
const color = colorMap[q.quality] ?? 'var(--text-mid)';
|
||||
const total = stats.quality.reduce((s, x) => s + x.count, 0);
|
||||
return (
|
||||
<div key={q.quality} style={{
|
||||
flex: q.count,
|
||||
padding: '10px 14px',
|
||||
borderRight: '1px solid var(--border)',
|
||||
minWidth: 80,
|
||||
}}>
|
||||
<div className={`quality-chip ${q.quality}`} style={{ marginBottom: 4 }}>
|
||||
{q.quality.replace('_', ' ')}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 18, fontWeight: 700, color }}>
|
||||
{q.count}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
|
||||
{total > 0 ? Math.round((q.count / total) * 100) : 0}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PHD2Section />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useState, Fragment } from 'react';
|
||||
import { useTargets } from '../hooks/useTargets';
|
||||
import TargetRow from '../components/targets/TargetRow';
|
||||
import DetailDrawer from '../components/targets/DetailDrawer';
|
||||
import type { Target } from '../api/types';
|
||||
|
||||
const OBJ_TYPES = ['All', 'galaxy', 'emission_nebula', 'reflection_nebula', 'planetary_nebula', 'snr', 'open_cluster', 'globular_cluster', 'dark_nebula'];
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
galaxy: 'Galaxy', emission_nebula: 'Emission', reflection_nebula: 'Reflection',
|
||||
planetary_nebula: 'Planetary', snr: 'SNR', open_cluster: 'Cluster',
|
||||
globular_cluster: 'Globular', dark_nebula: 'Dark',
|
||||
};
|
||||
const FILTERS = [
|
||||
{ id: '', label: 'Any filter' },
|
||||
{ id: 'sv220', label: 'HaOIII (SV220)' },
|
||||
{ id: 'c2', label: 'SIIOIII (C2)' },
|
||||
{ id: 'sv260', label: 'LP (SV260)' },
|
||||
{ id: 'uvir', label: 'UV/IR Cut' },
|
||||
];
|
||||
const SORT_OPTIONS = [
|
||||
{ value: '', label: 'Best alt tonight' },
|
||||
{ value: 'transit', label: 'Transit time' },
|
||||
{ value: 'size', label: 'Size (largest)' },
|
||||
{ value: 'magnitude', label: 'Magnitude' },
|
||||
{ value: 'difficulty', label: 'Difficulty' },
|
||||
{ value: 'integration', label: 'Total integration' },
|
||||
];
|
||||
const STATUS_OPTIONS = [
|
||||
{ id: 'tonight', label: 'Tonight only' },
|
||||
{ id: 'not_imaged', label: 'Not yet imaged' },
|
||||
{ id: 'mosaic_only', label: 'Mosaics only' },
|
||||
];
|
||||
|
||||
const COL_HEADERS = ['Type', 'Name', 'Size', 'Fill', 'Mosaic', 'Mag', '★', 'Filter', 'Alt', 'Vis', 'Int', 'Goal'];
|
||||
|
||||
function Chip({ active, color, onClick, children }: { active: boolean; color?: string; onClick: () => void; children: React.ReactNode }) {
|
||||
const c = color ?? 'var(--amber)';
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: '3px 10px',
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${active ? c : 'var(--border)'}`,
|
||||
background: active ? `${c}22` : 'var(--bg-panel)',
|
||||
color: active ? c : 'var(--text-mid)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 11,
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Targets() {
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
const [filterPill, setFilterPill] = useState('');
|
||||
const [tonight, setTonight] = useState(true);
|
||||
const [notImaged, setNotImaged] = useState(false);
|
||||
const [mosaicOnly, setMosaicOnly] = useState(false);
|
||||
const [minAlt, setMinAlt] = useState<number | undefined>(undefined);
|
||||
const [minUsable, setMinUsable] = useState<number | undefined>(undefined);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState('');
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const { data, isLoading } = useTargets({
|
||||
type: typeFilter || undefined,
|
||||
filter: filterPill || undefined,
|
||||
tonight,
|
||||
not_imaged: notImaged || undefined,
|
||||
mosaic_only: mosaicOnly || undefined,
|
||||
min_alt_deg: minAlt,
|
||||
min_usable_min: minUsable,
|
||||
search: search || undefined,
|
||||
sort: sort || undefined,
|
||||
page,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
setExpandedId(prev => prev === id ? null : id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 22, marginBottom: 12 }}>Targets</h1>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
background: 'var(--bg-void)',
|
||||
padding: '8px 0 10px',
|
||||
zIndex: 10,
|
||||
borderBottom: '1px solid var(--border)',
|
||||
marginBottom: 10,
|
||||
}}>
|
||||
{/* Row 1: object types */}
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 6 }}>
|
||||
{OBJ_TYPES.map(t => (
|
||||
<Chip
|
||||
key={t}
|
||||
active={(t === 'All' && !typeFilter) || t === typeFilter}
|
||||
color="var(--amber)"
|
||||
onClick={() => setTypeFilter(t === 'All' ? '' : t)}
|
||||
>
|
||||
{TYPE_LABELS[t] ?? t}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Row 2: filters + sort + status + search */}
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{/* Filter fit */}
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', marginRight: 2 }}>FILTER</span>
|
||||
{FILTERS.map(f => (
|
||||
<Chip
|
||||
key={f.id}
|
||||
active={f.id === filterPill}
|
||||
color="var(--blue)"
|
||||
onClick={() => setFilterPill(f.id === filterPill ? '' : f.id)}
|
||||
>
|
||||
{f.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ width: 1, background: 'var(--border)', height: 16, margin: '0 2px' }} />
|
||||
|
||||
{/* Status toggles */}
|
||||
<Chip active={tonight} color="var(--good)" onClick={() => setTonight(v => !v)}>
|
||||
Tonight
|
||||
</Chip>
|
||||
<Chip active={notImaged} color="var(--teal)" onClick={() => setNotImaged(v => !v)}>
|
||||
Not imaged
|
||||
</Chip>
|
||||
<Chip active={mosaicOnly} color="var(--warn)" onClick={() => setMosaicOnly(v => !v)}>
|
||||
Mosaics only
|
||||
</Chip>
|
||||
|
||||
<div style={{ width: 1, background: 'var(--border)', height: 16, margin: '0 2px' }} />
|
||||
|
||||
{/* Min alt filter */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>MIN ALT</span>
|
||||
<select
|
||||
value={minAlt ?? ''}
|
||||
onChange={e => setMinAlt(e.target.value ? Number(e.target.value) : undefined)}
|
||||
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, padding: '2px 6px', width: 70 }}
|
||||
>
|
||||
<option value="">Any</option>
|
||||
<option value="20">20°</option>
|
||||
<option value="30">30°</option>
|
||||
<option value="40">40°</option>
|
||||
<option value="50">50°</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Min usable */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>MIN TIME</span>
|
||||
<select
|
||||
value={minUsable ?? ''}
|
||||
onChange={e => setMinUsable(e.target.value ? Number(e.target.value) : undefined)}
|
||||
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, padding: '2px 6px', width: 80 }}
|
||||
>
|
||||
<option value="">Any</option>
|
||||
<option value="60">1h</option>
|
||||
<option value="120">2h</option>
|
||||
<option value="180">3h</option>
|
||||
<option value="240">4h</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ width: 1, background: 'var(--border)', height: 16, margin: '0 2px' }} />
|
||||
|
||||
{/* Sort */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>SORT</span>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={e => setSort(e.target.value)}
|
||||
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, padding: '2px 6px' }}
|
||||
>
|
||||
{SORT_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search name, NGC, M42, Andromeda…"
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
||||
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, padding: '3px 10px', width: 220 }}
|
||||
/>
|
||||
{search && (
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', whiteSpace: 'nowrap' }}>
|
||||
tonight filter bypassed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)' }}>
|
||||
{data?.total ?? 0} objects
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12, padding: 16 }}>
|
||||
Loading…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{data && (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border-hi)' }}>
|
||||
{COL_HEADERS.map(h => (
|
||||
<th key={h} style={{
|
||||
padding: '4px 8px',
|
||||
textAlign: 'left',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 10,
|
||||
color: 'var(--text-lo)',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((target: Target) => (
|
||||
<Fragment key={target.id}>
|
||||
<TargetRow
|
||||
target={target}
|
||||
expanded={expandedId === target.id}
|
||||
onToggle={() => toggleExpand(target.id)}
|
||||
/>
|
||||
{expandedId === target.id && (
|
||||
<tr>
|
||||
<td colSpan={COL_HEADERS.length} style={{ padding: 0 }}>
|
||||
<DetailDrawer target={target} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
@import './tokens.css';
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-void);
|
||||
color: var(--text-hi);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
min-width: 1280px;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
code, kbd, pre, .mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--amber);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-hi);
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
border-color: var(--amber-dim);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track { background: var(--bg-deep); }
|
||||
::-webkit-scrollbar-thumb { background: var(--muted); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-lo); }
|
||||
|
||||
/* Utility classes */
|
||||
.text-hi { color: var(--text-hi); }
|
||||
.text-mid { color: var(--text-mid); }
|
||||
.text-lo { color: var(--text-lo); }
|
||||
.text-amber { color: var(--amber); }
|
||||
.text-good { color: var(--good); }
|
||||
.text-warn { color: var(--warn); }
|
||||
.text-danger { color: var(--danger); }
|
||||
|
||||
.mono { font-family: var(--font-mono); }
|
||||
.display { font-family: var(--font-display); }
|
||||
|
||||
/* Type badge pills */
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.type-badge.galaxy { background: var(--type-galaxy); color: #fff; }
|
||||
.type-badge.emission_nebula { background: var(--type-emission); color: #fff; }
|
||||
.type-badge.planetary_nebula { background: var(--type-planetary); color: #fff; }
|
||||
.type-badge.snr { background: var(--type-snr); color: #fff; }
|
||||
.type-badge.globular_cluster { background: var(--type-globular); color: #fff; }
|
||||
.type-badge.open_cluster { background: var(--type-open); color: #111; }
|
||||
.type-badge.reflection_nebula { background: var(--type-reflection); color: #fff; }
|
||||
.type-badge.dark_nebula { background: var(--type-dark); color: var(--text-mid); }
|
||||
.type-badge.nebula { background: var(--teal); color: #fff; }
|
||||
|
||||
/* Quality chips */
|
||||
.quality-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.quality-chip.keeper { background: var(--good); color: #fff; }
|
||||
.quality-chip.needs_more { background: var(--blue); color: #fff; }
|
||||
.quality-chip.rejected { background: var(--danger); color: #fff; }
|
||||
.quality-chip.pending { background: var(--muted); color: var(--text-mid); }
|
||||
|
||||
/* Filter pills */
|
||||
.filter-pill {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.filter-pill.sv220 { background: rgba(155, 89, 182, 0.3); color: #c39dde; border: 1px solid rgba(155,89,182,0.4); }
|
||||
.filter-pill.c2 { background: rgba(77, 157, 224, 0.3); color: #8cbee8; border: 1px solid rgba(77,157,224,0.4); }
|
||||
.filter-pill.sv260 { background: rgba(232, 131, 42, 0.3); color: #e8a870; border: 1px solid rgba(232,131,42,0.4); }
|
||||
.filter-pill.uvir { background: rgba(61, 186, 114, 0.3); color: #7dd1a1; border: 1px solid rgba(61,186,114,0.4); }
|
||||
@@ -0,0 +1,48 @@
|
||||
:root {
|
||||
/* Backgrounds */
|
||||
--bg-void: #080a0f;
|
||||
--bg-deep: #0d1017;
|
||||
--bg-panel: #111520;
|
||||
--bg-row: #141825;
|
||||
--bg-hover: #1a2035;
|
||||
|
||||
/* Accent palette */
|
||||
--amber: #e8832a;
|
||||
--amber-dim: #7a4415;
|
||||
--amber-glow: rgba(232, 131, 42, 0.12);
|
||||
--blue: #4d9de0;
|
||||
--blue-dim: #1a3d5c;
|
||||
--teal: #2ab8a0;
|
||||
|
||||
/* Semantic */
|
||||
--good: #3dba72;
|
||||
--warn: #e8c030;
|
||||
--danger: #e05252;
|
||||
--info: #4d9de0;
|
||||
--muted: #3a4258;
|
||||
|
||||
/* Text */
|
||||
--text-hi: #edf0f5;
|
||||
--text-mid: #8892a8;
|
||||
--text-lo: #4a5268;
|
||||
--text-amber: #e8832a;
|
||||
|
||||
/* Borders */
|
||||
--border: #1e2538;
|
||||
--border-hi: #2e3858;
|
||||
|
||||
/* Type */
|
||||
--font-display: 'Syne', sans-serif;
|
||||
--font-mono: 'IBM Plex Mono', monospace;
|
||||
--font-sans: 'IBM Plex Sans', sans-serif;
|
||||
|
||||
/* Object type colors */
|
||||
--type-galaxy: #4d9de0;
|
||||
--type-emission: #2ab8a0;
|
||||
--type-planetary: #3dba72;
|
||||
--type-snr: #e8832a;
|
||||
--type-globular: #9b59b6;
|
||||
--type-open: #f1c40f;
|
||||
--type-reflection: #e67e22;
|
||||
--type-dark: #3a4258;
|
||||
}
|
||||
Reference in New Issue
Block a user