Switch to uplot for loss graph and rework performance of graph. It is significantly more perfromant now.

This commit is contained in:
Jaret Burkett
2026-05-07 07:38:00 -06:00
parent a12ddd72a1
commit d144cb5ea6
6 changed files with 297 additions and 673 deletions

View File

@@ -2,8 +2,9 @@
import { Job } from '@prisma/client';
import useJobLossLog, { LossPoint } from '@/hooks/useJobLossLog';
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, Legend } from 'recharts';
import { useMemo, useState, useEffect, useRef, useCallback } from 'react';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
interface Props {
job: Job;
@@ -21,22 +22,36 @@ 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);
// Fallback canvas height used before the container has been measured.
const FALLBACK_CANVAS_HEIGHT = 360;
const MIN_CANVAS_HEIGHT = 160;
let prev = points[0].value;
out[0] = { step: points[0].step, value: prev };
// 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) };
}
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 };
// 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;
}
@@ -64,7 +79,6 @@ function strokeForKey(key: string) {
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)';
@@ -97,6 +111,8 @@ export default function JobLossGraph({ job }: Props) {
// which loss series are enabled (default: all enabled)
const [enabled, setEnabled] = useState<Record<string, boolean>>({});
const [isZoomed, setIsZoomed] = useState(false);
// keep enabled map in sync with discovered keys (enable new ones automatically)
useEffect(() => {
setEnabled(prev => {
@@ -104,7 +120,6 @@ export default function JobLossGraph({ job }: Props) {
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];
}
@@ -114,200 +129,249 @@ export default function JobLossGraph({ job }: Props) {
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(() => {
// Build per-series processed point arrays (raw + smoothed + fullSmooth), then merge by step for charting.
// Build uPlot-aligned data + series configs.
const built = useMemo(() => {
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
// 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 }[] }> =
{};
// Union of all steps across active series.
const stepSet = new Set<number>();
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
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 map = new Map<number, number>();
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 smooth = emaSmoothPoints(raw, alpha);
const fullSmooth = emaSmoothPoints(raw, fullAlpha);
const color = strokeForKey(key);
const colorFaded = color.replace('1)', '0.40)');
const colorDull = dulledColor(color);
out[key] = { raw, smooth, fullSmooth };
if (showRaw) {
data.push(raw);
seriesConfigs.push({
label: `${key} (raw)`,
stroke: colorFaded,
width: 1.25,
spanGaps: false,
points: { show: false },
});
}
if (showSmoothed) {
data.push(smooth);
seriesConfigs.push({
label: key,
stroke: color,
width: 2,
spanGaps: false,
points: { show: false },
});
}
data.push(fullSmooth);
seriesConfigs.push({
label: `${key} (trend)`,
stroke: colorDull,
width: 2.5,
spanGaps: false,
points: { show: false },
});
}
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<number, any>();
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);
// y-domain clipping (2nd–98th percentile of all visible y values).
let yClip: { min: number; max: number } | null = null;
if (clipOutliers && xs.length >= 10) {
const vals: number[] = [];
for (let s = 1; s < data.length; s++) {
const arr = data[s] as (number | null)[];
for (const v of arr) {
if (v !== null && Number.isFinite(v)) vals.push(v as number);
}
}
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);
}
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);
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 = { min: lo, max: hi };
}
}
}
const arr = Array.from(map.values());
arr.sort((a, b) => a.step - b.step);
return arr;
}, [activeKeys, perSeries]);
return { data: data as uPlot.AlignedData, seriesConfigs, yClip };
}, [series, activeKeys, smoothing, plotStride, windowSize, useLogScale, showRaw, showSmoothed, clipOutliers]);
// 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]);
// 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<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const uplotRef = useRef<uPlot | null>(null);
// 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
// Latest yClip read by the y-scale range fn — kept current via effect.
const yClipRef = useRef<{ min: number; max: number } | null>(null);
useEffect(() => {
const wrapper = chartWrapperRef.current;
if (!wrapper) return;
yClipRef.current = built.yClip;
}, [built.yClip]);
const onDown = (e: MouseEvent) => {
selectStartPx.current = e.clientX;
selectStartLabel.current = fractionToStep(pxToFraction(e.clientX));
setIsDragging(true);
if (overlayRef.current) overlayRef.current.style.display = 'none';
// 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: {
x: { time: false },
y: {
distr: useLogScale ? 3 : 1,
range: (_u, dataMin, dataMax) => {
const c = yClipRef.current;
if (c) return [c.min, c.max];
return [dataMin, dataMax];
},
},
},
axes: [
{
stroke: 'rgba(255,255,255,0.55)',
grid: { stroke: 'rgba(255,255,255,0.06)' },
ticks: { stroke: 'rgba(255,255,255,0.15)' },
},
{
stroke: 'rgba(255,255,255,0.55)',
grid: { 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)),
},
],
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);
},
],
},
};
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`;
}
};
uplotRef.current = new uPlot(opts, built.data, containerRef.current);
setIsZoomed(false);
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));
}
};
// After uPlot mounts its legend, right-size the canvas against the actual
// legend height so the canvas fills the remaining vertical space.
const fitted = computeCanvasSize(host);
if (fitted) uplotRef.current.setSize(fitted);
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);
uplotRef.current?.destroy();
uplotRef.current = null;
};
}, [pxToFraction, fractionToStep]);
// 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(() => {
setZoomLeft(null);
setZoomRight(null);
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 hasData = chartData.length > 1;
const isZoomed = zoomLeft != null && zoomRight != null;
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]);
const totalPoints = built.data[0]?.length ?? 0;
return (
<div className="bg-gray-900 rounded-xl shadow-lg overflow-hidden border border-gray-800 flex flex-col">
<div className="bg-gray-800 px-4 py-3 flex items-center justify-between">
<div className="bg-gray-900 rounded-xl shadow-lg overflow-hidden border border-gray-800 flex flex-col h-full">
<div className="bg-gray-800 px-4 py-3 flex items-center justify-between shrink-0">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-blue-400" />
<h2 className="text-gray-100 text-sm font-medium">Loss graph</h2>
@@ -315,7 +379,7 @@ export default function JobLossGraph({ job }: Props) {
{status === 'loading' && 'Loading...'}
{status === 'refreshing' && 'Refreshing...'}
{status === 'error' && 'Error'}
{status === 'success' && hasData && `${chartData.length.toLocaleString()} steps`}
{status === 'success' && hasData && `${totalPoints.toLocaleString()} steps`}
{status === 'success' && !hasData && 'No data yet'}
</span>
</div>
@@ -330,126 +394,36 @@ export default function JobLossGraph({ job }: Props) {
</div>
{/* Chart */}
<div className="px-4 pt-4 pb-4">
<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 }}
/>
<div className="px-4 pt-4 pb-4 flex-1 min-h-0 flex flex-col">
<div
className="bg-gray-950 rounded-lg border border-gray-800 relative select-none flex-1 min-h-0"
style={{ minHeight: 240 }}
>
{!hasData ? (
<div className="h-full w-full flex items-center justify-center text-sm text-gray-400">
<div className="absolute inset-0 flex items-center justify-center text-sm text-gray-400">
{status === 'error' ? 'Failed to load loss logs.' : 'Waiting for loss points...'}
</div>
) : (
<>
{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)" />
<XAxis
dataKey="step"
tick={{ fill: 'rgba(255,255,255,0.55)', fontSize: 12 }}
tickLine={{ stroke: 'rgba(255,255,255,0.15)' }}
axisLine={{ stroke: 'rgba(255,255,255,0.15)' }}
minTickGap={40}
/>
<YAxis
scale={useLogScale ? 'log' : 'linear'}
tick={{ fill: 'rgba(255,255,255,0.55)', fontSize: 12 }}
tickLine={{ stroke: 'rgba(255,255,255,0.15)' }}
axisLine={{ stroke: 'rgba(255,255,255,0.15)' }}
width={72}
tickFormatter={formatNum}
domain={yDomain}
allowDataOverflow={clipOutliers}
/>
{!isDragging && (
<Tooltip
cursor={{ stroke: 'rgba(59,130,246,0.25)', strokeWidth: 1 }}
contentStyle={{
background: 'rgba(17,24,39,0.96)',
border: '1px solid rgba(31,41,55,1)',
borderRadius: 10,
color: 'rgba(255,255,255,0.9)',
fontSize: 12,
}}
labelStyle={{ color: 'rgba(255,255,255,0.75)' }}
labelFormatter={(label: any) => `step ${label}`}
formatter={(value: any, name: any) => [formatNum(Number(value)), name]}
/>
)}
<Legend
wrapperStyle={{
paddingTop: 8,
color: 'rgba(255,255,255,0.7)',
fontSize: 12,
}}
/>
{/* Raw lines */}
{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"
/>
))}
</LineChart>
</ResponsiveContainer>
{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>
)}
<div ref={chartHostRef} className="absolute top-0 left-0 right-0 bottom-2 overflow-hidden">
<div ref={containerRef} />
</div>
</>
)}
</div>
</div>
{/* Controls */}
<div className="px-4 pb-2">
<div className="px-4 pb-2 shrink-0">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="bg-gray-950 border border-gray-800 rounded-lg p-3">
<label className="block text-xs text-gray-400 mb-2">Display</label>
@@ -520,9 +494,31 @@ export default function JobLossGraph({ job }: Props) {
/>
<div className="mt-2 text-[11px] text-gray-500">UI downsample for huge runs.</div>
</div>
</div>
</div>
<style jsx global>{`
.uplot,
.uplot * {
font-family: inherit;
}
.uplot .u-legend {
color: rgba(255, 255, 255, 0.85);
font-size: 12px;
margin-top: 4px;
}
.uplot .u-legend th,
.uplot .u-legend td {
color: rgba(255, 255, 255, 0.85);
}
.uplot .u-legend .u-marker {
border-radius: 2px;
}
.uplot .u-select {
background: rgba(59, 130, 246, 0.15);
border: 1px solid rgba(59, 130, 246, 0.4);
}
`}</style>
</div>
);
}