Initial Commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user