Initial Commit
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||
PieChart, Pie, Cell, ScatterChart, Scatter, CartesianGrid, LineChart, Line,
|
||||
} from 'recharts';
|
||||
import { useStats } from '../hooks/useStats';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../api';
|
||||
import type { Phd2Log } from '../api/types';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
const FILTER_COLORS: Record<string, string> = {
|
||||
sv220: '#9b59b6',
|
||||
c2: '#4d9de0',
|
||||
sv260: '#e8832a',
|
||||
uvir: '#3dba72',
|
||||
};
|
||||
|
||||
function PHD2Section() {
|
||||
const qc = useQueryClient();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadResult, setUploadResult] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState<number | null>(null);
|
||||
const { data } = useQuery({
|
||||
queryKey: ['phd2'],
|
||||
queryFn: () => api.phd2.list(),
|
||||
});
|
||||
const items = data?.items ?? [];
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
setUploading(true);
|
||||
setUploadResult(null);
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
try {
|
||||
const res = await api.phd2.upload(fd) as any;
|
||||
if (res.duplicate) {
|
||||
setUploadResult('⚠ Duplicate session - not imported');
|
||||
} else {
|
||||
const analysis = res.analysis;
|
||||
const details: string[] = [];
|
||||
details.push(`RMS ${analysis.rms_total_arcsec.toFixed(2)}″`);
|
||||
if (analysis.duration_min) details.push(`${analysis.duration_min}m`);
|
||||
if (analysis.camera_name) details.push(analysis.camera_name);
|
||||
setUploadResult(`✓ Uploaded: ${details.join(' · ')}`);
|
||||
qc.invalidateQueries({ queryKey: ['phd2'] });
|
||||
qc.invalidateQueries({ queryKey: ['stats'] });
|
||||
}
|
||||
} catch {
|
||||
setUploadResult('✗ Upload failed');
|
||||
}
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Delete this PHD2 session?')) return;
|
||||
setDeleting(id);
|
||||
try {
|
||||
await api.phd2.delete(id);
|
||||
qc.invalidateQueries({ queryKey: ['phd2'] });
|
||||
qc.invalidateQueries({ queryKey: ['stats'] });
|
||||
} catch (e) {
|
||||
alert(`Failed to delete: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
||||
}
|
||||
setDeleting(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden', marginTop: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700, flex: 1 }}>PHD2 Guiding Sessions</div>
|
||||
<div
|
||||
onClick={() => !uploading && inputRef.current?.click()}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 3,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 11,
|
||||
color: 'var(--text-mid)',
|
||||
cursor: uploading ? 'default' : 'pointer',
|
||||
background: 'var(--bg-deep)',
|
||||
}}
|
||||
>
|
||||
{uploading ? 'Parsing…' : '↑ Upload .log'}
|
||||
</div>
|
||||
<input ref={inputRef} type="file" accept=".log,.csv" style={{ display: 'none' }}
|
||||
onChange={e => e.target.files?.[0] && handleFile(e.target.files[0])} />
|
||||
</div>
|
||||
{uploadResult && (
|
||||
<div style={{ padding: '6px 16px', fontFamily: 'var(--font-mono)', fontSize: 11,
|
||||
color: uploadResult.startsWith('✓') ? 'var(--good)' : uploadResult.startsWith('⚠') ? 'var(--warn)' : 'var(--danger)',
|
||||
borderBottom: '1px solid var(--border)', background: 'var(--bg-deep)' }}>
|
||||
{uploadResult}
|
||||
</div>
|
||||
)}
|
||||
{items.length === 0 ? (
|
||||
<div style={{ padding: 16, color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||
No PHD2 logs imported yet. Upload a .log file to analyze guiding performance.
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
{['Date', 'File', 'Duration', 'RMS Total', 'RMS RA', 'RMS Dec', 'Peak', 'Lost', 'SNR', ''].map(h => (
|
||||
<th key={h} style={{ padding: '6px 10px', textAlign: 'left', fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', fontWeight: 500, letterSpacing: '0.05em', whiteSpace: 'nowrap' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((log: Phd2Log) => {
|
||||
const rms = log.rms_total ?? 0;
|
||||
const rmsColor = rms < 0.7 ? 'var(--good)' : rms < 1.2 ? 'var(--warn)' : 'var(--danger)';
|
||||
return (
|
||||
<tr key={log.id} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-hi)', whiteSpace: 'nowrap' }}>{log.session_date}</td>
|
||||
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
title={log.filename}>{log.filename}</td>
|
||||
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', whiteSpace: 'nowrap' }}>
|
||||
{log.duration_min ? `${log.duration_min}m` : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 700, color: rmsColor, whiteSpace: 'nowrap' }}>
|
||||
{log.rms_total ? `${log.rms_total.toFixed(2)}″` : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', whiteSpace: 'nowrap' }}>
|
||||
{log.rms_ra ? `${log.rms_ra.toFixed(2)}″` : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', whiteSpace: 'nowrap' }}>
|
||||
{log.rms_dec ? `${log.rms_dec.toFixed(2)}″` : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: (log.peak_error ?? 0) > 2 ? 'var(--warn)' : 'var(--text-mid)', whiteSpace: 'nowrap' }}>
|
||||
{log.peak_error ? `${log.peak_error.toFixed(2)}″` : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: (log.star_lost_count ?? 0) > 5 ? 'var(--danger)' : 'var(--text-mid)', whiteSpace: 'nowrap' }}>
|
||||
{log.star_lost_count ?? 0}
|
||||
</td>
|
||||
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', whiteSpace: 'nowrap' }}>
|
||||
{log.guide_star_snr ? log.guide_star_snr.toFixed(1) : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<button
|
||||
onClick={() => handleDelete(log.id)}
|
||||
disabled={deleting === log.id}
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
background: 'var(--danger)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 2,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 10,
|
||||
cursor: deleting === log.id ? 'default' : 'pointer',
|
||||
opacity: deleting === log.id ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{deleting === log.id ? '…' : '×'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Stats() {
|
||||
const { data: stats, isLoading } = useStats();
|
||||
|
||||
if (isLoading || !stats) {
|
||||
return (
|
||||
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||
Loading statistics…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalH = (stats.total_integration_min / 60).toFixed(1);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}>
|
||||
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 22 }}>Statistics</h1>
|
||||
<button
|
||||
onClick={() => api.log.exportCsv()}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
padding: '5px 14px',
|
||||
background: 'var(--bg-panel)',
|
||||
border: '1px solid var(--border-hi)',
|
||||
borderRadius: 4,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 11,
|
||||
color: 'var(--text-mid)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
↓ Export Log CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Header stat cards */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 14, marginBottom: 28 }}>
|
||||
{[
|
||||
{ label: 'Total Sessions', value: stats.total_sessions },
|
||||
{ label: 'Integration Time', value: `${totalH}h` },
|
||||
{ label: 'Objects Imaged', value: stats.objects_with_keeper },
|
||||
{ label: 'Filter Types Used', value: stats.filter_usage.length },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 18px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 6 }}>{label}</div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 24, fontWeight: 700 }}>{value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20, marginBottom: 24 }}>
|
||||
{/* Integration per month */}
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700, marginBottom: 12 }}>Integration per Month</div>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={stats.monthly}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="month" tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }} tickLine={false} />
|
||||
<YAxis tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }} tickLine={false} tickFormatter={v => `${Math.round(v/60)}h`} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', fontFamily: 'IBM Plex Mono', fontSize: 11 }}
|
||||
formatter={(v: number) => [`${(v / 60).toFixed(1)}h`, 'Integration']}
|
||||
/>
|
||||
<Bar dataKey="total_min" fill="var(--amber)" radius={[2, 2, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Filter usage pie */}
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700, marginBottom: 12 }}>Filter Usage</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<ResponsiveContainer width={160} height={160}>
|
||||
<PieChart>
|
||||
<Pie data={stats.filter_usage} dataKey="total_min" nameKey="filter_id" cx="50%" cy="50%" outerRadius={70} innerRadius={40}>
|
||||
{stats.filter_usage.map((entry) => (
|
||||
<Cell key={entry.filter_id} fill={FILTER_COLORS[entry.filter_id] ?? '#888'} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: number) => `${(v / 60).toFixed(1)}h`} contentStyle={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', fontSize: 11 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div>
|
||||
{stats.filter_usage.map(f => (
|
||||
<div key={f.filter_id} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 2, background: FILTER_COLORS[f.filter_id] ?? '#888' }} />
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)' }}>
|
||||
{f.filter_id.toUpperCase()} — {(f.total_min / 60).toFixed(1)}h
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Object type breakdown */}
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700, marginBottom: 12 }}>By Object Type</div>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={stats.by_type} layout="vertical">
|
||||
<XAxis type="number" tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }} tickLine={false} tickFormatter={v => `${Math.round(v/60)}h`} />
|
||||
<YAxis type="category" dataKey="obj_type" tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }} tickLine={false} width={90} />
|
||||
<Tooltip contentStyle={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', fontSize: 11 }} formatter={(v: number) => `${(v / 60).toFixed(1)}h`} />
|
||||
<Bar dataKey="total_min" fill="var(--teal)" radius={[0, 2, 2, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Guiding RMS over time */}
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700, marginBottom: 12 }}>Guiding RMS over Time</div>
|
||||
{stats.guiding.length === 0 ? (
|
||||
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>No PHD2 logs imported yet.</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<LineChart data={stats.guiding.map(g => ({ ...g, rms_total: g.rms_total ?? null, rms_ra: g.rms_ra ?? null, rms_dec: g.rms_dec ?? null }))}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="date" tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }} tickLine={false} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }} tickLine={false} tickFormatter={v => `${v}″`} />
|
||||
<Tooltip contentStyle={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', fontFamily: 'IBM Plex Mono', fontSize: 11 }} formatter={(v: number) => `${v.toFixed(2)}″`} />
|
||||
<Line type="monotone" dataKey="rms_total" stroke="var(--blue)" strokeWidth={2} dot={{ r: 3, fill: 'var(--blue)' }} name="Total RMS" connectNulls={false} />
|
||||
<Line type="monotone" dataKey="rms_ra" stroke="var(--amber)" strokeWidth={1} dot={false} strokeDasharray="3 2" name="RA RMS" connectNulls={false} />
|
||||
<Line type="monotone" dataKey="rms_dec" stroke="var(--teal)" strokeWidth={1} dot={false} strokeDasharray="3 2" name="Dec RMS" connectNulls={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top targets */}
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700 }}>
|
||||
Most Integrated Targets
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
{['Name', 'Type', 'Sessions', 'Integration'].map(h => (
|
||||
<th key={h} style={{ padding: '6px 12px', textAlign: 'left', fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', fontWeight: 500, letterSpacing: '0.06em' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.top_targets.map(t => (
|
||||
<tr key={t.id} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '7px 12px', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-hi)' }}>
|
||||
{t.common_name ?? t.name}
|
||||
{t.common_name && <span style={{ color: 'var(--text-lo)', marginLeft: 6 }}>{t.name}</span>}
|
||||
</td>
|
||||
<td style={{ padding: '7px 12px', fontSize: 11, color: 'var(--text-mid)' }}>{t.obj_type}</td>
|
||||
<td style={{ padding: '7px 12px', fontFamily: 'var(--font-mono)', fontSize: 11 }}>{t.sessions}</td>
|
||||
<td style={{ padding: '7px 12px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--amber)' }}>
|
||||
{(t.total_min / 60).toFixed(1)}h
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Quality breakdown */}
|
||||
{stats.quality && stats.quality.length > 0 && (
|
||||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden', marginTop: 20 }}>
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700 }}>
|
||||
Session Quality Breakdown
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 0 }}>
|
||||
{stats.quality.map(q => {
|
||||
const colorMap: Record<string, string> = {
|
||||
keeper: 'var(--good)',
|
||||
needs_more: 'var(--blue)',
|
||||
rejected: 'var(--danger)',
|
||||
pending: 'var(--muted)',
|
||||
};
|
||||
const color = colorMap[q.quality] ?? 'var(--text-mid)';
|
||||
const total = stats.quality.reduce((s, x) => s + x.count, 0);
|
||||
return (
|
||||
<div key={q.quality} style={{
|
||||
flex: q.count,
|
||||
padding: '10px 14px',
|
||||
borderRight: '1px solid var(--border)',
|
||||
minWidth: 80,
|
||||
}}>
|
||||
<div className={`quality-chip ${q.quality}`} style={{ marginBottom: 4 }}>
|
||||
{q.quality.replace('_', ' ')}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 18, fontWeight: 700, color }}>
|
||||
{q.count}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
|
||||
{total > 0 ? Math.round((q.count / total) * 100) : 0}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PHD2Section />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user