1import { useEffect, useMemo, useReducer, useState } from "react";
2import { SupportedTypedArray } from "../../hooks/TimeseriesDataClient";
3import { useTimeseriesDataClient } from "../../hooks/useTimeseriesDataClient";
4import { Dataset } from "../../types";
5import { Margins, Range, WorkerMessage } from "./WorkerTypes";
6import { initialState, timeseriesViewReducer } from "./timeseriesViewReducer";
8interface TimeseriesViewProps {
9 width: number;
10 height: number;
11 dataset: Dataset;
12}
14const TimeseriesView: React.FC<TimeseriesViewProps> = ({
15 width,
16 height,
17 dataset,
18}) => {
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);
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 [container, setContainer] = useState<HTMLDivElement | null>(null);
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 });
42 // Load data for current range
43 useEffect(() => {
44 if (!client || !xRange) return;
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();
75 dispatch({
76 type: "SET_X_RANGE",
77 range: { min: 0, max: Math.min(999, shape - 1) },
78 });
79 }
80 }, [client]);
82 // Set up wheel event listener
83 useEffect(() => {
84 if (!container || !client) return;
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
98 const zoomFactor = e.deltaY > 0 ? 1.02 : 1 / 1.02;
99 const shape = client.getShape();
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(
107 shape - 1,
108 zoomCenter + (xRange.max - zoomCenter) * zoomFactor,
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 };
118 }, [container, client, width, margins, xRange]);
120 // Set up mouse event listeners for panning
121 useEffect(() => {
122 if (!container || !client) return;
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) => {
130 if (!isDragging || lastDragX === 0) return;
132 const deltaX = e.clientX - lastDragX;
133 const xRatio = deltaX / (width - margins.left - margins.right);
134 const dataDelta = (xRange.max - xRange.min) * xRatio;
135 const shape = client.getShape();
137 if (xRange.min - dataDelta < 0) return;
138 if (xRange.max - dataDelta > shape - 1) return;
140 const newMin = xRange.min - dataDelta;
141 const newMax = xRange.max - dataDelta;
143 // Only update if we're still within bounds
144 if (newMin >= 0 && newMax <= shape - 1) {
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 };
165 }, [container, client, width, margins, xRange, isDragging, lastDragX]);
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>(() => {
201 if (!dataY) return { min: 0, max: 1 };
202 const values = Array.from(dataY);
203 return {
204 min: Math.min(...values),
205 max: Math.max(...values),
206 };
207 }, [dataY]);
209 // Handle dimension changes
210 useEffect(() => {
211 if (!worker) return;
212 if (!dataY) return;
213 if (!dataT) return;
215 const msg: WorkerMessage = {
216 type: "render",
217 timeseriesT: dataT,
218 timeseriesY: Array.from(dataY),
219 width,
220 height,
221 margins,
222 xRange,
223 yRange,
224 };
225 console.log("--- posting message to worker", msg);
226 worker.postMessage(msg);
227 }, [width, height, dataT, dataY, worker, margins, xRange, yRange]);
229 // Render cursor on overlay canvas
230 useEffect(() => {
231 if (!overlayCanvasElement || selectedIndex === null || !dataY) return;
232 const ctx = overlayCanvasElement.getContext("2d");
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,
254 dataT,
255 dataY,
256 xRange,
257 ]);
259 const selectedValue = useMemo(() => {
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>;
275 }
277 const handleCanvasClick = (e: React.MouseEvent<HTMLDivElement>) => {
278 if (!overlayCanvasElement || !dataY || isDragging) return;
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));
283 if (index >= 0) {
284 dispatch({ type: "SET_SELECTED_INDEX", index });
285 }
286 };
288 return (
289 <div style={{ position: "relative", width, height: height + 30 }}>
290 <div
291 ref={setContainer}
292 style={{ position: "relative", width, height }}
293 onClick={handleCanvasClick}
294 >
295 <canvas
296 ref={setCanvasElement}
297 key={`canvas-${width}-${height}`}
298 width={width}
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>
318 {selectedIndex !== -1 && dataY && (
319 <div style={{ height: 30, padding: "5px 0", color: "#666" }}>
320 Index: {selectedIndex}, Value: {selectedValue?.toFixed(3)}
321 </div>
322 )}
323 </div>
324 );
325};
327export default TimeseriesView;