3b1d188improve uiJeremy Magland 1import { useEffect, useMemo, useReducer, useState, useCallback } from "react";
d824abcimprove timeseries viewJeremy Magland 2import { SupportedTypedArray } from "../../hooks/TimeseriesDataClient";
3import { useTimeseriesDataClient } from "../../hooks/useTimeseriesDataClient";
5import { Margins, Range, WorkerMessage } from "./WorkerTypes";
d824abcimprove timeseries viewJeremy Magland 6import { initialState, timeseriesViewReducer } from "./timeseriesViewReducer";
be099efview dataset dataJeremy Magland 7
8interface TimeseriesViewProps {
9 width: number;
10 height: number;
11 dataset: Dataset;
12}
14const TimeseriesView: React.FC<TimeseriesViewProps> = ({
15 width,
16 height,
17 dataset,
18}) => {
d824abcimprove timeseries viewJeremy Magland 19 const { client, error: clientError } = useTimeseriesDataClient(dataset);
20 const [dataT, setDataT] = useState<number[] | null>(null);
21 const [dataY, setDataY] = useState<SupportedTypedArray | null>(null);
22 const [error, setError] = useState<string | null>(clientError);
23 const [isLoading, setIsLoading] = useState(false);
be099efview dataset dataJeremy Magland 24
25 const [canvasElement, setCanvasElement] = useState<HTMLCanvasElement | null>(
26 null,
27 );
28 const [overlayCanvasElement, setOverlayCanvasElement] =
29 useState<HTMLCanvasElement | null>(null);
30 const [state, dispatch] = useReducer(timeseriesViewReducer, initialState);
31 const { selectedIndex, isDragging, lastDragX, xRange } = state;
33 const [showHint, setShowHint] = useState(true);
35 // Hide hint when user interacts with the graph
36 const hideHint = useCallback(() => {
37 setShowHint(false);
38 }, []);
40 // Auto-hide hint after 4 seconds
41 useEffect(() => {
42 if (showHint) {
43 const timer = setTimeout(() => {
44 setShowHint(false);
45 }, 5000);
46 return () => clearTimeout(timer);
47 }
48 }, [showHint]);
be099efview dataset dataJeremy Magland 49
d824abcimprove timeseries viewJeremy Magland 50 const [container, setContainer] = useState<HTMLDivElement | null>(null);
be099efview dataset dataJeremy Magland 51 const [worker, setWorker] = useState<Worker | null>(null);
52 const [margins] = useState<Margins>({
53 left: 50,
54 right: 20,
55 top: 20,
56 bottom: 50,
57 });
63 const loadRangeData = async () => {
64 try {
65 setIsLoading(true);
66 const start = Math.floor(xRange.min);
67 const end = Math.ceil(xRange.max) + 1;
68 const rangeData = await client.fetchRange(start, end);
69 setDataY(rangeData);
70 const dT = Array.from(
71 { length: rangeData.length },
72 (_, i) => i + start,
73 );
74 setDataT(dT);
75 setError(null);
76 } catch (err) {
77 setError(
78 err instanceof Error ? err.message : "Failed to load data range",
79 );
80 } finally {
81 setIsLoading(false);
82 }
83 };
85 loadRangeData();
86 }, [client, xRange]);
88 // Update xRange when client is initialized
89 useEffect(() => {
90 if (client) {
91 const shape = client.getShape();
93 type: "SET_X_RANGE",
96 }
be099efview dataset dataJeremy Magland 98
99 // Set up wheel event listener
100 useEffect(() => {
be099efview dataset dataJeremy Magland 102
103 const handleWheel = (e: WheelEvent) => {
105 return; // Allow page scrolling if wheel zoom not enabled
106 }
109 const rect = container.getBoundingClientRect();
110 const x = e.clientX - rect.left;
111 const xRatio =
112 (x - margins.left) / (width - margins.left - margins.right);
114 // Calculate zoom center in data coordinates
115 const zoomCenter = xRange.min + (xRange.max - xRange.min) * xRatio;
117 // Calculate new range
be099efview dataset dataJeremy Magland 120
121 // Ensure we don't zoom out beyond data bounds
122 const newMin = Math.max(
123 0,
124 zoomCenter - (zoomCenter - xRange.min) * zoomFactor,
125 );
126 const newMax = Math.min(
129 );
131 dispatch({ type: "SET_X_RANGE", range: { min: newMin, max: newMax } });
132 };
134 container.addEventListener("wheel", handleWheel, { passive: false });
135 return () => {
136 container.removeEventListener("wheel", handleWheel);
137 };
3b1d188improve uiJeremy Magland 138 }, [container, client, width, margins, xRange, isWheelEnabled]);
be099efview dataset dataJeremy Magland 139
140 // Set up mouse event listeners for panning
141 useEffect(() => {
be099efview dataset dataJeremy Magland 143
144 const handleMouseDown = (e: MouseEvent) => {
145 dispatch({ type: "SET_IS_DRAGGING", isDragging: true });
146 dispatch({ type: "SET_LAST_DRAG_X", x: e.clientX });
147 };
149 const handleMouseMove = (e: MouseEvent) => {
be099efview dataset dataJeremy Magland 151
152 const deltaX = e.clientX - lastDragX;
153 const xRatio = deltaX / (width - margins.left - margins.right);
154 const dataDelta = (xRange.max - xRange.min) * xRatio;
be099efview dataset dataJeremy Magland 156
157 if (xRange.min - dataDelta < 0) return;
be099efview dataset dataJeremy Magland 159
160 const newMin = xRange.min - dataDelta;
161 const newMax = xRange.max - dataDelta;
163 // Only update if we're still within bounds
be099efview dataset dataJeremy Magland 165 dispatch({ type: "SET_X_RANGE", range: { min: newMin, max: newMax } });
166 }
168 dispatch({ type: "SET_LAST_DRAG_X", x: e.clientX });
169 };
171 const handleMouseUp = () => {
172 dispatch({ type: "SET_IS_DRAGGING", isDragging: false });
173 dispatch({ type: "SET_LAST_DRAG_X", x: 0 });
174 };
176 container.addEventListener("mousedown", handleMouseDown);
177 window.addEventListener("mousemove", handleMouseMove);
178 window.addEventListener("mouseup", handleMouseUp);
180 return () => {
181 container.removeEventListener("mousedown", handleMouseDown);
182 window.removeEventListener("mousemove", handleMouseMove);
183 window.removeEventListener("mouseup", handleMouseUp);
184 };
d824abcimprove timeseries viewJeremy Magland 185 }, [container, client, width, margins, xRange, isDragging, lastDragX]);
be099efview dataset dataJeremy Magland 186
187 // Set worker
188 useEffect(() => {
189 if (!canvasElement) return;
190 const worker = new Worker(
191 new URL("./TimeseriesViewWorker", import.meta.url),
192 {
193 type: "module",
194 },
195 );
196 let offscreenCanvas: OffscreenCanvas;
197 try {
198 offscreenCanvas = canvasElement.transferControlToOffscreen();
199 } catch (err) {
200 console.warn(err);
201 console.warn(
202 "Unable to transfer control to offscreen canvas (expected during dev)",
203 );
204 return;
205 }
206 const msg: WorkerMessage = {
207 type: "initialize",
208 canvas: offscreenCanvas,
209 };
210 worker.postMessage(msg, [offscreenCanvas]);
212 setWorker(worker);
214 return () => {
215 worker.terminate();
216 };
217 }, [canvasElement]);
219 // Calculate yRange from data
220 const yRange = useMemo<Range>(() => {
224 max: computeMax(dataY),
be099efview dataset dataJeremy Magland 227
228 // Handle dimension changes
229 useEffect(() => {
230 if (!worker) return;
232 if (!dataT) return;
be099efview dataset dataJeremy Magland 233
234 const msg: WorkerMessage = {
235 type: "render",
237 timeseriesY: Array.from(dataY),
239 height,
240 margins,
241 xRange,
242 yRange,
243 };
244 worker.postMessage(msg);
d824abcimprove timeseries viewJeremy Magland 245 }, [width, height, dataT, dataY, worker, margins, xRange, yRange]);
be099efview dataset dataJeremy Magland 246
247 // Render cursor on overlay canvas
248 useEffect(() => {
d824abcimprove timeseries viewJeremy Magland 249 if (!overlayCanvasElement || selectedIndex === null || !dataY) return;
251 if (!ctx) return;
253 // Clear overlay canvas
254 ctx.clearRect(0, 0, width, height);
256 // Draw cursor line
257 const xRatio = (selectedIndex - xRange.min) / (xRange.max - xRange.min);
258 const x = margins.left + xRatio * (width - margins.left - margins.right);
259 ctx.beginPath();
260 ctx.strokeStyle = "#ff0000";
261 ctx.lineWidth = 1;
262 ctx.setLineDash([4, 4]);
263 ctx.moveTo(x, margins.top);
264 ctx.lineTo(x, height - margins.bottom);
265 ctx.stroke();
266 }, [
267 selectedIndex,
268 overlayCanvasElement,
269 width,
270 height,
271 margins,
273 dataY,
275 ]);
278 if (selectedIndex === -1 || !dataT || !dataY) return null;
279 for (let i = 0; i < dataT.length; i++) {
280 if (dataT[i] === selectedIndex) {
281 return dataY[i];
282 }
283 }
284 return null;
285 }, [selectedIndex, dataT, dataY]);
287 if (error || clientError) {
288 return <div>Error loading data: {error || clientError}</div>;
289 }
291 if (isLoading && !dataY) {
292 return <div>Loading...</div>;
295 const handleCanvasClick = (e: React.MouseEvent<HTMLDivElement>) => {
d824abcimprove timeseries viewJeremy Magland 296 if (!overlayCanvasElement || !dataY || isDragging) return;
3b1d188improve uiJeremy Magland 297
298 // Enable wheel zooming on first click
299 if (!isWheelEnabled) {
300 setIsWheelEnabled(true);
301 }
be099efview dataset dataJeremy Magland 303 const rect = overlayCanvasElement.getBoundingClientRect();
304 const x = e.clientX - rect.left;
305 const xRatio = (x - margins.left) / (width - margins.left - margins.right);
306 const index = Math.round(xRange.min + xRatio * (xRange.max - xRange.min));
309 }
310 };
312 return (
313 <div style={{ position: "relative", width, height: height + 30 }}>
315 <div
316 style={{
317 position: "absolute",
318 top: margins.top + 10,
319 right: margins.right + 10,
320 display: "flex",
321 flexDirection: "column",
322 alignItems: "flex-end",
323 gap: "8px",
324 zIndex: 10,
325 opacity: showHint ? 0.8 : 0,
326 transition: "opacity 0.5s ease-out",
327 pointerEvents: "none",
328 fontSize: "12px",
329 color: "#666",
330 }}
331 >
332 <div
333 style={{
334 display: "flex",
335 alignItems: "center",
336 gap: "4px",
337 backgroundColor: "rgba(255, 255, 255, 0.9)",
338 padding: "2px 6px",
339 borderRadius: "4px",
340 }}
341 >
342 <span>Drag to pan</span>
343 <svg width="14" height="14" viewBox="0 0 24 24" fill="#666">
344 <path d="M15 3h2v5h-2V3zm4 0h2v5h-2V3zm-4 7h2v5h-2v-5zm4 0h2v5h-2v-5zm-4 7h2v5h-2v-5zm4 0h2v5h-2v-5z" />
345 </svg>
346 </div>
347 <div
348 style={{
349 display: "flex",
350 alignItems: "center",
351 gap: "4px",
352 backgroundColor: "rgba(255, 255, 255, 0.9)",
353 padding: "2px 6px",
354 borderRadius: "4px",
355 }}
356 >
357 <span>Scroll to zoom</span>
358 <svg width="14" height="14" viewBox="0 0 24 24" fill="#666">
359 <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" />
360 </svg>
361 </div>
362 </div>
363 )}
368 handleCanvasClick(e);
369 hideHint();
370 }}
371 onMouseDown={hideHint}
373 <canvas
374 ref={setCanvasElement}
377 height={height}
378 style={{
379 position: "absolute",
380 width: "100%",
381 height: "100%",
382 }}
383 />
384 <canvas
385 ref={setOverlayCanvasElement}
386 width={width}
387 height={height}
388 style={{
389 position: "absolute",
390 width: "100%",
391 height: "100%",
392 pointerEvents: "none",
393 }}
394 />
395 </div>
be099efview dataset dataJeremy Magland 397 <div style={{ height: 30, padding: "5px 0", color: "#666" }}>
d824abcimprove timeseries viewJeremy Magland 398 Index: {selectedIndex}, Value: {selectedValue?.toFixed(3)}
400 )}
401 </div>
402 );
403};
406 let min = Infinity;
407 for (let i = 0; i < data.length; i++) {
408 if (data[i] < min) {
409 min = data[i];
410 }
411 }
412 return min;
413};
415const computeMax = (data: SupportedTypedArray) => {
416 let max = -Infinity;
417 for (let i = 0; i < data.length; i++) {
418 if (data[i] > max) {
419 max = data[i];
420 }
421 }
422 return max;
423};