d824abcimprove timeseries viewJeremy Magland 1import { useEffect, useMemo, useReducer, useState } from "react";
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;
d824abcimprove timeseries viewJeremy Magland 33 const [container, setContainer] = useState<HTMLDivElement | null>(null);
be099efview dataset dataJeremy Magland 34 const [worker, setWorker] = useState<Worker | null>(null);
35 const [margins] = useState<Margins>({
36 left: 50,
37 right: 20,
38 top: 20,
39 bottom: 50,
40 });
46 const loadRangeData = async () => {
47 try {
48 setIsLoading(true);
49 const start = Math.floor(xRange.min);
50 const end = Math.ceil(xRange.max) + 1;
51 const rangeData = await client.fetchRange(start, end);
52 setDataY(rangeData);
53 const dT = Array.from(
54 { length: rangeData.length },
55 (_, i) => i + start,
56 );
57 setDataT(dT);
58 setError(null);
59 } catch (err) {
60 setError(
61 err instanceof Error ? err.message : "Failed to load data range",
62 );
63 } finally {
64 setIsLoading(false);
65 }
66 };
68 loadRangeData();
69 }, [client, xRange]);
71 // Update xRange when client is initialized
72 useEffect(() => {
73 if (client) {
74 const shape = client.getShape();
76 type: "SET_X_RANGE",
79 }
be099efview dataset dataJeremy Magland 81
82 // Set up wheel event listener
83 useEffect(() => {
be099efview dataset dataJeremy Magland 85
86 const handleWheel = (e: WheelEvent) => {
87 e.preventDefault();
89 const rect = container.getBoundingClientRect();
90 const x = e.clientX - rect.left;
91 const xRatio =
92 (x - margins.left) / (width - margins.left - margins.right);
94 // Calculate zoom center in data coordinates
95 const zoomCenter = xRange.min + (xRange.max - xRange.min) * xRatio;
97 // Calculate new range
99 const shape = client.getShape();
be099efview dataset dataJeremy Magland 100
101 // Ensure we don't zoom out beyond data bounds
102 const newMin = Math.max(
103 0,
104 zoomCenter - (zoomCenter - xRange.min) * zoomFactor,
105 );
106 const newMax = Math.min(
109 );
111 dispatch({ type: "SET_X_RANGE", range: { min: newMin, max: newMax } });
112 };
114 container.addEventListener("wheel", handleWheel, { passive: false });
115 return () => {
116 container.removeEventListener("wheel", handleWheel);
117 };
be099efview dataset dataJeremy Magland 119
120 // Set up mouse event listeners for panning
121 useEffect(() => {
be099efview dataset dataJeremy Magland 123
124 const handleMouseDown = (e: MouseEvent) => {
125 dispatch({ type: "SET_IS_DRAGGING", isDragging: true });
126 dispatch({ type: "SET_LAST_DRAG_X", x: e.clientX });
127 };
129 const handleMouseMove = (e: MouseEvent) => {
be099efview dataset dataJeremy Magland 131
132 const deltaX = e.clientX - lastDragX;
133 const xRatio = deltaX / (width - margins.left - margins.right);
134 const dataDelta = (xRange.max - xRange.min) * xRatio;
be099efview dataset dataJeremy Magland 136
137 if (xRange.min - dataDelta < 0) return;
be099efview dataset dataJeremy Magland 139
140 const newMin = xRange.min - dataDelta;
141 const newMax = xRange.max - dataDelta;
143 // Only update if we're still within bounds
be099efview dataset dataJeremy Magland 145 dispatch({ type: "SET_X_RANGE", range: { min: newMin, max: newMax } });
146 }
148 dispatch({ type: "SET_LAST_DRAG_X", x: e.clientX });
149 };
151 const handleMouseUp = () => {
152 dispatch({ type: "SET_IS_DRAGGING", isDragging: false });
153 dispatch({ type: "SET_LAST_DRAG_X", x: 0 });
154 };
156 container.addEventListener("mousedown", handleMouseDown);
157 window.addEventListener("mousemove", handleMouseMove);
158 window.addEventListener("mouseup", handleMouseUp);
160 return () => {
161 container.removeEventListener("mousedown", handleMouseDown);
162 window.removeEventListener("mousemove", handleMouseMove);
163 window.removeEventListener("mouseup", handleMouseUp);
164 };
d824abcimprove timeseries viewJeremy Magland 165 }, [container, client, width, margins, xRange, isDragging, lastDragX]);
be099efview dataset dataJeremy Magland 166
167 // Set worker
168 useEffect(() => {
169 if (!canvasElement) return;
170 const worker = new Worker(
171 new URL("./TimeseriesViewWorker", import.meta.url),
172 {
173 type: "module",
174 },
175 );
176 let offscreenCanvas: OffscreenCanvas;
177 try {
178 offscreenCanvas = canvasElement.transferControlToOffscreen();
179 } catch (err) {
180 console.warn(err);
181 console.warn(
182 "Unable to transfer control to offscreen canvas (expected during dev)",
183 );
184 return;
185 }
186 const msg: WorkerMessage = {
187 type: "initialize",
188 canvas: offscreenCanvas,
189 };
190 worker.postMessage(msg, [offscreenCanvas]);
192 setWorker(worker);
194 return () => {
195 worker.terminate();
196 };
197 }, [canvasElement]);
199 // Calculate yRange from data
200 const yRange = useMemo<Range>(() => {
202 const values = Array.from(dataY);
205 max: Math.max(...values),
be099efview dataset dataJeremy Magland 208
209 // Handle dimension changes
210 useEffect(() => {
211 if (!worker) return;
213 if (!dataT) return;
be099efview dataset dataJeremy Magland 214
215 const msg: WorkerMessage = {
216 type: "render",
218 timeseriesY: Array.from(dataY),
220 height,
221 margins,
222 xRange,
223 yRange,
224 };
d824abcimprove timeseries viewJeremy Magland 227 }, [width, height, dataT, dataY, worker, margins, xRange, yRange]);
be099efview dataset dataJeremy Magland 228
229 // Render cursor on overlay canvas
230 useEffect(() => {
d824abcimprove timeseries viewJeremy Magland 231 if (!overlayCanvasElement || selectedIndex === null || !dataY) return;
233 if (!ctx) return;
235 // Clear overlay canvas
236 ctx.clearRect(0, 0, width, height);
238 // Draw cursor line
239 const xRatio = (selectedIndex - xRange.min) / (xRange.max - xRange.min);
240 const x = margins.left + xRatio * (width - margins.left - margins.right);
241 ctx.beginPath();
242 ctx.strokeStyle = "#ff0000";
243 ctx.lineWidth = 1;
244 ctx.setLineDash([4, 4]);
245 ctx.moveTo(x, margins.top);
246 ctx.lineTo(x, height - margins.bottom);
247 ctx.stroke();
248 }, [
249 selectedIndex,
250 overlayCanvasElement,
251 width,
252 height,
253 margins,
255 dataY,
257 ]);
260 if (selectedIndex === -1 || !dataT || !dataY) return null;
261 for (let i = 0; i < dataT.length; i++) {
262 if (dataT[i] === selectedIndex) {
263 return dataY[i];
264 }
265 }
266 return null;
267 }, [selectedIndex, dataT, dataY]);
269 if (error || clientError) {
270 return <div>Error loading data: {error || clientError}</div>;
271 }
273 if (isLoading && !dataY) {
274 return <div>Loading...</div>;
277 const handleCanvasClick = (e: React.MouseEvent<HTMLDivElement>) => {
d824abcimprove timeseries viewJeremy Magland 278 if (!overlayCanvasElement || !dataY || isDragging) return;
be099efview dataset dataJeremy Magland 279 const rect = overlayCanvasElement.getBoundingClientRect();
280 const x = e.clientX - rect.left;
281 const xRatio = (x - margins.left) / (width - margins.left - margins.right);
282 const index = Math.round(xRange.min + xRatio * (xRange.max - xRange.min));
285 }
286 };
288 return (
289 <div style={{ position: "relative", width, height: height + 30 }}>
290 <div
293 onClick={handleCanvasClick}
294 >
295 <canvas
296 ref={setCanvasElement}
299 height={height}
300 style={{
301 position: "absolute",
302 width: "100%",
303 height: "100%",
304 }}
305 />
306 <canvas
307 ref={setOverlayCanvasElement}
308 width={width}
309 height={height}
310 style={{
311 position: "absolute",
312 width: "100%",
313 height: "100%",
314 pointerEvents: "none",
315 }}
316 />
317 </div>
be099efview dataset dataJeremy Magland 319 <div style={{ height: 30, padding: "5px 0", color: "#666" }}>
d824abcimprove timeseries viewJeremy Magland 320 Index: {selectedIndex}, Value: {selectedValue?.toFixed(3)}
322 )}
323 </div>
324 );
325};
327export default TimeseriesView;