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,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>
);
}