552a4baadd web-uiJeremy Magland 1import { useEffect, useMemo, useReducer, useState, useCallback } from "react";
2import TimeseriesNavigationBar from "./TimeseriesNavigationBar";
3import { SupportedTypedArray } from "../../hooks/TimeseriesDataClient";
4import { useTimeseriesDataClient } from "../../hooks/useTimeseriesDataClient";
5import { Dataset } from "../../types";
6import { Margins, Range, WorkerMessage } from "./WorkerTypes";
7import { initialState, timeseriesViewReducer } from "./timeseriesViewReducer";
9interface TimeseriesViewProps {
10 width: number;
11 height: number;
12 dataset: Dataset;
13}
15const TimeseriesView: React.FC<TimeseriesViewProps> = ({
16 width,
17 height,
18 dataset,
19}) => {
20 const { client, error: clientError } = useTimeseriesDataClient(dataset);
21 const [dataT, setDataT] = useState<number[] | null>(null);
22 const [dataY, setDataY] = useState<SupportedTypedArray | null>(null);
c6f25e2multi-channel in uiJeremy Magland 23 const [dataYAll, setDataYAll] = useState<SupportedTypedArray[] | null>(null);
25 const [isLoading, setIsLoading] = useState(false);
c6f25e2multi-channel in uiJeremy Magland 26 const [selectedChannel, setSelectedChannel] = useState<number | "all">(0);
27 const [numChannels, setNumChannels] = useState<number>(1);
552a4baadd web-uiJeremy Magland 28
29 const [canvasElement, setCanvasElement] = useState<HTMLCanvasElement | null>(
30 null,
31 );
32 const [overlayCanvasElement, setOverlayCanvasElement] =
33 useState<HTMLCanvasElement | null>(null);
34 const [state, dispatch] = useReducer(timeseriesViewReducer, initialState);
35 const { selectedIndex, isDragging, lastDragX, xRange } = state;
36 const [isWheelEnabled, setIsWheelEnabled] = useState(false);
37 const [showHint, setShowHint] = useState(true);
39 // Hide hint when user interacts with the graph
40 const hideHint = useCallback(() => {
41 setShowHint(false);
42 }, []);
44 // Auto-hide hint after 4 seconds
45 useEffect(() => {
46 if (showHint) {
47 const timer = setTimeout(() => {
48 setShowHint(false);
49 }, 5000);
50 return () => clearTimeout(timer);
51 }
52 }, [showHint]);
54 const [container, setContainer] = useState<HTMLDivElement | null>(null);
55 const [worker, setWorker] = useState<Worker | null>(null);
56 const [margins] = useState<Margins>({
57 left: 50,
58 right: 20,
59 top: 20,
60 bottom: 50,
61 });
63 // Load data for current range
64 useEffect(() => {
65 if (!client || !xRange) return;
67 const loadRangeData = async () => {
68 try {
69 setIsLoading(true);
70 const start = Math.floor(xRange.min);
71 const end = Math.ceil(xRange.max) + 1;
73 if (selectedChannel === "all") {
74 // Load all channels
75 const allChannelData = await Promise.all(
76 Array.from({ length: numChannels }, (_, ch) =>
77 client.fetchRange(start, end, ch)
78 )
79 );
80 setDataYAll(allChannelData);
81 setDataY(null);
82 } else {
83 // Load single channel
84 const rangeData = await client.fetchRange(start, end, selectedChannel);
85 setDataY(rangeData);
86 setDataYAll(null);
87 }
92 );
93 setDataT(dT);
94 setError(null);
95 } catch (err) {
96 setError(
97 err instanceof Error ? err.message : "Failed to load data range",
98 );
99 } finally {
100 setIsLoading(false);
101 }
102 };
104 loadRangeData();
552a4baadd web-uiJeremy Magland 106
107 // Update xRange when client is initialized
108 useEffect(() => {
109 if (client) {
110 const shape = client.getShape();
112 setNumChannels(channels);
113 // Default to "all" if 20 or fewer channels, otherwise default to channel 0
114 setSelectedChannel(channels > 1 && channels <= 20 ? "all" : 0);
116 type: "SET_X_RANGE",
117 range: { min: 0, max: Math.min(999, shape - 1) },
118 });
119 }
120 }, [client]);
122 // Set up wheel event listener
123 useEffect(() => {
124 if (!container || !client) return;
126 const handleWheel = (e: WheelEvent) => {
127 if (!isWheelEnabled) {
128 return; // Allow page scrolling if wheel zoom not enabled
129 }
130 e.preventDefault();
132 const rect = container.getBoundingClientRect();
133 const x = e.clientX - rect.left;
134 const xRatio =
135 (x - margins.left) / (width - margins.left - margins.right);
137 // Calculate zoom center in data coordinates
138 const zoomCenter = xRange.min + (xRange.max - xRange.min) * xRatio;
140 // Calculate new range
141 const zoomFactor = e.deltaY > 0 ? 1.1 : 1 / 1.1;
142 const shape = client.getShape();
144 // Ensure we don't zoom out beyond data bounds
145 const newMin = Math.max(
146 0,
147 zoomCenter - (zoomCenter - xRange.min) * zoomFactor,
148 );
149 const newMax = Math.min(
150 shape - 1,
151 zoomCenter + (xRange.max - zoomCenter) * zoomFactor,
152 );
154 dispatch({ type: "SET_X_RANGE", range: { min: newMin, max: newMax } });
155 };
157 container.addEventListener("wheel", handleWheel, { passive: false });
158 return () => {
159 container.removeEventListener("wheel", handleWheel);
160 };
161 }, [container, client, width, margins, xRange, isWheelEnabled]);
163 // Set up mouse event listeners for panning
164 useEffect(() => {
165 if (!container || !client) return;
167 const handleMouseDown = (e: MouseEvent) => {
168 dispatch({ type: "SET_IS_DRAGGING", isDragging: true });
169 dispatch({ type: "SET_LAST_DRAG_X", x: e.clientX });
170 };
172 const handleMouseMove = (e: MouseEvent) => {
173 if (!isDragging || lastDragX === 0) return;
175 const deltaX = e.clientX - lastDragX;
176 const xRatio = deltaX / (width - margins.left - margins.right);
177 const dataDelta = (xRange.max - xRange.min) * xRatio;
178 const shape = client.getShape();
180 if (xRange.min - dataDelta < 0) return;
181 if (xRange.max - dataDelta > shape - 1) return;
183 const newMin = xRange.min - dataDelta;
184 const newMax = xRange.max - dataDelta;
186 // Only update if we're still within bounds
187 if (newMin >= 0 && newMax <= shape - 1) {
188 dispatch({ type: "SET_X_RANGE", range: { min: newMin, max: newMax } });
189 }
191 dispatch({ type: "SET_LAST_DRAG_X", x: e.clientX });
192 };
194 const handleMouseUp = () => {
195 dispatch({ type: "SET_IS_DRAGGING", isDragging: false });
196 dispatch({ type: "SET_LAST_DRAG_X", x: 0 });
197 };
199 container.addEventListener("mousedown", handleMouseDown);
200 window.addEventListener("mousemove", handleMouseMove);
201 window.addEventListener("mouseup", handleMouseUp);
203 return () => {
204 container.removeEventListener("mousedown", handleMouseDown);
205 window.removeEventListener("mousemove", handleMouseMove);
206 window.removeEventListener("mouseup", handleMouseUp);
207 };
208 }, [container, client, width, margins, xRange, isDragging, lastDragX]);
210 // Set worker
211 useEffect(() => {
212 if (!canvasElement) return;
213 const worker = new Worker(
214 new URL("./TimeseriesViewWorker", import.meta.url),
215 {
216 type: "module",
217 },
218 );
219 let offscreenCanvas: OffscreenCanvas;
220 try {
221 offscreenCanvas = canvasElement.transferControlToOffscreen();
222 } catch (err) {
223 console.warn(err);
224 console.warn(
225 "Unable to transfer control to offscreen canvas (expected during dev)",
226 );
227 return;
228 }
229 const msg: WorkerMessage = {
230 type: "initialize",
231 canvas: offscreenCanvas,
232 };
233 worker.postMessage(msg, [offscreenCanvas]);
235 setWorker(worker);
237 return () => {
238 worker.terminate();
239 };
240 }, [canvasElement]);
242 // Calculate yRange from data
243 const yRange = useMemo<Range>(() => {
245 // Calculate range across all channels
246 let min = Infinity;
247 let max = -Infinity;
248 for (const channelData of dataYAll) {
249 const channelMin = computeMin(channelData);
250 const channelMax = computeMax(channelData);
251 if (channelMin < min) min = channelMin;
252 if (channelMax > max) max = channelMax;
253 }
254 return { min, max };
255 } else if (dataY) {
256 return {
257 min: computeMin(dataY),
258 max: computeMax(dataY),
259 };
260 }
261 return { min: 0, max: 1 };
262 }, [dataY, dataYAll]);
552a4baadd web-uiJeremy Magland 263
264 // Handle dimension changes
265 useEffect(() => {
266 if (!worker) return;
267 if (!dataT) return;
552a4baadd web-uiJeremy Magland 269
270 const msg: WorkerMessage = {
271 type: "render",
272 timeseriesT: dataT,
274 timeseriesYAll: dataYAll ? dataYAll.map(ch => Array.from(ch)) : undefined,
276 height,
277 margins,
278 xRange,
279 yRange,
280 };
281 worker.postMessage(msg);
c6f25e2multi-channel in uiJeremy Magland 282 }, [width, height, dataT, dataY, dataYAll, worker, margins, xRange, yRange]);
552a4baadd web-uiJeremy Magland 283
284 // Render cursor on overlay canvas
285 useEffect(() => {
c6f25e2multi-channel in uiJeremy Magland 286 if (!overlayCanvasElement || selectedIndex === null || (!dataY && !dataYAll)) return;
288 if (!ctx) return;
290 // Clear overlay canvas
291 ctx.clearRect(0, 0, width, height);
293 // Draw cursor line
294 const xRatio = (selectedIndex - xRange.min) / (xRange.max - xRange.min);
295 const x = margins.left + xRatio * (width - margins.left - margins.right);
296 ctx.beginPath();
297 ctx.strokeStyle = "#ff0000";
298 ctx.lineWidth = 1;
299 ctx.setLineDash([4, 4]);
300 ctx.moveTo(x, margins.top);
301 ctx.lineTo(x, height - margins.bottom);
302 ctx.stroke();
303 }, [
304 selectedIndex,
305 overlayCanvasElement,
306 width,
307 height,
308 margins,
309 dataT,
310 dataY,
313 ]);
315 const selectedValue = useMemo(() => {
318 if (dataYAll) {
319 // Return all channel values
320 const values: number[] = [];
321 for (let ch = 0; ch < dataYAll.length; ch++) {
322 for (let i = 0; i < dataT.length; i++) {
323 if (dataT[i] === selectedIndex) {
324 values.push(dataYAll[ch][i]);
325 break;
326 }
327 }
328 }
329 return values.length > 0 ? values : null;
330 } else if (dataY) {
331 // Return single channel value
332 for (let i = 0; i < dataT.length; i++) {
333 if (dataT[i] === selectedIndex) {
334 return dataY[i];
335 }
337 }
338 return null;
552a4baadd web-uiJeremy Magland 340
341 if (error || clientError) {
342 return <div>Error loading data: {error || clientError}</div>;
343 }
345 if (isLoading && !dataY) {
346 return <div>Loading...</div>;
347 }
349 const handleCanvasClick = (e: React.MouseEvent<HTMLDivElement>) => {
c6f25e2multi-channel in uiJeremy Magland 350 if (!overlayCanvasElement || (!dataY && !dataYAll) || isDragging) return;
552a4baadd web-uiJeremy Magland 351
352 // Enable wheel zooming on first click
353 if (!isWheelEnabled) {
354 setIsWheelEnabled(true);
355 }
357 const rect = overlayCanvasElement.getBoundingClientRect();
358 const x = e.clientX - rect.left;
359 const xRatio = (x - margins.left) / (width - margins.left - margins.right);
360 const index = Math.round(xRange.min + xRatio * (xRange.max - xRange.min));
361 if (index >= 0) {
362 dispatch({ type: "SET_SELECTED_INDEX", index });
363 }
364 };
366 return (
367 <div style={{ position: "relative", width, height: height + 50 }}>
368 <div style={{ marginBottom: 10, height: 20 }}>
369 <TimeseriesNavigationBar
370 width={width}
371 height={20}
372 totalRange={{ min: 0, max: client ? client.getShape() - 1 : 999 }}
373 viewRange={xRange}
374 onViewRangeChange={(range) =>
375 dispatch({ type: "SET_X_RANGE", range })
376 }
377 />
378 </div>
380 <div style={{ marginBottom: 10, display: "flex", alignItems: "center", gap: 8 }}>
381 <label htmlFor="channel-select" style={{ fontSize: "14px", color: "#666" }}>
382 Channel:
383 </label>
384 <select
385 id="channel-select"
386 value={selectedChannel}
387 onChange={(e) => {
388 const value = e.target.value;
389 setSelectedChannel(value === "all" ? "all" : Number(value));
390 }}
391 style={{
392 padding: "4px 8px",
393 fontSize: "14px",
394 borderRadius: "4px",
395 border: "1px solid #ccc",
396 backgroundColor: "white",
397 cursor: "pointer",
398 }}
399 >
400 <option value="all">All (overlay)</option>
401 {Array.from({ length: numChannels }, (_, i) => (
402 <option key={i} value={i}>
403 {i}
404 </option>
405 ))}
406 </select>
407 </div>
408 )}
410 <div
411 style={{
412 position: "absolute",
413 top: margins.top + 10,
414 right: margins.right + 10,
415 display: "flex",
416 flexDirection: "column",
417 alignItems: "flex-end",
418 gap: "8px",
419 zIndex: 10,
420 opacity: showHint ? 0.8 : 0,
421 transition: "opacity 0.5s ease-out",
422 pointerEvents: "none",
423 fontSize: "12px",
424 color: "#666",
425 }}
426 >
427 <div
428 style={{
429 display: "flex",
430 alignItems: "center",
431 gap: "4px",
432 backgroundColor: "rgba(255, 255, 255, 0.9)",
433 padding: "2px 6px",
434 borderRadius: "4px",
435 }}
436 >
437 <span>Drag to pan</span>
438 <svg width="14" height="14" viewBox="0 0 24 24" fill="#666">
439 <path d="M15 3h2v5h-2V3zm4 0h2v5h-2V3zm-4 7h2v5h-2v-5zm4 0h2v5h-2v-5zm-4 7h2v5h-2v-5zm4 0h2v5h-2v-5z" />
440 </svg>
441 </div>
442 <div
443 style={{
444 display: "flex",
445 alignItems: "center",
446 gap: "4px",
447 backgroundColor: "rgba(255, 255, 255, 0.9)",
448 padding: "2px 6px",
449 borderRadius: "4px",
450 }}
451 >
452 <span>Scroll to zoom</span>
453 <svg width="14" height="14" viewBox="0 0 24 24" fill="#666">
454 <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" />
455 </svg>
456 </div>
457 </div>
458 )}
459 <div
460 ref={setContainer}
461 style={{ position: "relative", width, height }}
462 onClick={(e) => {
463 handleCanvasClick(e);
464 hideHint();
465 }}
466 onMouseDown={hideHint}
467 >
468 <canvas
469 ref={setCanvasElement}
470 key={`canvas-${width}-${height}`}
471 width={width}
472 height={height}
473 style={{
474 position: "absolute",
475 width: "100%",
476 height: "100%",
477 }}
478 />
479 <canvas
480 ref={setOverlayCanvasElement}
481 width={width}
482 height={height}
483 style={{
484 position: "absolute",
485 width: "100%",
486 height: "100%",
487 pointerEvents: "none",
488 }}
489 />
490 </div>
494 {Array.isArray(selectedValue)
495 ? `Values: [${selectedValue.slice(0, 5).map(v => v.toFixed(3)).join(", ")}${selectedValue.length > 5 ? ", ..." : ""}]`
496 : `Value: ${selectedValue.toFixed(3)}`}
498 )}
499 </div>
500 );
501};
503const computeMin = (data: SupportedTypedArray) => {
504 let min = Infinity;
505 for (let i = 0; i < data.length; i++) {
506 if (data[i] < min) {
507 min = data[i];
508 }
509 }
510 return min;
511};
513const computeMax = (data: SupportedTypedArray) => {
514 let max = -Infinity;
515 for (let i = 0; i < data.length; i++) {
516 if (data[i] > max) {
517 max = data[i];
518 }
519 }
520 return max;
521};
523export default TimeseriesView;