373 lines
19 KiB
TypeScript
373 lines
19 KiB
TypeScript
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=".txt,.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>
|
||
);
|
||
}
|