'use client'; import { Job } from '@prisma/client'; import useJobLossLog, { LossPoint } from '@/hooks/useJobLossLog'; import { useMemo, useState, useEffect, useRef, useCallback } from 'react'; import uPlot from 'uplot'; import 'uplot/dist/uPlot.min.css'; interface Props { job: Job; } function formatNum(v: number) { if (!Number.isFinite(v)) return ''; if (v === 0) return '0'; const abs = Math.abs(v); // Very small / very large magnitudes read better as exponents (e.g. 1.00e-5) // than as long decimal strings like 0.0000100. if (abs < 1e-3 || abs >= 1e6) return v.toExponential(2); if (abs >= 1000) return v.toFixed(0); if (abs >= 10) return v.toFixed(3); if (abs >= 1) return v.toFixed(4); return v.toPrecision(4); } function clamp01(x: number) { return Math.max(0, Math.min(1, x)); } // Fallback canvas height used before the container has been measured. const FALLBACK_CANVAS_HEIGHT = 360; const MIN_CANVAS_HEIGHT = 160; // Compute canvas size so uPlot's canvas + its HTML legend fit inside `host`. // `host` should be a layout-controlled wrapper (NOT the uPlot mount node, since // uPlot's stylesheet sets `width: min-content` on its mount node). function computeCanvasSize(host: HTMLElement): { width: number; height: number } | null { const { width, height } = host.getBoundingClientRect(); if (width <= 0 || height <= 0) return null; const legend = host.querySelector('.u-legend') as HTMLElement | null; const legendH = legend?.getBoundingClientRect().height ?? 0; return { width, height: Math.max(MIN_CANVAS_HEIGHT, height - legendH) }; } // EMA over a (number|null)[] series. Nulls are preserved as gaps and do not // advance the running average. function emaWithNulls(ys: (number | null)[], alpha: number): (number | null)[] { const out: (number | null)[] = new Array(ys.length); let prev: number | null = null; for (let i = 0; i < ys.length; i++) { const v = ys[i]; if (v === null || !Number.isFinite(v)) { out[i] = null; continue; } if (prev === null) prev = v as number; else prev = alpha * (v as number) + (1 - alpha) * prev; out[i] = 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)]; } function dulledColor(rgba: string): string { const m = rgba.match(/rgba?\((\d+),(\d+),(\d+)/); if (!m) return 'rgba(120,120,120,1)'; const r = Math.round(Number(m[1]) * 0.55); const g = Math.round(Number(m[2]) * 0.55); const b = Math.round(Number(m[3]) * 0.55); return `rgba(${r},${g},${b},1)`; } 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(80); // UI-only downsample for rendering speed const [plotStride, setPlotStride] = useState(1); // show only last N points in the chart (0 = all) const [windowSize] = useState(0); // quick y clipping for readability const [clipOutliers, setClipOutliers] = useState(false); // which loss series are enabled (default: all enabled) const [enabled, setEnabled] = useState>({}); const [isZoomed, setIsZoomed] = useState(false); // keep enabled map in sync with discovered keys. Only "loss/loss" is on by // default; every other metric starts deactivated (user can toggle it on). useEffect(() => { setEnabled(prev => { const next = { ...prev }; for (const k of lossKeys) { if (next[k] === undefined) next[k] = k === 'loss/loss'; } 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]); // Build uPlot-aligned data + series configs. const built = useMemo(() => { const stride = Math.max(1, plotStride | 0); const t = clamp01(smoothing / 100); const alpha = 1.0 - t * 0.98; // 1.0 -> 0.02 const fullAlpha = 0.005; // Union of all steps across active series. const stepSet = new Set(); for (const key of activeKeys) { const pts: LossPoint[] = series[key] ?? []; for (const p of pts) { if (p.value === null || !Number.isFinite(p.value as number)) continue; if (useLogScale && (p.value as number) <= 0) continue; stepSet.add(p.step); } } let xs = Array.from(stepSet).sort((a, b) => a - b); if (stride > 1) xs = xs.filter((_, i) => i % stride === 0); if (windowSize > 0 && xs.length > windowSize) xs = xs.slice(xs.length - windowSize); const xsSet = new Set(xs); const data: (number[] | (number | null)[])[] = [xs]; const seriesConfigs: uPlot.Series[] = [{}]; // x // Each metric gets its own y-scale (so unrelated magnitudes auto-range // independently) plus a matching colored axis. const scales: uPlot.Scales = { x: { time: false } }; const axes: uPlot.Axis[] = [ { stroke: 'rgba(255,255,255,0.55)', grid: { stroke: 'rgba(255,255,255,0.06)' }, ticks: { stroke: 'rgba(255,255,255,0.15)' }, }, ]; // Data columns belonging to each scale, for per-scale clip percentiles. const scaleArrays: Record = {}; for (let ki = 0; ki < activeKeys.length; ki++) { const key = activeKeys[ki]; const scaleKey = `y::${key}`; const pts: LossPoint[] = series[key] ?? []; const map = new Map(); for (const p of pts) { if (p.value === null || !Number.isFinite(p.value as number)) continue; if (useLogScale && (p.value as number) <= 0) continue; if (!xsSet.has(p.step)) continue; map.set(p.step, p.value as number); } const raw: (number | null)[] = xs.map(s => (map.has(s) ? (map.get(s) as number) : null)); const smooth = emaWithNulls(raw, alpha); const fullSmooth = emaWithNulls(raw, fullAlpha); const color = strokeForKey(key); const colorFaded = color.replace('1)', '0.40)'); const colorDull = dulledColor(color); const colArrays: (number | null)[][] = []; if (showRaw) { data.push(raw); seriesConfigs.push({ label: `${key} (raw)`, scale: scaleKey, stroke: colorFaded, width: 1.25, spanGaps: false, points: { show: false }, }); colArrays.push(raw); } if (showSmoothed) { data.push(smooth); seriesConfigs.push({ label: key, scale: scaleKey, stroke: color, width: 2, spanGaps: false, points: { show: false }, }); colArrays.push(smooth); } data.push(fullSmooth); seriesConfigs.push({ label: `${key} (trend)`, scale: scaleKey, stroke: colorDull, width: 2.5, spanGaps: false, points: { show: false }, }); colArrays.push(fullSmooth); scaleArrays[scaleKey] = colArrays; scales[scaleKey] = { distr: useLogScale ? 3 : 1, range: (_u, dataMin, dataMax) => { const c = yClipRef.current?.[scaleKey]; if (c) return [c.min, c.max]; return [dataMin, dataMax]; }, }; axes.push({ scale: scaleKey, side: ki % 2 === 0 ? 3 : 1, // alternate left / right stroke: color, label: key, labelSize: 14, // Only the first scale draws gridlines; overlaying grids from multiple // independent scales would be unreadable. grid: { show: ki === 0, stroke: 'rgba(255,255,255,0.06)' }, ticks: { stroke: 'rgba(255,255,255,0.15)' }, size: 60, values: (_u, ticks) => ticks.map(tk => formatNum(tk)), }); } // y-domain clipping (2nd–98th percentile), computed per scale. let yClip: Record | null = null; if (clipOutliers && xs.length >= 10) { yClip = {}; for (const scaleKey of Object.keys(scaleArrays)) { const vals: number[] = []; for (const arr of scaleArrays[scaleKey]) { for (const v of arr) { if (v !== null && Number.isFinite(v)) vals.push(v as number); } } if (vals.length >= 10) { 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) { yClip[scaleKey] = { min: lo, max: hi }; } } } } return { data: data as uPlot.AlignedData, seriesConfigs, scales, axes, yClip }; }, [series, activeKeys, smoothing, plotStride, windowSize, useLogScale, showRaw, showSmoothed, clipOutliers]); // Layout wrapper we measure for sizing — uPlot collapses its own mount node // to width:min-content, so we can't read sizes off it. const chartHostRef = useRef(null); const containerRef = useRef(null); const uplotRef = useRef(null); // Latest per-scale yClip read by the y-scale range fns — kept current via effect. const yClipRef = useRef | null>(null); useEffect(() => { yClipRef.current = built.yClip; }, [built.yClip]); // Track zoom state via ref so the data-update effect can decide whether to refit scales. const isZoomedRef = useRef(false); useEffect(() => { isZoomedRef.current = isZoomed; }, [isZoomed]); // Structural recreate key — recreate uPlot only when the series shape or // axis distribution changes. Data updates go through setData. const hasData = (built.data[0]?.length ?? 0) > 1; const structuralKey = useMemo( () => `${activeKeys.join('|')}|raw=${showRaw}|sm=${showSmoothed}|log=${useLogScale}|has=${hasData}`, [activeKeys, showRaw, showSmoothed, useLogScale, hasData], ); useEffect(() => { if (uplotRef.current) { uplotRef.current.destroy(); uplotRef.current = null; } if (!containerRef.current || !chartHostRef.current) return; if (!hasData) return; const host = chartHostRef.current; const rect = host.getBoundingClientRect(); const initialHeight = rect.height > 0 ? Math.max(MIN_CANVAS_HEIGHT, rect.height - 40) : FALLBACK_CANVAS_HEIGHT; const opts: uPlot.Options = { width: rect.width || 800, height: initialHeight, padding: [12, 16, 0, 4], series: built.seriesConfigs, scales: built.scales, axes: built.axes, cursor: { drag: { x: true, y: false, setScale: true }, points: { size: 6 }, }, legend: { show: true }, hooks: { setScale: [ (u, key) => { if (key !== 'x') return; const xs = u.data[0] as number[]; if (!xs || !xs.length) return; const sx = u.scales.x; const zoomed = sx.min !== xs[0] || sx.max !== xs[xs.length - 1]; setIsZoomed(zoomed); }, ], }, }; uplotRef.current = new uPlot(opts, built.data, containerRef.current); setIsZoomed(false); // Right-size the canvas against the legend height so it fills the remaining // vertical space. Defer to the next frame: the legend's height depends on // how many series wrap, and that layout isn't settled synchronously after // construction — measuring now would read a stale height (the bug that // previously required a manual resize to correct). const raf = requestAnimationFrame(() => { const u = uplotRef.current; if (!u) return; const fitted = computeCanvasSize(host); if (fitted) u.setSize(fitted); }); return () => { cancelAnimationFrame(raf); uplotRef.current?.destroy(); uplotRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [structuralKey]); // Push new data without recreating — preserves zoom & cursor state. useEffect(() => { const u = uplotRef.current; if (!u) return; // When zoomed, pass resetScales=false so the user's view stays put. uPlot // skips its commit() in that branch though, so force a redraw to actually // re-render the new smoothed/strided values within the zoom window. if (isZoomedRef.current) { u.setData(built.data, false); u.redraw(true, true); } else { u.setData(built.data, true); } }, [built]); // Resize observer — fit canvas to the wrapper's available space minus the // HTML legend uPlot renders below it. Observe the layout wrapper, not the // uPlot mount node (which uPlot pins to width:min-content). // Re-runs on `hasData` because the observed element only exists once data // has loaded (see the `!hasData` branch below). useEffect(() => { const el = chartHostRef.current; if (!el) return; const ro = new ResizeObserver(() => { const u = uplotRef.current; if (!u) return; const fitted = computeCanvasSize(el); if (fitted) u.setSize(fitted); }); ro.observe(el); return () => ro.disconnect(); }, [hasData]); const handleResetZoom = useCallback(() => { const u = uplotRef.current; if (!u) return; const xs = u.data[0] as number[]; if (!xs || !xs.length) return; u.setScale('x', { min: xs[0], max: xs[xs.length - 1] }); }, []); const totalPoints = built.data[0]?.length ?? 0; return (

Loss graph

{status === 'loading' && 'Loading...'} {status === 'refreshing' && 'Refreshing...'} {status === 'error' && 'Error'} {status === 'success' && hasData && `${totalPoints.toLocaleString()} steps`} {status === 'success' && !hasData && 'No data yet'}
{/* Chart */}
{!hasData ? (
{status === 'error' ? 'Failed to load loss logs.' : 'Waiting for loss points...'}
) : ( <> {isZoomed && ( )}
)}
{/* 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.
); } function ToggleButton({ checked, onClick, label }: { checked: boolean; onClick: () => void; label: string }) { return ( ); }