Initial Commit

This commit is contained in:
2026-04-09 23:23:31 +02:00
commit 9223e4d35f
94 changed files with 15173 additions and 0 deletions
@@ -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: '3050° good' },
{ color: 'var(--warn)', label: '1530° 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>
);
}
+203
View File
@@ -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>
);
}
+183
View File
@@ -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>
);
}
+124
View File
@@ -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.01.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: '06% (Clear)', 2: '619%', 3: '1931%', 4: '3144%',
5: '4456%', 6: '5669%', 7: '6981%', 8: '8194%', 9: '94100% (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>
);
}