Skip to content

Commit b384ee1

Browse files
authored
Add Textbox control for controlling zoom in Performance Trends (#179)
1 parent d4aab38 commit b384ee1

File tree

1 file changed

+126
-9
lines changed

1 file changed

+126
-9
lines changed

frontend/src/pages/leaderboard/components/UserTrendChart.tsx

Lines changed: 126 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
Button,
1515
FormControlLabel,
1616
Switch,
17+
Stack,
1718
} from "@mui/material";
1819
import {
1920
fetchUserTrend,
@@ -92,25 +93,48 @@ export default function UserTrendChart({ leaderboardId, defaultUsers, defaultGpu
9293
const [clipOffscreen, setClipOffscreen] = useState(false);
9394

9495
const chartRef = useRef<ReactECharts>(null);
95-
const [zoomState, setZoomState] = useState<Array<{ start?: number; end?: number }>>([]);
96+
const [zoomState, setZoomState] = useState<Array<{ startValue?: number; endValue?: number }>>([]);
9697

97-
// Capture zoom state when it changes
98+
// Local state for axis input fields (not applied until button clicked)
99+
const [xStartInput, setXStartInput] = useState("");
100+
const [xEndInput, setXEndInput] = useState("");
101+
const [yMinInput, setYMinInput] = useState("");
102+
const [yMaxInput, setYMaxInput] = useState("");
103+
104+
// Capture zoom state when it changes (using values, not percentages)
98105
const onDataZoom = useCallback(() => {
99106
const chartInstance = chartRef.current?.getEchartsInstance();
100107
if (chartInstance) {
101-
const opt = chartInstance.getOption() as { dataZoom?: Array<{ start?: number; end?: number }> };
108+
const opt = chartInstance.getOption() as { dataZoom?: Array<{ startValue?: number; endValue?: number }> };
102109
if (opt.dataZoom) {
103110
setZoomState(opt.dataZoom.map((dz) => ({
104-
start: dz.start,
105-
end: dz.end,
111+
startValue: dz.startValue,
112+
endValue: dz.endValue,
106113
})));
114+
// Sync input fields with current zoom
115+
if (opt.dataZoom[0]?.startValue) {
116+
setXStartInput(new Date(opt.dataZoom[0].startValue).toISOString().split("T")[0]);
117+
}
118+
if (opt.dataZoom[0]?.endValue) {
119+
setXEndInput(new Date(opt.dataZoom[0].endValue).toISOString().split("T")[0]);
120+
}
121+
if (opt.dataZoom[1]?.startValue !== undefined) {
122+
setYMinInput(formatMicrosecondsNum(opt.dataZoom[1].startValue).toString());
123+
}
124+
if (opt.dataZoom[1]?.endValue !== undefined) {
125+
setYMaxInput(formatMicrosecondsNum(opt.dataZoom[1].endValue).toString());
126+
}
107127
}
108128
}
109129
}, []);
110130

111131
// Clear saved zoom state when restore is triggered
112132
const onRestore = useCallback(() => {
113133
setZoomState([]);
134+
setXStartInput("");
135+
setXEndInput("");
136+
setYMinInput("");
137+
setYMaxInput("");
114138
}, []);
115139

116140
const chartEvents = {
@@ -305,6 +329,45 @@ export default function UserTrendChart({ leaderboardId, defaultUsers, defaultGpu
305329
const gpuTypesFromCustom = customData?.time_series ? Object.keys(customData.time_series) : [];
306330
const gpuTypes = [...new Set([...gpuTypesFromUsers, ...gpuTypesFromCustom])];
307331

332+
// Apply axis range from input fields
333+
const handleApplyAxisRange = () => {
334+
const newZoomState: Array<{ startValue?: number; endValue?: number }> = [{}, {}, {}, {}];
335+
336+
// Apply X-axis values
337+
if (xStartInput) {
338+
const date = new Date(xStartInput);
339+
if (!isNaN(date.getTime())) {
340+
newZoomState[0].startValue = date.getTime();
341+
newZoomState[2].startValue = date.getTime();
342+
}
343+
}
344+
if (xEndInput) {
345+
const date = new Date(xEndInput);
346+
if (!isNaN(date.getTime())) {
347+
newZoomState[0].endValue = date.getTime();
348+
newZoomState[2].endValue = date.getTime();
349+
}
350+
}
351+
352+
// Apply Y-axis values
353+
if (yMinInput) {
354+
const value = parseFloat(yMinInput) / 1_000_000;
355+
if (!isNaN(value)) {
356+
newZoomState[1].startValue = value;
357+
newZoomState[3].startValue = value;
358+
}
359+
}
360+
if (yMaxInput) {
361+
const value = parseFloat(yMaxInput) / 1_000_000;
362+
if (!isNaN(value)) {
363+
newZoomState[1].endValue = value;
364+
newZoomState[3].endValue = value;
365+
}
366+
}
367+
368+
setZoomState(newZoomState);
369+
};
370+
308371
const renderSearchInput = () => (
309372
<Box sx={{ mb: 2, display: "flex", gap: 2, alignItems: "flex-start" }}>
310373
<Autocomplete
@@ -591,13 +654,15 @@ export default function UserTrendChart({ leaderboardId, defaultUsers, defaultGpu
591654
type: "inside" as const,
592655
xAxisIndex: 0,
593656
filterMode,
594-
...(zoomState[0] && { start: zoomState[0].start, end: zoomState[0].end }),
657+
...(zoomState[0]?.startValue !== undefined && { startValue: zoomState[0].startValue }),
658+
...(zoomState[0]?.endValue !== undefined && { endValue: zoomState[0].endValue }),
595659
},
596660
{
597661
type: "inside" as const,
598662
yAxisIndex: 0,
599663
filterMode,
600-
...(zoomState[1] && { start: zoomState[1].start, end: zoomState[1].end }),
664+
...(zoomState[1]?.startValue !== undefined && { startValue: zoomState[1].startValue }),
665+
...(zoomState[1]?.endValue !== undefined && { endValue: zoomState[1].endValue }),
601666
},
602667
{
603668
type: "slider" as const,
@@ -614,7 +679,8 @@ export default function UserTrendChart({ leaderboardId, defaultUsers, defaultGpu
614679
textStyle: {
615680
color: textColor,
616681
},
617-
...(zoomState[2] && { start: zoomState[2].start, end: zoomState[2].end }),
682+
...(zoomState[2]?.startValue !== undefined && { startValue: zoomState[2].startValue }),
683+
...(zoomState[2]?.endValue !== undefined && { endValue: zoomState[2].endValue }),
618684
},
619685
{
620686
type: "slider" as const,
@@ -631,7 +697,8 @@ export default function UserTrendChart({ leaderboardId, defaultUsers, defaultGpu
631697
textStyle: {
632698
color: textColor,
633699
},
634-
...(zoomState[3] && { start: zoomState[3].start, end: zoomState[3].end }),
700+
...(zoomState[3]?.startValue !== undefined && { startValue: zoomState[3].startValue }),
701+
...(zoomState[3]?.endValue !== undefined && { endValue: zoomState[3].endValue }),
635702
},
636703
];
637704

@@ -752,6 +819,56 @@ export default function UserTrendChart({ leaderboardId, defaultUsers, defaultGpu
752819
notMerge={true}
753820
onEvents={chartEvents}
754821
/>
822+
<Stack direction="row" spacing={2} sx={{ mt: 2, alignItems: "center", flexWrap: "wrap" }}>
823+
<Typography variant="body2" color="text.secondary">
824+
X-Axis (Date):
825+
</Typography>
826+
<TextField
827+
label="Start"
828+
type="date"
829+
size="small"
830+
value={xStartInput}
831+
onChange={(e) => setXStartInput(e.target.value)}
832+
slotProps={{ inputLabel: { shrink: true } }}
833+
sx={{ width: 150 }}
834+
/>
835+
<TextField
836+
label="End"
837+
type="date"
838+
size="small"
839+
value={xEndInput}
840+
onChange={(e) => setXEndInput(e.target.value)}
841+
slotProps={{ inputLabel: { shrink: true } }}
842+
sx={{ width: 150 }}
843+
/>
844+
<Typography variant="body2" color="text.secondary" sx={{ ml: 2 }}>
845+
Y-Axis (μs):
846+
</Typography>
847+
<TextField
848+
label="Min"
849+
type="number"
850+
size="small"
851+
value={yMinInput}
852+
onChange={(e) => setYMinInput(e.target.value)}
853+
sx={{ width: 120 }}
854+
/>
855+
<TextField
856+
label="Max"
857+
type="number"
858+
size="small"
859+
value={yMaxInput}
860+
onChange={(e) => setYMaxInput(e.target.value)}
861+
sx={{ width: 120 }}
862+
/>
863+
<Button
864+
variant="contained"
865+
size="small"
866+
onClick={handleApplyAxisRange}
867+
sx={{ height: 40 }}
868+
>
869+
Apply
870+
</Button>
871+
</Stack>
755872
</Box>
756873
);
757874
}

0 commit comments

Comments
 (0)