'use client'; import { Job } from '@prisma/client'; import useJobLossLog, { LossPoint } from '@/hooks/useJobLossLog'; import { useMemo, useState, useEffect } from 'react'; import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, Legend } from 'recharts'; interface Props { job: Job; } function formatNum(v: number) { if (!Number.isFinite(v)) return ''; if (Math.abs(v) >= 1000) return v.toFixed(0); if (Math.abs(v) >= 10) return v.toFixed(3); if (Math.abs(v) >= 1) return v.toFixed(4); return v.toPrecision(4); } function clamp01(x: number) { return Math.max(0, Math.min(1, x)); } // EMA smoothing that works on a per-series list. // alpha=1 -> no smoothing, alpha closer to 0 -> more smoothing. function emaSmoothPoints(points: { step: number; value: number }[], alpha: number) { if (points.length === 0) return []; const a = clamp01(alpha); const out: { step: number; value: number }[] = new Array(points.length); let prev = points[0].value; out[0] = { step: points[0].step, value: prev }; for (let i = 1; i < points.length; i++) { const x = points[i].value; prev = a * x + (1 - a) * prev; out[i] = { step: points[i].step, value: prev }; } return out; } function hashToIndex(str: string, mod: number) { let h = 2166136261; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } return Math.abs(h) % mod; } const PALETTE = [ 'rgba(96,165,250,1)', // blue-400 'rgba(52,211,153,1)', // emerald-400 'rgba(167,139,250,1)', // purple-400 'rgba(251,191,36,1)', // amber-400 'rgba(244,114,182,1)', // pink-400 'rgba(248,113,113,1)', // red-400 'rgba(34,211,238,1)', // cyan-400 'rgba(129,140,248,1)', // indigo-400 ]; function strokeForKey(key: string) { return PALETTE[hashToIndex(key, PALETTE.length)]; } export default function JobLossGraph({ job }: Props) { const { series, lossKeys, status, refreshLoss } = useJobLossLog(job.id, 2000); // Controls const [useLogScale, setUseLogScale] = useState(false); const [showRaw, setShowRaw] = useState(false); const [showSmoothed, setShowSmoothed] = useState(true); // 0..100 slider. 100 = no smoothing, 0 = heavy smoothing. const [smoothing, setSmoothing] = useState(90); // UI-only downsample for rendering speed const [plotStride, setPlotStride] = useState(1); // show only last N points in the chart (0 = all) const [windowSize, setWindowSize] = useState(0); // quick y clipping for readability const [clipOutliers, setClipOutliers] = useState(false); // which loss series are enabled (default: all enabled) const [enabled, setEnabled] = useState>({}); // keep enabled map in sync with discovered keys (enable new ones automatically) useEffect(() => { setEnabled(prev => { const next = { ...prev }; for (const k of lossKeys) { if (next[k] === undefined) next[k] = true; } // drop removed keys for (const k of Object.keys(next)) { if (!lossKeys.includes(k)) delete next[k]; } return next; }); }, [lossKeys]); const activeKeys = useMemo(() => lossKeys.filter(k => enabled[k] !== false), [lossKeys, enabled]); const perSeries = useMemo(() => { // Build per-series processed point arrays (raw + smoothed), then merge by step for charting. const stride = Math.max(1, plotStride | 0); // smoothing%: 0 => no smoothing (alpha=1.0), 100 => heavy smoothing (alpha=0.02) const t = clamp01(smoothing / 100); const alpha = 1.0 - t * 0.98; // 1.0 -> 0.02 const out: Record = {}; for (const key of activeKeys) { const pts: LossPoint[] = series[key] ?? []; let raw = pts .filter(p => p.value !== null && Number.isFinite(p.value as number)) .map(p => ({ step: p.step, value: p.value as number })) .filter(p => (useLogScale ? p.value > 0 : true)) .filter((_, idx) => idx % stride === 0); // windowing (applies after stride) if (windowSize > 0 && raw.length > windowSize) { raw = raw.slice(raw.length - windowSize); } const smooth = emaSmoothPoints(raw, alpha); out[key] = { raw, smooth }; } return out; }, [series, activeKeys, smoothing, plotStride, windowSize, useLogScale]); const chartData = useMemo(() => { // Merge series into one array of objects keyed by step. // Fields: `${key}__raw` and `${key}__smooth` const map = new Map(); for (const key of activeKeys) { const s = perSeries[key]; if (!s) continue; for (const p of s.raw) { const row = map.get(p.step) ?? { step: p.step }; row[`${key}__raw`] = p.value; map.set(p.step, row); } for (const p of s.smooth) { const row = map.get(p.step) ?? { step: p.step }; row[`${key}__smooth`] = p.value; map.set(p.step, row); } } const arr = Array.from(map.values()); arr.sort((a, b) => a.step - b.step); return arr; }, [activeKeys, perSeries]); const hasData = chartData.length > 1; const yDomain = useMemo((): [number | 'auto', number | 'auto'] => { if (!clipOutliers || chartData.length < 10) return ['auto', 'auto']; // Collect visible values (prefer smoothed if shown, else raw) const vals: number[] = []; for (const row of chartData) { for (const key of activeKeys) { const k = showSmoothed ? `${key}__smooth` : `${key}__raw`; const v = row[k]; if (typeof v === 'number' && Number.isFinite(v)) vals.push(v); } } if (vals.length < 10) return ['auto', 'auto']; vals.sort((a, b) => a - b); const lo = vals[Math.floor(vals.length * 0.02)]; const hi = vals[Math.ceil(vals.length * 0.98) - 1]; if (!Number.isFinite(lo) || !Number.isFinite(hi) || lo === hi) return ['auto', 'auto']; return [lo, hi]; }, [clipOutliers, chartData, activeKeys, showSmoothed]); return (

Loss graph

{status === 'loading' && 'Loading...'} {status === 'refreshing' && 'Refreshing...'} {status === 'error' && 'Error'} {status === 'success' && hasData && `${chartData.length.toLocaleString()} steps`} {status === 'success' && !hasData && 'No data yet'}
{/* Chart */}
{!hasData ? (
{status === 'error' ? 'Failed to load loss logs.' : 'Waiting for loss points...'}
) : ( `step ${label}`} formatter={(value: any, name: any) => [formatNum(Number(value)), name]} /> {activeKeys.map(k => { const color = strokeForKey(k); return ( {showRaw && ( )} {showSmoothed && ( )} ); })} )}
{/* Controls */}
setShowSmoothed(v => !v)} label="Smoothed" /> setShowRaw(v => !v)} label="Raw" /> setUseLogScale(v => !v)} label="Log Y" /> setClipOutliers(v => !v)} label="Clip outliers" />
{lossKeys.length === 0 ? (
No loss keys found yet.
) : (
{lossKeys.map(k => ( ))}
)}
{smoothing}%
setSmoothing(Number(e.target.value))} className="w-full accent-blue-500" disabled={!showSmoothed} />
every {plotStride} pt
setPlotStride(Number(e.target.value))} className="w-full accent-blue-500" />
UI downsample for huge runs.
{windowSize === 0 ? 'all' : windowSize.toLocaleString()}
setWindowSize(Number(e.target.value))} className="w-full accent-blue-500" />
Set to 0 to show all (not recommended for very long runs).
); } function ToggleButton({ checked, onClick, label }: { checked: boolean; onClick: () => void; label: string }) { return ( ); }