be099efview dataset dataJeremy Magland 1import { useEffect, useState, useMemo, useRef, useReducer } from "react";
2import { useTimeseriesData } from "../../hooks/useTimeseriesData";
3import { Dataset } from "../../types";
4import { Margins, Range, WorkerMessage } from "./WorkerTypes";
5import { timeseriesViewReducer, initialState } from "./timeseriesViewReducer";
7interface TimeseriesViewProps {
8 width: number;
9 height: number;
10 dataset: Dataset;
11}
13const TimeseriesView: React.FC<TimeseriesViewProps> = ({
14 width,
15 height,
16 dataset,
17}) => {
18 const { data, error } = useTimeseriesData(dataset);
20 const [canvasElement, setCanvasElement] = useState<HTMLCanvasElement | null>(
21 null,
22 );
23 const [overlayCanvasElement, setOverlayCanvasElement] =
24 useState<HTMLCanvasElement | null>(null);
25 const [state, dispatch] = useReducer(timeseriesViewReducer, initialState);
26 const { selectedIndex, isDragging, lastDragX, xRange } = state;
28 const containerRef = useRef<HTMLDivElement>(null);
29 const [worker, setWorker] = useState<Worker | null>(null);
30 const [margins] = useState<Margins>({
31 left: 50,
32 right: 20,
33 top: 20,
34 bottom: 50,
35 });
37 // Update xRange when data changes
38 useEffect(() => {
39 if (data) {
40 dispatch({
41 type: "SET_X_RANGE",
42 range: { min: 0, max: data.length - 1 },
43 });
44 }
45 }, [data]);
47 // Set up wheel event listener
48 useEffect(() => {
49 const container = containerRef.current;
50 if (!container) return;
52 const handleWheel = (e: WheelEvent) => {
53 if (!data) return;
54 e.preventDefault();
56 const rect = container.getBoundingClientRect();
57 const x = e.clientX - rect.left;
58 const xRatio =
59 (x - margins.left) / (width - margins.left - margins.right);
61 // Calculate zoom center in data coordinates
62 const zoomCenter = xRange.min + (xRange.max - xRange.min) * xRatio;
64 // Calculate new range
65 const zoomFactor = e.deltaY > 0 ? 1.1 : 0.9;
67 // Ensure we don't zoom out beyond data bounds
68 const newMin = Math.max(
69 0,
70 zoomCenter - (zoomCenter - xRange.min) * zoomFactor,
71 );
72 const newMax = Math.min(
73 data.length - 1,
74 zoomCenter + (xRange.max - zoomCenter) * zoomFactor,
75 );
77 dispatch({ type: "SET_X_RANGE", range: { min: newMin, max: newMax } });
78 };
80 container.addEventListener("wheel", handleWheel, { passive: false });
81 return () => {
82 container.removeEventListener("wheel", handleWheel);
83 };
84 }, [data, width, margins, xRange]);
86 // Set up mouse event listeners for panning
87 useEffect(() => {
88 const container = containerRef.current;
89 if (!container) return;
91 const handleMouseDown = (e: MouseEvent) => {
92 dispatch({ type: "SET_IS_DRAGGING", isDragging: true });
93 dispatch({ type: "SET_LAST_DRAG_X", x: e.clientX });
94 };
96 const handleMouseMove = (e: MouseEvent) => {
97 if (!isDragging || lastDragX === 0 || !data) return;
99 const deltaX = e.clientX - lastDragX;
100 const xRatio = deltaX / (width - margins.left - margins.right);
101 const dataDelta = (xRange.max - xRange.min) * xRatio;
103 if (xRange.min - dataDelta < 0) return;
104 if (xRange.max - dataDelta > data.length - 1) return;
106 const newMin = xRange.min - dataDelta;
107 const newMax = xRange.max - dataDelta;
109 // Only update if we're still within bounds
110 if (newMin >= 0 && newMax <= data.length - 1) {
111 dispatch({ type: "SET_X_RANGE", range: { min: newMin, max: newMax } });
112 }
114 dispatch({ type: "SET_LAST_DRAG_X", x: e.clientX });
115 };
117 const handleMouseUp = () => {
118 dispatch({ type: "SET_IS_DRAGGING", isDragging: false });
119 dispatch({ type: "SET_LAST_DRAG_X", x: 0 });
120 };
122 container.addEventListener("mousedown", handleMouseDown);
123 window.addEventListener("mousemove", handleMouseMove);
124 window.addEventListener("mouseup", handleMouseUp);
126 return () => {
127 container.removeEventListener("mousedown", handleMouseDown);
128 window.removeEventListener("mousemove", handleMouseMove);
129 window.removeEventListener("mouseup", handleMouseUp);
130 };
131 }, [data, width, margins, xRange, isDragging, lastDragX]);
133 // Set worker
134 useEffect(() => {
135 if (!canvasElement) return;
136 const worker = new Worker(
137 new URL("./TimeseriesViewWorker", import.meta.url),
138 {
139 type: "module",
140 },
141 );
142 let offscreenCanvas: OffscreenCanvas;
143 try {
144 offscreenCanvas = canvasElement.transferControlToOffscreen();
145 } catch (err) {
146 console.warn(err);
147 console.warn(
148 "Unable to transfer control to offscreen canvas (expected during dev)",
149 );
150 return;
151 }
152 const msg: WorkerMessage = {
153 type: "initialize",
154 canvas: offscreenCanvas,
155 };
156 worker.postMessage(msg, [offscreenCanvas]);
158 setWorker(worker);
160 return () => {
161 worker.terminate();
162 };
163 }, [canvasElement]);
165 // Calculate yRange from data
166 const yRange = useMemo<Range>(() => {
167 if (!data) return { min: 0, max: 1 };
168 return {
169 min: Math.min(...data),
170 max: Math.max(...data),
171 };
172 }, [data]);
174 // Handle dimension changes
175 useEffect(() => {
176 if (!worker) return;
177 if (!data) return;
179 const msg: WorkerMessage = {
180 type: "render",
181 timeseries: data,
182 width,
183 height,
184 margins,
185 xRange,
186 yRange,
187 };
188 worker.postMessage(msg);
189 }, [width, height, data, worker, margins, xRange, yRange]);
191 // Render cursor on overlay canvas
192 useEffect(() => {
193 if (!overlayCanvasElement || selectedIndex === null || !data) return;
194 const ctx = overlayCanvasElement.getContext("2d");
195 if (!ctx) return;
197 // Clear overlay canvas
198 ctx.clearRect(0, 0, width, height);
200 // Draw cursor line
201 const xRatio = (selectedIndex - xRange.min) / (xRange.max - xRange.min);
202 const x = margins.left + xRatio * (width - margins.left - margins.right);
203 ctx.beginPath();
204 ctx.strokeStyle = "#ff0000";
205 ctx.lineWidth = 1;
206 ctx.setLineDash([4, 4]);
207 ctx.moveTo(x, margins.top);
208 ctx.lineTo(x, height - margins.bottom);
209 ctx.stroke();
210 }, [
211 selectedIndex,
212 overlayCanvasElement,
213 width,
214 height,
215 margins,
216 data,
217 xRange,
218 ]);
220 if (error) {
221 return <div>Error loading data: {error}</div>;
222 }
224 const handleCanvasClick = (e: React.MouseEvent<HTMLDivElement>) => {
225 if (!overlayCanvasElement || !data || isDragging) return;
226 const rect = overlayCanvasElement.getBoundingClientRect();
227 const x = e.clientX - rect.left;
228 const xRatio = (x - margins.left) / (width - margins.left - margins.right);
229 const index = Math.round(xRange.min + xRatio * (xRange.max - xRange.min));
230 if (index >= 0 && index < data.length) {
231 dispatch({ type: "SET_SELECTED_INDEX", index });
232 }
233 };
235 return (
236 <div style={{ position: "relative", width, height: height + 30 }}>
237 <div
238 ref={containerRef}
239 style={{ position: "relative", width, height }}
240 onClick={handleCanvasClick}
241 >
242 <canvas
243 ref={setCanvasElement}
244 width={width}
245 height={height}
246 style={{
247 position: "absolute",
248 width: "100%",
249 height: "100%",
250 }}
251 />
252 <canvas
253 ref={setOverlayCanvasElement}
254 width={width}
255 height={height}
256 style={{
257 position: "absolute",
258 width: "100%",
259 height: "100%",
260 pointerEvents: "none",
261 }}
262 />
263 </div>
264 {selectedIndex !== -1 && data && (
265 <div style={{ height: 30, padding: "5px 0", color: "#666" }}>
266 Index: {selectedIndex}, Value: {data[selectedIndex].toFixed(3)}
267 </div>
268 )}
269 </div>
270 );
271};
273export default TimeseriesView;