/ concept-collection / ephys_compression_tests
Sign in
concept-collection / ephys_compression_tests
ephys_compression_tests / web-ui / src / components / dataset / TimeseriesView.tsx
623 lines · 19.3 KBBlameHistoryRaw
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;
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);
23 const [dataYAll, setDataYAll] = useState<SupportedTypedArray[] | null>(null);
24 const [error, setError] = useState<string | null>(clientError);
25 const [isLoading, setIsLoading] = useState(false);
26 const [selectedChannel, setSelectedChannel] = useState<number | "all">(0);
27 const [numChannels, setNumChannels] = useState<number>(1);
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 // Wheel zooming is disabled because it causes the page to scroll when the user
37 // tries to scroll the timeseries view, creating a poor user experience.
38 // Instead, we provide explicit zoom control buttons.
39 const [isWheelEnabled] = useState(false); // Keep state for potential future use, but always false
40 const [showHint, setShowHint] = useState(true);
42 // Hide hint when user interacts with the graph
43 const hideHint = useCallback(() => {
44 setShowHint(false);
45 }, []);
47 // Auto-hide hint after 4 seconds
48 useEffect(() => {
49 if (showHint) {
50 const timer = setTimeout(() => {
51 setShowHint(false);
52 }, 5000);
53 return () => clearTimeout(timer);
54 }
55 }, [showHint]);
57 const [container, setContainer] = useState<HTMLDivElement | null>(null);
58 const [worker, setWorker] = useState<Worker | null>(null);
59 const [margins] = useState<Margins>({
60 left: 50,
61 right: 20,
62 top: 20,
63 bottom: 50,
64 });
66 // Load data for current range
67 useEffect(() => {
68 if (!client || !xRange) return;
70 const loadRangeData = async () => {
71 try {
72 setIsLoading(true);
73 const start = Math.floor(xRange.min);
74 const end = Math.ceil(xRange.max) + 1;
76 if (selectedChannel === "all") {
77 // Load all channels
78 const allChannelData = await Promise.all(
79 Array.from({ length: numChannels }, (_, ch) =>
80 client.fetchRange(start, end, ch)
81 )
82 );
83 setDataYAll(allChannelData);
84 setDataY(null);
85 } else {
86 // Load single channel
87 const rangeData = await client.fetchRange(start, end, selectedChannel);
88 setDataY(rangeData);
89 setDataYAll(null);
90 }
92 const dT = Array.from(
93 { length: end - start },
94 (_, i) => i + start,
95 );
96 setDataT(dT);
97 setError(null);
98 } catch (err) {
99 setError(
100 err instanceof Error ? err.message : "Failed to load data range",
101 );
102 } finally {
103 setIsLoading(false);
104 }
105 };
107 loadRangeData();
108 }, [client, xRange, selectedChannel, numChannels]);
110 // Update xRange when client is initialized
111 useEffect(() => {
112 if (client) {
113 const shape = client.getShape();
114 const channels = client.getNumChannels();
115 setNumChannels(channels);
116 // Default to "all" if 20 or fewer channels, otherwise default to channel 0
117 setSelectedChannel(channels > 1 && channels <= 20 ? "all" : 0);
118 dispatch({
119 type: "SET_X_RANGE",
120 range: { min: 0, max: Math.min(999, shape - 1) },
121 });
122 }
123 }, [client]);
125 // Set up wheel event listener
126 useEffect(() => {
127 if (!container || !client) return;
129 const handleWheel = (e: WheelEvent) => {
130 if (!isWheelEnabled) {
131 return; // Allow page scrolling if wheel zoom not enabled
132 }
133 e.preventDefault();
134 e.stopPropagation();
136 const rect = container.getBoundingClientRect();
137 const x = e.clientX - rect.left;
138 const xRatio =
139 (x - margins.left) / (width - margins.left - margins.right);
141 // Calculate zoom center in data coordinates
142 const zoomCenter = xRange.min + (xRange.max - xRange.min) * xRatio;
144 // Calculate new range
145 const zoomFactor = e.deltaY > 0 ? 1.1 : 1 / 1.1;
146 const shape = client.getShape();
148 // Ensure we don't zoom out beyond data bounds
149 const newMin = Math.max(
150 0,
151 zoomCenter - (zoomCenter - xRange.min) * zoomFactor,
152 );
153 const newMax = Math.min(
154 shape - 1,
155 zoomCenter + (xRange.max - zoomCenter) * zoomFactor,
156 );
158 dispatch({ type: "SET_X_RANGE", range: { min: newMin, max: newMax } });
159 };
161 container.addEventListener("wheel", handleWheel, { passive: false });
162 return () => {
163 container.removeEventListener("wheel", handleWheel);
164 };
165 }, [container, client, width, margins, isWheelEnabled]);
167 // Set up mouse event listeners for panning
168 useEffect(() => {
169 if (!container || !client) return;
171 const handleMouseDown = (e: MouseEvent) => {
172 dispatch({ type: "SET_IS_DRAGGING", isDragging: true });
173 dispatch({ type: "SET_LAST_DRAG_X", x: e.clientX });
174 };
176 const handleMouseMove = (e: MouseEvent) => {
177 if (!isDragging || lastDragX === 0) return;
179 const deltaX = e.clientX - lastDragX;
180 const xRatio = deltaX / (width - margins.left - margins.right);
181 const dataDelta = (xRange.max - xRange.min) * xRatio;
182 const shape = client.getShape();
184 if (xRange.min - dataDelta < 0) return;
185 if (xRange.max - dataDelta > shape - 1) return;
187 const newMin = xRange.min - dataDelta;
188 const newMax = xRange.max - dataDelta;
190 // Only update if we're still within bounds
191 if (newMin >= 0 && newMax <= shape - 1) {
192 dispatch({ type: "SET_X_RANGE", range: { min: newMin, max: newMax } });
193 }
195 dispatch({ type: "SET_LAST_DRAG_X", x: e.clientX });
196 };
198 const handleMouseUp = () => {
199 dispatch({ type: "SET_IS_DRAGGING", isDragging: false });
200 dispatch({ type: "SET_LAST_DRAG_X", x: 0 });
201 };
203 container.addEventListener("mousedown", handleMouseDown);
204 window.addEventListener("mousemove", handleMouseMove);
205 window.addEventListener("mouseup", handleMouseUp);
207 return () => {
208 container.removeEventListener("mousedown", handleMouseDown);
209 window.removeEventListener("mousemove", handleMouseMove);
210 window.removeEventListener("mouseup", handleMouseUp);
211 };
212 }, [container, client, width, margins, xRange, isDragging, lastDragX]);
214 // Set worker
215 useEffect(() => {
216 if (!canvasElement) return;
217 const worker = new Worker(
218 new URL("./TimeseriesViewWorker", import.meta.url),
219 {
220 type: "module",
221 },
222 );
223 let offscreenCanvas: OffscreenCanvas;
224 try {
225 offscreenCanvas = canvasElement.transferControlToOffscreen();
226 } catch (err) {
227 console.warn(err);
228 console.warn(
229 "Unable to transfer control to offscreen canvas (expected during dev)",
230 );
231 return;
232 }
233 const msg: WorkerMessage = {
234 type: "initialize",
235 canvas: offscreenCanvas,
236 };
237 worker.postMessage(msg, [offscreenCanvas]);
239 setWorker(worker);
241 return () => {
242 worker.terminate();
243 };
244 }, [canvasElement]);
246 // Calculate yRange from data
247 const yRange = useMemo<Range>(() => {
248 if (dataYAll) {
249 // Calculate range across all channels
250 let min = Infinity;
251 let max = -Infinity;
252 for (const channelData of dataYAll) {
253 const channelMin = computeMin(channelData);
254 const channelMax = computeMax(channelData);
255 if (channelMin < min) min = channelMin;
256 if (channelMax > max) max = channelMax;
257 }
258 return { min, max };
259 } else if (dataY) {
260 return {
261 min: computeMin(dataY),
262 max: computeMax(dataY),
263 };
264 }
265 return { min: 0, max: 1 };
266 }, [dataY, dataYAll]);
268 // Handle dimension changes
269 useEffect(() => {
270 if (!worker) return;
271 if (!dataT) return;
272 if (!dataY && !dataYAll) return;
274 const msg: WorkerMessage = {
275 type: "render",
276 timeseriesT: dataT,
277 timeseriesY: dataY ? Array.from(dataY) : [],
278 timeseriesYAll: dataYAll ? dataYAll.map(ch => Array.from(ch)) : undefined,
279 width,
280 height,
281 margins,
282 xRange,
283 yRange,
284 };
285 worker.postMessage(msg);
286 }, [width, height, dataT, dataY, dataYAll, worker, margins, xRange, yRange]);
288 // Render cursor on overlay canvas
289 useEffect(() => {
290 if (!overlayCanvasElement || selectedIndex === null || (!dataY && !dataYAll)) return;
291 const ctx = overlayCanvasElement.getContext("2d");
292 if (!ctx) return;
294 // Clear overlay canvas
295 ctx.clearRect(0, 0, width, height);
297 // Draw cursor line
298 const xRatio = (selectedIndex - xRange.min) / (xRange.max - xRange.min);
299 const x = margins.left + xRatio * (width - margins.left - margins.right);
300 ctx.beginPath();
301 ctx.strokeStyle = "#ff0000";
302 ctx.lineWidth = 1;
303 ctx.setLineDash([4, 4]);
304 ctx.moveTo(x, margins.top);
305 ctx.lineTo(x, height - margins.bottom);
306 ctx.stroke();
307 }, [
308 selectedIndex,
309 overlayCanvasElement,
310 width,
311 height,
312 margins,
313 dataT,
314 dataY,
315 dataYAll,
316 xRange,
317 ]);
319 const selectedValue = useMemo(() => {
320 if (selectedIndex === -1 || !dataT) return null;
322 if (dataYAll) {
323 // Return all channel values
324 const values: number[] = [];
325 for (let ch = 0; ch < dataYAll.length; ch++) {
326 for (let i = 0; i < dataT.length; i++) {
327 if (dataT[i] === selectedIndex) {
328 values.push(dataYAll[ch][i]);
329 break;
330 }
331 }
332 }
333 return values.length > 0 ? values : null;
334 } else if (dataY) {
335 // Return single channel value
336 for (let i = 0; i < dataT.length; i++) {
337 if (dataT[i] === selectedIndex) {
338 return dataY[i];
339 }
340 }
341 }
342 return null;
343 }, [selectedIndex, dataT, dataY, dataYAll]);
345 if (error || clientError) {
346 return <div>Error loading data: {error || clientError}</div>;
347 }
349 if (isLoading && !dataY) {
350 return <div>Loading...</div>;
351 }
353 const handleCanvasClick = (e: React.MouseEvent<HTMLDivElement>) => {
354 if (!overlayCanvasElement || (!dataY && !dataYAll) || isDragging) return;
356 const rect = overlayCanvasElement.getBoundingClientRect();
357 const x = e.clientX - rect.left;
358 const xRatio = (x - margins.left) / (width - margins.left - margins.right);
359 const index = Math.round(xRange.min + xRatio * (xRange.max - xRange.min));
360 if (index >= 0) {
361 dispatch({ type: "SET_SELECTED_INDEX", index });
362 }
363 };
365 // Zoom control functions - zoom centered on the selected index (current timepoint)
366 const handleZoomIn = () => {
367 if (!client) return;
368 // Use selectedIndex as center if set, otherwise use view center
369 const center = selectedIndex !== -1 ? selectedIndex : (xRange.min + xRange.max) / 2;
370 const currentRange = xRange.max - xRange.min;
371 const newRange = currentRange / 1.5; // Zoom in by 1.5x
372 const newMin = Math.max(0, center - newRange / 2);
373 const newMax = Math.min(client.getShape() - 1, center + newRange / 2);
374 dispatch({ type: "SET_X_RANGE", range: { min: newMin, max: newMax } });
375 };
377 const handleZoomOut = () => {
378 if (!client) return;
379 // Use selectedIndex as center if set, otherwise use view center
380 const center = selectedIndex !== -1 ? selectedIndex : (xRange.min + xRange.max) / 2;
381 const currentRange = xRange.max - xRange.min;
382 const newRange = currentRange * 1.5; // Zoom out by 1.5x
383 const shape = client.getShape();
384 const newMin = Math.max(0, center - newRange / 2);
385 const newMax = Math.min(shape - 1, center + newRange / 2);
386 dispatch({ type: "SET_X_RANGE", range: { min: newMin, max: newMax } });
387 };
389 const handleZoomReset = () => {
390 if (!client) return;
391 const shape = client.getShape();
392 dispatch({
393 type: "SET_X_RANGE",
394 range: { min: 0, max: Math.min(999, shape - 1) },
395 });
396 };
398 return (
399 <div style={{ position: "relative", width, height: height + 50 }}>
400 <div style={{ marginBottom: 10, height: 20 }}>
401 <TimeseriesNavigationBar
402 width={width}
403 height={20}
404 totalRange={{ min: 0, max: client ? client.getShape() - 1 : 999 }}
405 viewRange={xRange}
406 onViewRangeChange={(range) =>
407 dispatch({ type: "SET_X_RANGE", range })
408 }
409 />
410 </div>
411 {numChannels > 1 && (
412 <div style={{ marginBottom: 10, display: "flex", alignItems: "center", gap: 8 }}>
413 <label htmlFor="channel-select" style={{ fontSize: "14px", color: "#666" }}>
414 Channel:
415 </label>
416 <select
417 id="channel-select"
418 value={selectedChannel}
419 onChange={(e) => {
420 const value = e.target.value;
421 setSelectedChannel(value === "all" ? "all" : Number(value));
422 }}
423 style={{
424 padding: "4px 8px",
425 fontSize: "14px",
426 borderRadius: "4px",
427 border: "1px solid #ccc",
428 backgroundColor: "white",
429 cursor: "pointer",
430 }}
431 >
432 <option value="all">All (overlay)</option>
433 {Array.from({ length: numChannels }, (_, i) => (
434 <option key={i} value={i}>
435 {i}
436 </option>
437 ))}
438 </select>
439 </div>
440 )}
441 {showHint && (
442 <div
443 style={{
444 position: "absolute",
445 top: margins.top + 10,
446 right: margins.right + 10,
447 display: "flex",
448 flexDirection: "column",
449 alignItems: "flex-end",
450 gap: "8px",
451 zIndex: 10,
452 opacity: showHint ? 0.8 : 0,
453 transition: "opacity 0.5s ease-out",
454 pointerEvents: "none",
455 fontSize: "12px",
456 color: "#666",
457 }}
458 >
459 <div
460 style={{
461 display: "flex",
462 alignItems: "center",
463 gap: "4px",
464 backgroundColor: "rgba(255, 255, 255, 0.9)",
465 padding: "2px 6px",
466 borderRadius: "4px",
467 }}
468 >
469 <span>Drag to pan</span>
470 <svg width="14" height="14" viewBox="0 0 24 24" fill="#666">
471 <path d="M15 3h2v5h-2V3zm4 0h2v5h-2V3zm-4 7h2v5h-2v-5zm4 0h2v5h-2v-5zm-4 7h2v5h-2v-5zm4 0h2v5h-2v-5z" />
472 </svg>
473 </div>
474 </div>
475 )}
476 {/* Zoom control buttons - positioned at bottom right to avoid blocking channel selector */}
477 <div
478 style={{
479 position: "absolute",
480 bottom: margins.bottom + 10,
481 right: margins.right + 10,
482 display: "flex",
483 gap: "6px",
484 zIndex: 10,
485 }}
486 >
487 <button
488 onClick={handleZoomIn}
489 disabled={!client}
490 style={{
491 padding: "6px 8px",
492 fontSize: "14px",
493 borderRadius: "4px",
494 border: "1px solid #ccc",
495 backgroundColor: "white",
496 cursor: client ? "pointer" : "not-allowed",
497 opacity: client ? 1 : 0.5,
498 display: "flex",
499 alignItems: "center",
500 justifyContent: "center",
501 }}
502 title="Zoom in"
503 >
504 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
505 <circle cx="11" cy="11" r="8" />
506 <path d="M21 21l-4.35-4.35" />
507 <line x1="8" y1="11" x2="14" y2="11" />
508 <line x1="11" y1="8" x2="11" y2="14" />
509 </svg>
510 </button>
511 <button
512 onClick={handleZoomOut}
513 disabled={!client}
514 style={{
515 padding: "6px 8px",
516 fontSize: "14px",
517 borderRadius: "4px",
518 border: "1px solid #ccc",
519 backgroundColor: "white",
520 cursor: client ? "pointer" : "not-allowed",
521 opacity: client ? 1 : 0.5,
522 display: "flex",
523 alignItems: "center",
524 justifyContent: "center",
525 }}
526 title="Zoom out"
527 >
528 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
529 <circle cx="11" cy="11" r="8" />
530 <path d="M21 21l-4.35-4.35" />
531 <line x1="8" y1="11" x2="14" y2="11" />
532 </svg>
533 </button>
534 <button
535 onClick={handleZoomReset}
536 disabled={!client}
537 style={{
538 padding: "6px 8px",
539 fontSize: "14px",
540 borderRadius: "4px",
541 border: "1px solid #ccc",
542 backgroundColor: "white",
543 cursor: client ? "pointer" : "not-allowed",
544 opacity: client ? 1 : 0.5,
545 display: "flex",
546 alignItems: "center",
547 justifyContent: "center",
548 }}
549 title="Reset zoom"
550 >
551 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
552 <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
553 <path d="M21 3v5h-5" />
554 <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
555 <path d="M3 21v-5h5" />
556 </svg>
557 </button>
558 </div>
559 <div
560 ref={setContainer}
561 style={{ position: "relative", width, height }}
562 onClick={(e) => {
563 handleCanvasClick(e);
564 hideHint();
565 }}
566 onMouseDown={hideHint}
567 >
568 <canvas
569 ref={setCanvasElement}
570 key={`canvas-${width}-${height}`}
571 width={width}
572 height={height}
573 style={{
574 position: "absolute",
575 width: "100%",
576 height: "100%",
577 }}
578 />
579 <canvas
580 ref={setOverlayCanvasElement}
581 width={width}
582 height={height}
583 style={{
584 position: "absolute",
585 width: "100%",
586 height: "100%",
587 pointerEvents: "none",
588 }}
589 />
590 </div>
591 {selectedIndex !== -1 && selectedValue && (
592 <div style={{ height: 30, padding: "5px 0", color: "#666" }}>
593 Index: {selectedIndex},{" "}
594 {Array.isArray(selectedValue)
595 ? `Values: [${selectedValue.slice(0, 5).map(v => v.toFixed(3)).join(", ")}${selectedValue.length > 5 ? ", ..." : ""}]`
596 : `Value: ${selectedValue.toFixed(3)}`}
597 </div>
598 )}
599 </div>
600 );
601};
603const computeMin = (data: SupportedTypedArray) => {
604 let min = Infinity;
605 for (let i = 0; i < data.length; i++) {
606 if (data[i] < min) {
607 min = data[i];
608 }
609 }
610 return min;
611};
613const computeMax = (data: SupportedTypedArray) => {
614 let max = -Infinity;
615 for (let i = 0; i < data.length; i++) {
616 if (data[i] > max) {
617 max = data[i];
618 }
619 }
620 return max;
621};
623export default TimeseriesView;
moveopenescclose