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