Work on loss graph. Add smoothed overlay. Allow user to hilite a secton of the graph to zoom into.
This commit is contained in:
parent
401f7df425
commit
a7bb4ddb2c
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { Job } from '@prisma/client';
|
import { Job } from '@prisma/client';
|
||||||
import useJobLossLog, { LossPoint } from '@/hooks/useJobLossLog';
|
import useJobLossLog, { LossPoint } from '@/hooks/useJobLossLog';
|
||||||
import { useMemo, useState, useEffect } from 'react';
|
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, Legend } from 'recharts';
|
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, Legend } from 'recharts';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -64,6 +64,16 @@ function strokeForKey(key: string) {
|
||||||
return PALETTE[hashToIndex(key, PALETTE.length)];
|
return PALETTE[hashToIndex(key, PALETTE.length)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns a solid but duller/darker version of an rgba color string for the trend overlay.
|
||||||
|
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) {
|
export default function JobLossGraph({ job }: Props) {
|
||||||
const { series, lossKeys, status, refreshLoss } = useJobLossLog(job.id, 2000);
|
const { series, lossKeys, status, refreshLoss } = useJobLossLog(job.id, 2000);
|
||||||
|
|
||||||
|
|
@ -73,13 +83,13 @@ export default function JobLossGraph({ job }: Props) {
|
||||||
const [showSmoothed, setShowSmoothed] = useState(true);
|
const [showSmoothed, setShowSmoothed] = useState(true);
|
||||||
|
|
||||||
// 0..100 slider. 100 = no smoothing, 0 = heavy smoothing.
|
// 0..100 slider. 100 = no smoothing, 0 = heavy smoothing.
|
||||||
const [smoothing, setSmoothing] = useState(90);
|
const [smoothing, setSmoothing] = useState(80);
|
||||||
|
|
||||||
// UI-only downsample for rendering speed
|
// UI-only downsample for rendering speed
|
||||||
const [plotStride, setPlotStride] = useState(1);
|
const [plotStride, setPlotStride] = useState(1);
|
||||||
|
|
||||||
// show only last N points in the chart (0 = all)
|
// show only last N points in the chart (0 = all)
|
||||||
const [windowSize, setWindowSize] = useState<number>(0);
|
const [windowSize] = useState<number>(0);
|
||||||
|
|
||||||
// quick y clipping for readability
|
// quick y clipping for readability
|
||||||
const [clipOutliers, setClipOutliers] = useState(false);
|
const [clipOutliers, setClipOutliers] = useState(false);
|
||||||
|
|
@ -104,15 +114,28 @@ export default function JobLossGraph({ job }: Props) {
|
||||||
|
|
||||||
const activeKeys = useMemo(() => lossKeys.filter(k => enabled[k] !== false), [lossKeys, enabled]);
|
const activeKeys = useMemo(() => lossKeys.filter(k => enabled[k] !== false), [lossKeys, enabled]);
|
||||||
|
|
||||||
|
// Zoom state for drag-to-zoom
|
||||||
|
const [zoomLeft, setZoomLeft] = useState<number | null>(null);
|
||||||
|
const [zoomRight, setZoomRight] = useState<number | null>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
// Selection tracked entirely in refs to avoid re-renders during drag
|
||||||
|
const selectStartLabel = useRef<number | null>(null);
|
||||||
|
const selectStartPx = useRef<number | null>(null);
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
const chartWrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const perSeries = useMemo(() => {
|
const perSeries = useMemo(() => {
|
||||||
// Build per-series processed point arrays (raw + smoothed), then merge by step for charting.
|
// Build per-series processed point arrays (raw + smoothed + fullSmooth), then merge by step for charting.
|
||||||
const stride = Math.max(1, plotStride | 0);
|
const stride = Math.max(1, plotStride | 0);
|
||||||
|
|
||||||
// smoothing%: 0 => no smoothing (alpha=1.0), 100 => heavy smoothing (alpha=0.02)
|
// smoothing%: 0 => no smoothing (alpha=1.0), 100 => heavy smoothing (alpha=0.02)
|
||||||
const t = clamp01(smoothing / 100);
|
const t = clamp01(smoothing / 100);
|
||||||
const alpha = 1.0 - t * 0.98; // 1.0 -> 0.02
|
const alpha = 1.0 - t * 0.98; // 1.0 -> 0.02
|
||||||
|
|
||||||
const out: Record<string, { raw: { step: number; value: number }[]; smooth: { step: number; value: number }[] }> =
|
// Full smoothing overlay: always max smoothing (alpha=0.02)
|
||||||
|
const fullAlpha = 0.005;
|
||||||
|
|
||||||
|
const out: Record<string, { raw: { step: number; value: number }[]; smooth: { step: number; value: number }[]; fullSmooth: { step: number; value: number }[] }> =
|
||||||
{};
|
{};
|
||||||
|
|
||||||
for (const key of activeKeys) {
|
for (const key of activeKeys) {
|
||||||
|
|
@ -130,8 +153,9 @@ export default function JobLossGraph({ job }: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const smooth = emaSmoothPoints(raw, alpha);
|
const smooth = emaSmoothPoints(raw, alpha);
|
||||||
|
const fullSmooth = emaSmoothPoints(raw, fullAlpha);
|
||||||
|
|
||||||
out[key] = { raw, smooth };
|
out[key] = { raw, smooth, fullSmooth };
|
||||||
}
|
}
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
|
|
@ -156,6 +180,11 @@ export default function JobLossGraph({ job }: Props) {
|
||||||
row[`${key}__smooth`] = p.value;
|
row[`${key}__smooth`] = p.value;
|
||||||
map.set(p.step, row);
|
map.set(p.step, row);
|
||||||
}
|
}
|
||||||
|
for (const p of s.fullSmooth) {
|
||||||
|
const row = map.get(p.step) ?? { step: p.step };
|
||||||
|
row[`${key}__fullsmooth`] = p.value;
|
||||||
|
map.set(p.step, row);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const arr = Array.from(map.values());
|
const arr = Array.from(map.values());
|
||||||
|
|
@ -163,7 +192,96 @@ export default function JobLossGraph({ job }: Props) {
|
||||||
return arr;
|
return arr;
|
||||||
}, [activeKeys, perSeries]);
|
}, [activeKeys, perSeries]);
|
||||||
|
|
||||||
|
// Zoomed slice of chartData
|
||||||
|
const visibleData = useMemo(() => {
|
||||||
|
if (zoomLeft == null || zoomRight == null) return chartData;
|
||||||
|
const lo = Math.min(zoomLeft, zoomRight);
|
||||||
|
const hi = Math.max(zoomLeft, zoomRight);
|
||||||
|
return chartData.filter(d => d.step >= lo && d.step <= hi);
|
||||||
|
}, [chartData, zoomLeft, zoomRight]);
|
||||||
|
|
||||||
|
// Convert a pixel x within the wrapper to a fractional position [0,1] across the plot area
|
||||||
|
const pxToFraction = useCallback((clientX: number) => {
|
||||||
|
const wrapper = chartWrapperRef.current;
|
||||||
|
if (!wrapper) return 0;
|
||||||
|
const rect = wrapper.getBoundingClientRect();
|
||||||
|
// chart margin left (8) + yAxis width (72) = 80, margin right = 16
|
||||||
|
const plotLeft = 80;
|
||||||
|
const plotRight = rect.width - 16;
|
||||||
|
const plotWidth = plotRight - plotLeft;
|
||||||
|
const localX = clientX - rect.left;
|
||||||
|
return Math.max(0, Math.min(1, (localX - plotLeft) / plotWidth));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fractionToStep = useCallback((frac: number) => {
|
||||||
|
const data = visibleData;
|
||||||
|
if (data.length === 0) return 0;
|
||||||
|
const idx = Math.round(frac * (data.length - 1));
|
||||||
|
return data[Math.max(0, Math.min(data.length - 1, idx))].step;
|
||||||
|
}, [visibleData]);
|
||||||
|
|
||||||
|
// Native DOM events for drag selection
|
||||||
|
useEffect(() => {
|
||||||
|
const wrapper = chartWrapperRef.current;
|
||||||
|
if (!wrapper) return;
|
||||||
|
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
selectStartPx.current = e.clientX;
|
||||||
|
selectStartLabel.current = fractionToStep(pxToFraction(e.clientX));
|
||||||
|
setIsDragging(true);
|
||||||
|
if (overlayRef.current) overlayRef.current.style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMove = (e: MouseEvent) => {
|
||||||
|
if (selectStartPx.current == null) return;
|
||||||
|
const rect = wrapper.getBoundingClientRect();
|
||||||
|
const plotLeft = 80;
|
||||||
|
const plotRight = rect.width - 16;
|
||||||
|
const startLocal = selectStartPx.current - rect.left;
|
||||||
|
const curLocal = e.clientX - rect.left;
|
||||||
|
// Clamp to plot area
|
||||||
|
const clampedStart = Math.max(plotLeft, Math.min(plotRight, startLocal));
|
||||||
|
const clampedCur = Math.max(plotLeft, Math.min(plotRight, curLocal));
|
||||||
|
const left = Math.min(clampedStart, clampedCur);
|
||||||
|
const width = Math.abs(clampedCur - clampedStart);
|
||||||
|
if (overlayRef.current) {
|
||||||
|
overlayRef.current.style.display = width > 3 ? 'block' : 'none';
|
||||||
|
overlayRef.current.style.left = `${left}px`;
|
||||||
|
overlayRef.current.style.width = `${width}px`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUp = (e: MouseEvent) => {
|
||||||
|
if (selectStartPx.current == null) return;
|
||||||
|
const startStep = selectStartLabel.current!;
|
||||||
|
const endStep = fractionToStep(pxToFraction(e.clientX));
|
||||||
|
selectStartPx.current = null;
|
||||||
|
selectStartLabel.current = null;
|
||||||
|
setIsDragging(false);
|
||||||
|
if (overlayRef.current) overlayRef.current.style.display = 'none';
|
||||||
|
if (startStep !== endStep) {
|
||||||
|
setZoomLeft(Math.min(startStep, endStep));
|
||||||
|
setZoomRight(Math.max(startStep, endStep));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
wrapper.addEventListener('mousedown', onDown);
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
return () => {
|
||||||
|
wrapper.removeEventListener('mousedown', onDown);
|
||||||
|
window.removeEventListener('mousemove', onMove);
|
||||||
|
window.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
}, [pxToFraction, fractionToStep]);
|
||||||
|
|
||||||
|
const handleResetZoom = useCallback(() => {
|
||||||
|
setZoomLeft(null);
|
||||||
|
setZoomRight(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const hasData = chartData.length > 1;
|
const hasData = chartData.length > 1;
|
||||||
|
const isZoomed = zoomLeft != null && zoomRight != null;
|
||||||
|
|
||||||
const yDomain = useMemo((): [number | 'auto', number | 'auto'] => {
|
const yDomain = useMemo((): [number | 'auto', number | 'auto'] => {
|
||||||
if (!clipOutliers || chartData.length < 10) return ['auto', 'auto'];
|
if (!clipOutliers || chartData.length < 10) return ['auto', 'auto'];
|
||||||
|
|
@ -213,14 +331,32 @@ export default function JobLossGraph({ job }: Props) {
|
||||||
|
|
||||||
{/* Chart */}
|
{/* Chart */}
|
||||||
<div className="px-4 pt-4 pb-4">
|
<div className="px-4 pt-4 pb-4">
|
||||||
<div className="bg-gray-950 rounded-lg border border-gray-800 h-96 relative">
|
<div ref={chartWrapperRef} className="bg-gray-950 rounded-lg border border-gray-800 h-96 relative select-none">
|
||||||
|
{/* Drag selection overlay — positioned via refs, no re-renders */}
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
style={{ display: 'none', position: 'absolute', top: 10, bottom: 10, pointerEvents: 'none', background: 'rgba(59,130,246,0.15)', border: '1px solid rgba(59,130,246,0.4)', zIndex: 5 }}
|
||||||
|
/>
|
||||||
{!hasData ? (
|
{!hasData ? (
|
||||||
<div className="h-full w-full flex items-center justify-center text-sm text-gray-400">
|
<div className="h-full w-full flex items-center justify-center text-sm text-gray-400">
|
||||||
{status === 'error' ? 'Failed to load loss logs.' : 'Waiting for loss points...'}
|
{status === 'error' ? 'Failed to load loss logs.' : 'Waiting for loss points...'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<>
|
||||||
<LineChart data={chartData} margin={{ top: 10, right: 16, bottom: 10, left: 8 }}>
|
{isZoomed && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResetZoom}
|
||||||
|
className="absolute top-2 right-2 z-10 px-2 py-1 rounded text-xs bg-blue-600/80 hover:bg-blue-600 text-white border border-blue-500/50"
|
||||||
|
>
|
||||||
|
Reset zoom
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<ResponsiveContainer width="100%" height="100%" style={isDragging ? { pointerEvents: 'none' } : undefined}>
|
||||||
|
<LineChart
|
||||||
|
data={visibleData}
|
||||||
|
margin={{ top: 10, right: 16, bottom: 10, left: 8 }}
|
||||||
|
>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="step"
|
dataKey="step"
|
||||||
|
|
@ -239,19 +375,21 @@ export default function JobLossGraph({ job }: Props) {
|
||||||
domain={yDomain}
|
domain={yDomain}
|
||||||
allowDataOverflow={clipOutliers}
|
allowDataOverflow={clipOutliers}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
{!isDragging && (
|
||||||
cursor={{ stroke: 'rgba(59,130,246,0.25)', strokeWidth: 1 }}
|
<Tooltip
|
||||||
contentStyle={{
|
cursor={{ stroke: 'rgba(59,130,246,0.25)', strokeWidth: 1 }}
|
||||||
background: 'rgba(17,24,39,0.96)',
|
contentStyle={{
|
||||||
border: '1px solid rgba(31,41,55,1)',
|
background: 'rgba(17,24,39,0.96)',
|
||||||
borderRadius: 10,
|
border: '1px solid rgba(31,41,55,1)',
|
||||||
color: 'rgba(255,255,255,0.9)',
|
borderRadius: 10,
|
||||||
fontSize: 12,
|
color: 'rgba(255,255,255,0.9)',
|
||||||
}}
|
fontSize: 12,
|
||||||
labelStyle={{ color: 'rgba(255,255,255,0.75)' }}
|
}}
|
||||||
labelFormatter={(label: any) => `step ${label}`}
|
labelStyle={{ color: 'rgba(255,255,255,0.75)' }}
|
||||||
formatter={(value: any, name: any) => [formatNum(Number(value)), name]}
|
labelFormatter={(label: any) => `step ${label}`}
|
||||||
/>
|
formatter={(value: any, name: any) => [formatNum(Number(value)), name]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Legend
|
<Legend
|
||||||
wrapperStyle={{
|
wrapperStyle={{
|
||||||
|
|
@ -261,38 +399,51 @@ export default function JobLossGraph({ job }: Props) {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{activeKeys.map(k => {
|
{/* Raw lines */}
|
||||||
const color = strokeForKey(k);
|
{showRaw && activeKeys.map(k => (
|
||||||
|
<Line
|
||||||
|
key={`${k}__raw`}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={`${k}__raw`}
|
||||||
|
name={`${k} (raw)`}
|
||||||
|
stroke={strokeForKey(k).replace('1)', '0.40)')}
|
||||||
|
strokeWidth={1.25}
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* Smoothed lines */}
|
||||||
|
{showSmoothed && activeKeys.map(k => (
|
||||||
|
<Line
|
||||||
|
key={`${k}__smooth`}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={`${k}__smooth`}
|
||||||
|
name={`${k}`}
|
||||||
|
stroke={strokeForKey(k)}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* Full-smooth trend overlay — rendered last so it's on top, hidden from legend/tooltip */}
|
||||||
|
{activeKeys.map(k => (
|
||||||
|
<Line
|
||||||
|
key={`${k}__fullsmooth`}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={`${k}__fullsmooth`}
|
||||||
|
name={`${k}__fullsmooth`}
|
||||||
|
stroke={dulledColor(strokeForKey(k))}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
legendType="none"
|
||||||
|
tooltipType="none"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
return (
|
|
||||||
<g key={k}>
|
|
||||||
{showRaw && (
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey={`${k}__raw`}
|
|
||||||
name={`${k} (raw)`}
|
|
||||||
stroke={color.replace('1)', '0.40)')}
|
|
||||||
strokeWidth={1.25}
|
|
||||||
dot={false}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showSmoothed && (
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey={`${k}__smooth`}
|
|
||||||
name={`${k}`}
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -370,24 +521,6 @@ export default function JobLossGraph({ job }: Props) {
|
||||||
<div className="mt-2 text-[11px] text-gray-500">UI downsample for huge runs.</div>
|
<div className="mt-2 text-[11px] text-gray-500">UI downsample for huge runs.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-950 border border-gray-800 rounded-lg p-3 md:col-span-2">
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<label className="block text-xs text-gray-400">Window (last N points)</label>
|
|
||||||
<span className="text-xs text-gray-300">{windowSize === 0 ? 'all' : windowSize.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={20000}
|
|
||||||
step={250}
|
|
||||||
value={windowSize}
|
|
||||||
onChange={e => setWindowSize(Number(e.target.value))}
|
|
||||||
className="w-full accent-blue-500"
|
|
||||||
/>
|
|
||||||
<div className="mt-2 text-[11px] text-gray-500">
|
|
||||||
Set to 0 to show all (not recommended for very long runs).
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue