/ concept-collection / benchcompress
Sign in
concept-collection / benchcompress
benchcompress / web-ui / src / components / dataset / TimeseriesView.tsx
437 lines · 12.5 KBCodeBlameHistory
3b1d188improve uiJeremy Magland 1import { useEffect, useMemo, useReducer, useState, useCallback } from "react";
1ac6645long descriptionsJeremy Magland 2import TimeseriesNavigationBar from "./TimeseriesNavigationBar";
d824abcimprove timeseries viewJeremy Magland 3import { SupportedTypedArray } from "../../hooks/TimeseriesDataClient";
4import { useTimeseriesDataClient } from "../../hooks/useTimeseriesDataClient";
be099efview dataset dataJeremy Magland 5import { Dataset } from "../../types";
6import { Margins, Range, WorkerMessage } from "./WorkerTypes";
d824abcimprove timeseries viewJeremy Magland 7import { initialState, timeseriesViewReducer } from "./timeseriesViewReducer";
be099efview dataset dataJeremy Magland 8
9interface TimeseriesViewProps {
10 width: number;
11 height: number;
12 dataset: Dataset;
15const TimeseriesView: React.FC<TimeseriesViewProps> = ({
16 width,
17 height,
18 dataset,
19}) => {
d824abcimprove timeseries viewJeremy Magland 20 const { client, error: clientError } = useTimeseriesDataClient(dataset);
21 const [dataT, setDataT] = useState<number[] | null>(null);
22 const [dataY, setDataY] = useState<SupportedTypedArray | null>(null);
23 const [error, setError] = useState<string | null>(clientError);
24 const [isLoading, setIsLoading] = useState(false);
be099efview dataset dataJeremy Magland 25
26 const [canvasElement, setCanvasElement] = useState<HTMLCanvasElement | null>(
27 null,
28 );
29 const [overlayCanvasElement, setOverlayCanvasElement] =
30 useState<HTMLCanvasElement | null>(null);
31 const [state, dispatch] = useReducer(timeseriesViewReducer, initialState);
32 const { selectedIndex, isDragging, lastDragX, xRange } = state;
3b1d188improve uiJeremy Magland 33 const [isWheelEnabled, setIsWheelEnabled] = useState(false);
34 const [showHint, setShowHint] = useState(true);
36 // Hide hint when user interacts with the graph
37 const hideHint = useCallback(() => {
38 setShowHint(false);
39 }, []);
41 // Auto-hide hint after 4 seconds
42 useEffect(() => {
43 if (showHint) {
44 const timer = setTimeout(() => {
45 setShowHint(false);
46 }, 5000);
47 return () => clearTimeout(timer);
48 }
49 }, [showHint]);
be099efview dataset dataJeremy Magland 50
d824abcimprove timeseries viewJeremy Magland 51 const [container, setContainer] = useState<HTMLDivElement | null>(null);
be099efview dataset dataJeremy Magland 52 const [worker, setWorker] = useState<Worker | null>(null);
53 const [margins] = useState<Margins>({
54 left: 50,
55 right: 20,
56 top: 20,
57 bottom: 50,
58 });
d824abcimprove timeseries viewJeremy Magland 60 // Load data for current range
be099efview dataset dataJeremy Magland 61 useEffect(() => {
d824abcimprove timeseries viewJeremy Magland 62 if (!client || !xRange) return;
64 const loadRangeData = async () => {
65 try {
66 setIsLoading(true);
67 const start = Math.floor(xRange.min);
68 const end = Math.ceil(xRange.max) + 1;
69 const rangeData = await client.fetchRange(start, end);
70 setDataY(rangeData);
71 const dT = Array.from(
72 { length: rangeData.length },
73 (_, i) => i + start,
74 );
75 setDataT(dT);
76 setError(null);
77 } catch (err) {
78 setError(
79 err instanceof Error ? err.message : "Failed to load data range",
80 );
81 } finally {
82 setIsLoading(false);
83 }
84 };
86 loadRangeData();
87 }, [client, xRange]);
89 // Update xRange when client is initialized
90 useEffect(() => {
91 if (client) {
92 const shape = client.getShape();
be099efview dataset dataJeremy Magland 93 dispatch({
94 type: "SET_X_RANGE",
bde0876rearrange tabsJeremy Magland 95 range: { min: 0, max: Math.min(999, shape - 1) },
be099efview dataset dataJeremy Magland 96 });
97 }
d824abcimprove timeseries viewJeremy Magland 98 }, [client]);
be099efview dataset dataJeremy Magland 99
100 // Set up wheel event listener
101 useEffect(() => {
d824abcimprove timeseries viewJeremy Magland 102 if (!container || !client) return;
be099efview dataset dataJeremy Magland 103
104 const handleWheel = (e: WheelEvent) => {
3b1d188improve uiJeremy Magland 105 if (!isWheelEnabled) {
106 return; // Allow page scrolling if wheel zoom not enabled
107 }
be099efview dataset dataJeremy Magland 108 e.preventDefault();
110 const rect = container.getBoundingClientRect();
111 const x = e.clientX - rect.left;
112 const xRatio =
113 (x - margins.left) / (width - margins.left - margins.right);
115 // Calculate zoom center in data coordinates
116 const zoomCenter = xRange.min + (xRange.max - xRange.min) * xRatio;
118 // Calculate new range
4cc92d1incr version of algJeremy Magland 119 const zoomFactor = e.deltaY > 0 ? 1.1 : 1 / 1.1;
d824abcimprove timeseries viewJeremy Magland 120 const shape = client.getShape();
be099efview dataset dataJeremy Magland 121
122 // Ensure we don't zoom out beyond data bounds
123 const newMin = Math.max(
124 0,
125 zoomCenter - (zoomCenter - xRange.min) * zoomFactor,
126 );
127 const newMax = Math.min(
d824abcimprove timeseries viewJeremy Magland 128 shape - 1,
be099efview dataset dataJeremy Magland 129 zoomCenter + (xRange.max - zoomCenter) * zoomFactor,
130 );
132 dispatch({ type: "SET_X_RANGE", range: { min: newMin, max: newMax } });
133 };
135 container.addEventListener("wheel", handleWheel, { passive: false });
136 return () => {
137 container.removeEventListener("wheel", handleWheel);
138 };
3b1d188improve uiJeremy Magland 139 }, [container, client, width, margins, xRange, isWheelEnabled]);
be099efview dataset dataJeremy Magland 140
141 // Set up mouse event listeners for panning
142 useEffect(() => {
d824abcimprove timeseries viewJeremy Magland 143 if (!container || !client) return;
be099efview dataset dataJeremy Magland 144
145 const handleMouseDown = (e: MouseEvent) => {
146 dispatch({ type: "SET_IS_DRAGGING", isDragging: true });
147 dispatch({ type: "SET_LAST_DRAG_X", x: e.clientX });
148 };
150 const handleMouseMove = (e: MouseEvent) => {
d824abcimprove timeseries viewJeremy Magland 151 if (!isDragging || lastDragX === 0) return;
be099efview dataset dataJeremy Magland 152
153 const deltaX = e.clientX - lastDragX;
154 const xRatio = deltaX / (width - margins.left - margins.right);
155 const dataDelta = (xRange.max - xRange.min) * xRatio;
d824abcimprove timeseries viewJeremy Magland 156 const shape = client.getShape();
be099efview dataset dataJeremy Magland 157
158 if (xRange.min - dataDelta < 0) return;
d824abcimprove timeseries viewJeremy Magland 159 if (xRange.max - dataDelta > shape - 1) return;
be099efview dataset dataJeremy Magland 160
161 const newMin = xRange.min - dataDelta;
162 const newMax = xRange.max - dataDelta;
164 // Only update if we're still within bounds
d824abcimprove timeseries viewJeremy Magland 165 if (newMin >= 0 && newMax <= shape - 1) {
be099efview dataset dataJeremy Magland 166 dispatch({ type: "SET_X_RANGE", range: { min: newMin, max: newMax } });
167 }
169 dispatch({ type: "SET_LAST_DRAG_X", x: e.clientX });
170 };
172 const handleMouseUp = () => {
173 dispatch({ type: "SET_IS_DRAGGING", isDragging: false });
174 dispatch({ type: "SET_LAST_DRAG_X", x: 0 });
175 };
177 container.addEventListener("mousedown", handleMouseDown);
178 window.addEventListener("mousemove", handleMouseMove);
179 window.addEventListener("mouseup", handleMouseUp);
181 return () => {
182 container.removeEventListener("mousedown", handleMouseDown);
183 window.removeEventListener("mousemove", handleMouseMove);
184 window.removeEventListener("mouseup", handleMouseUp);
185 };
d824abcimprove timeseries viewJeremy Magland 186 }, [container, client, width, margins, xRange, isDragging, lastDragX]);
be099efview dataset dataJeremy Magland 187
188 // Set worker
189 useEffect(() => {
190 if (!canvasElement) return;
191 const worker = new Worker(
192 new URL("./TimeseriesViewWorker", import.meta.url),
193 {
194 type: "module",
195 },
196 );
197 let offscreenCanvas: OffscreenCanvas;
198 try {
199 offscreenCanvas = canvasElement.transferControlToOffscreen();
200 } catch (err) {
201 console.warn(err);
202 console.warn(
203 "Unable to transfer control to offscreen canvas (expected during dev)",
204 );
205 return;
206 }
207 const msg: WorkerMessage = {
208 type: "initialize",
209 canvas: offscreenCanvas,
210 };
211 worker.postMessage(msg, [offscreenCanvas]);
213 setWorker(worker);
215 return () => {
216 worker.terminate();
217 };
218 }, [canvasElement]);
220 // Calculate yRange from data
221 const yRange = useMemo<Range>(() => {
d824abcimprove timeseries viewJeremy Magland 222 if (!dataY) return { min: 0, max: 1 };
be099efview dataset dataJeremy Magland 223 return {
88280f2improve loading speedJeremy Magland 224 min: computeMin(dataY),
225 max: computeMax(dataY),
be099efview dataset dataJeremy Magland 226 };
d824abcimprove timeseries viewJeremy Magland 227 }, [dataY]);
be099efview dataset dataJeremy Magland 228
229 // Handle dimension changes
230 useEffect(() => {
231 if (!worker) return;
d824abcimprove timeseries viewJeremy Magland 232 if (!dataY) return;
233 if (!dataT) return;
be099efview dataset dataJeremy Magland 234
235 const msg: WorkerMessage = {
236 type: "render",
d824abcimprove timeseries viewJeremy Magland 237 timeseriesT: dataT,
238 timeseriesY: Array.from(dataY),
be099efview dataset dataJeremy Magland 239 width,
240 height,
241 margins,
242 xRange,
243 yRange,
244 };
245 worker.postMessage(msg);
d824abcimprove timeseries viewJeremy Magland 246 }, [width, height, dataT, dataY, worker, margins, xRange, yRange]);
be099efview dataset dataJeremy Magland 247
248 // Render cursor on overlay canvas
249 useEffect(() => {
d824abcimprove timeseries viewJeremy Magland 250 if (!overlayCanvasElement || selectedIndex === null || !dataY) return;
be099efview dataset dataJeremy Magland 251 const ctx = overlayCanvasElement.getContext("2d");
252 if (!ctx) return;
254 // Clear overlay canvas
255 ctx.clearRect(0, 0, width, height);
257 // Draw cursor line
258 const xRatio = (selectedIndex - xRange.min) / (xRange.max - xRange.min);
259 const x = margins.left + xRatio * (width - margins.left - margins.right);
260 ctx.beginPath();
261 ctx.strokeStyle = "#ff0000";
262 ctx.lineWidth = 1;
263 ctx.setLineDash([4, 4]);
264 ctx.moveTo(x, margins.top);
265 ctx.lineTo(x, height - margins.bottom);
266 ctx.stroke();
267 }, [
268 selectedIndex,
269 overlayCanvasElement,
270 width,
271 height,
272 margins,
d824abcimprove timeseries viewJeremy Magland 273 dataT,
274 dataY,
be099efview dataset dataJeremy Magland 275 xRange,
276 ]);
d824abcimprove timeseries viewJeremy Magland 278 const selectedValue = useMemo(() => {
279 if (selectedIndex === -1 || !dataT || !dataY) return null;
280 for (let i = 0; i < dataT.length; i++) {
281 if (dataT[i] === selectedIndex) {
282 return dataY[i];
283 }
284 }
285 return null;
286 }, [selectedIndex, dataT, dataY]);
288 if (error || clientError) {
289 return <div>Error loading data: {error || clientError}</div>;
290 }
292 if (isLoading && !dataY) {
293 return <div>Loading...</div>;
be099efview dataset dataJeremy Magland 294 }
296 const handleCanvasClick = (e: React.MouseEvent<HTMLDivElement>) => {
d824abcimprove timeseries viewJeremy Magland 297 if (!overlayCanvasElement || !dataY || isDragging) return;
3b1d188improve uiJeremy Magland 298
299 // Enable wheel zooming on first click
300 if (!isWheelEnabled) {
301 setIsWheelEnabled(true);
302 }
be099efview dataset dataJeremy Magland 304 const rect = overlayCanvasElement.getBoundingClientRect();
305 const x = e.clientX - rect.left;
306 const xRatio = (x - margins.left) / (width - margins.left - margins.right);
307 const index = Math.round(xRange.min + xRatio * (xRange.max - xRange.min));
d824abcimprove timeseries viewJeremy Magland 308 if (index >= 0) {
be099efview dataset dataJeremy Magland 309 dispatch({ type: "SET_SELECTED_INDEX", index });
310 }
311 };
313 return (
1ac6645long descriptionsJeremy Magland 314 <div style={{ position: "relative", width, height: height + 50 }}>
315 <div style={{ marginBottom: 10, height: 20 }}>
316 <TimeseriesNavigationBar
317 width={width}
318 height={20}
319 totalRange={{ min: 0, max: client ? client.getShape() - 1 : 999 }}
320 viewRange={xRange}
321 onViewRangeChange={(range) =>
322 dispatch({ type: "SET_X_RANGE", range })
323 }
324 />
325 </div>
3b1d188improve uiJeremy Magland 326 {showHint && (
327 <div
328 style={{
329 position: "absolute",
330 top: margins.top + 10,
331 right: margins.right + 10,
332 display: "flex",
333 flexDirection: "column",
334 alignItems: "flex-end",
335 gap: "8px",
336 zIndex: 10,
337 opacity: showHint ? 0.8 : 0,
338 transition: "opacity 0.5s ease-out",
339 pointerEvents: "none",
340 fontSize: "12px",
341 color: "#666",
342 }}
343 >
344 <div
345 style={{
346 display: "flex",
347 alignItems: "center",
348 gap: "4px",
349 backgroundColor: "rgba(255, 255, 255, 0.9)",
350 padding: "2px 6px",
351 borderRadius: "4px",
352 }}
353 >
354 <span>Drag to pan</span>
355 <svg width="14" height="14" viewBox="0 0 24 24" fill="#666">
356 <path d="M15 3h2v5h-2V3zm4 0h2v5h-2V3zm-4 7h2v5h-2v-5zm4 0h2v5h-2v-5zm-4 7h2v5h-2v-5zm4 0h2v5h-2v-5z" />
357 </svg>
358 </div>
359 <div
360 style={{
361 display: "flex",
362 alignItems: "center",
363 gap: "4px",
364 backgroundColor: "rgba(255, 255, 255, 0.9)",
365 padding: "2px 6px",
366 borderRadius: "4px",
367 }}
368 >
369 <span>Scroll to zoom</span>
370 <svg width="14" height="14" viewBox="0 0 24 24" fill="#666">
371 <path d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9-4.03-9-9-9zm0 16c-3.86 0-7-3.14-7-7s3.14-7 7-7 7 3.14 7 7-3.14 7-7 7zm1-11h-2v3H8v2h3v3h2v-3h3v-2h-3V8z" />
372 </svg>
373 </div>
374 </div>
375 )}
be099efview dataset dataJeremy Magland 376 <div
d824abcimprove timeseries viewJeremy Magland 377 ref={setContainer}
be099efview dataset dataJeremy Magland 378 style={{ position: "relative", width, height }}
3b1d188improve uiJeremy Magland 379 onClick={(e) => {
380 handleCanvasClick(e);
381 hideHint();
382 }}
383 onMouseDown={hideHint}
be099efview dataset dataJeremy Magland 384 >
385 <canvas
386 ref={setCanvasElement}
d824abcimprove timeseries viewJeremy Magland 387 key={`canvas-${width}-${height}`}
be099efview dataset dataJeremy Magland 388 width={width}
389 height={height}
390 style={{
391 position: "absolute",
392 width: "100%",
393 height: "100%",
394 }}
395 />
396 <canvas
397 ref={setOverlayCanvasElement}
398 width={width}
399 height={height}
400 style={{
401 position: "absolute",
402 width: "100%",
403 height: "100%",
404 pointerEvents: "none",
405 }}
406 />
407 </div>
d824abcimprove timeseries viewJeremy Magland 408 {selectedIndex !== -1 && dataY && (
be099efview dataset dataJeremy Magland 409 <div style={{ height: 30, padding: "5px 0", color: "#666" }}>
d824abcimprove timeseries viewJeremy Magland 410 Index: {selectedIndex}, Value: {selectedValue?.toFixed(3)}
be099efview dataset dataJeremy Magland 411 </div>
412 )}
413 </div>
414 );
415};
88280f2improve loading speedJeremy Magland 417const computeMin = (data: SupportedTypedArray) => {
418 let min = Infinity;
419 for (let i = 0; i < data.length; i++) {
420 if (data[i] < min) {
421 min = data[i];
422 }
423 }
424 return min;
425};
427const computeMax = (data: SupportedTypedArray) => {
428 let max = -Infinity;
429 for (let i = 0; i < data.length; i++) {
430 if (data[i] > max) {
431 max = data[i];
432 }
433 }
434 return max;
435};
be099efview dataset dataJeremy Magland 437export default TimeseriesView;
moveopenescclose