/ concept-collection / ephys_compression_tests
Sign in
concept-collection / ephys_compression_tests
ephys_compression_tests / web-ui / src / components / dataset / TimeseriesView.tsx
715 lines · 23.0 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 // Initialize reconstructed data client when reconstructedInfo changes
120 useEffect(() => {
121 if (!reconstructedInfo) {
122 setReconstructedClient(null);
123 setDataYReconstructed(null);
124 setDataYResiduals(null);
125 return;
126 }
128 // When a reconstruction is selected for comparison, switch from "all" to channel 0
129 if (selectedChannel === "all") {
130 setSelectedChannel(0);
131 }
133 const initClient = async () => {
134 try {
135 const client = await TimeseriesDataClient.create(
136 reconstructedInfo.datasetJsonUrl,
137 reconstructedInfo.reconstructedUrl,
138 1000
139 );
140 setReconstructedClient(client);
141 } catch (err) {
142 console.error("Failed to initialize reconstructed data client:", err);
143 setError("Failed to load reconstructed data");
144 }
145 };
147 initClient();
148 }, [reconstructedInfo, selectedChannel]);
150 // Load reconstructed data for current range
151 useEffect(() => {
152 if (!reconstructedClient || !xRange || selectedChannel === "all" || comparisonMode === "original") {
153 setDataYReconstructed(null);
154 setDataYResiduals(null);
155 return;
156 }
158 const loadReconstructedData = async () => {
159 try {
160 const start = Math.floor(xRange.min);
161 const end = Math.ceil(xRange.max) + 1;
162 const channel = typeof selectedChannel === "number" ? selectedChannel : 0;
164 const reconstructedData = await reconstructedClient.fetchRange(start, end, channel);
165 setDataYReconstructed(reconstructedData);
167 // Compute residuals if we have both original and reconstructed
168 if (dataY && reconstructedData.length === dataY.length) {
169 const residuals = new Float32Array(dataY.length);
170 for (let i = 0; i < dataY.length; i++) {
171 residuals[i] = dataY[i] - reconstructedData[i];
172 }
173 setDataYResiduals(residuals);
174 }
175 } catch (err) {
176 console.error("Failed to load reconstructed data:", err);
177 }
178 };
180 loadReconstructedData();
181 }, [reconstructedClient, xRange, selectedChannel, dataY, comparisonMode]);
183 // Update xRange when client is initialized
184 useEffect(() => {
185 if (client) {
186 const shape = client.getShape();
187 const channels = client.getNumChannels();
188 setNumChannels(channels);
189 // Default to "all" if 20 or fewer channels, otherwise default to channel 0
190 setSelectedChannel(channels > 1 && channels <= 20 ? "all" : 0);
191 dispatch({
192 type: "SET_X_RANGE",
193 range: { min: 0, max: Math.min(999, shape - 1) },
194 });
195 }
196 }, [client]);
198 // Set up wheel event listener
199 useEffect(() => {
200 if (!container || !client) return;
202 const handleWheel = (e: WheelEvent) => {
203 if (!isWheelEnabled) {
204 return; // Allow page scrolling if wheel zoom not enabled
205 }
206 e.preventDefault();
207 e.stopPropagation();
209 const rect = container.getBoundingClientRect();
210 const x = e.clientX - rect.left;
211 const xRatio =
212 (x - margins.left) / (width - margins.left - margins.right);
214 // Calculate zoom center in data coordinates
215 const zoomCenter = xRange.min + (xRange.max - xRange.min) * xRatio;
217 // Calculate new range
218 const zoomFactor = e.deltaY > 0 ? 1.1 : 1 / 1.1;
219 const shape = client.getShape();
221 // Ensure we don't zoom out beyond data bounds
222 const newMin = Math.max(
223 0,
224 zoomCenter - (zoomCenter - xRange.min) * zoomFactor,
225 );
226 const newMax = Math.min(
227 shape - 1,
228 zoomCenter + (xRange.max - zoomCenter) * zoomFactor,
229 );
231 dispatch({ type: "SET_X_RANGE", range: { min: newMin, max: newMax } });
232 };
234 container.addEventListener("wheel", handleWheel, { passive: false });
235 return () => {
236 container.removeEventListener("wheel", handleWheel);
237 };
238 }, [container, client, width, margins, isWheelEnabled]);
240 // Set up mouse event listeners for panning
241 useEffect(() => {
242 if (!container || !client) return;
244 const handleMouseDown = (e: MouseEvent) => {
245 dispatch({ type: "SET_IS_DRAGGING", isDragging: true });
246 dispatch({ type: "SET_LAST_DRAG_X", x: e.clientX });
247 };
249 const handleMouseMove = (e: MouseEvent) => {
250 if (!isDragging || lastDragX === 0) return;
252 const deltaX = e.clientX - lastDragX;
253 const xRatio = deltaX / (width - margins.left - margins.right);
254 const dataDelta = (xRange.max - xRange.min) * xRatio;
255 const shape = client.getShape();
257 if (xRange.min - dataDelta < 0) return;
258 if (xRange.max - dataDelta > shape - 1) return;
260 const newMin = xRange.min - dataDelta;
261 const newMax = xRange.max - dataDelta;
263 // Only update if we're still within bounds
264 if (newMin >= 0 && newMax <= shape - 1) {
265 dispatch({ type: "SET_X_RANGE", range: { min: newMin, max: newMax } });
266 }
268 dispatch({ type: "SET_LAST_DRAG_X", x: e.clientX });
269 };
271 const handleMouseUp = () => {
272 dispatch({ type: "SET_IS_DRAGGING", isDragging: false });
273 dispatch({ type: "SET_LAST_DRAG_X", x: 0 });
274 };
276 container.addEventListener("mousedown", handleMouseDown);
277 window.addEventListener("mousemove", handleMouseMove);
278 window.addEventListener("mouseup", handleMouseUp);
280 return () => {
281 container.removeEventListener("mousedown", handleMouseDown);
282 window.removeEventListener("mousemove", handleMouseMove);
283 window.removeEventListener("mouseup", handleMouseUp);
284 };
285 }, [container, client, width, margins, xRange, isDragging, lastDragX]);
287 // Set worker
288 useEffect(() => {
289 if (!canvasElement) return;
290 const worker = new Worker(
291 new URL("./TimeseriesViewWorker", import.meta.url),
292 {
293 type: "module",
294 },
295 );
296 let offscreenCanvas: OffscreenCanvas;
297 try {
298 offscreenCanvas = canvasElement.transferControlToOffscreen();
299 } catch (err) {
300 console.warn(err);
301 console.warn(
302 "Unable to transfer control to offscreen canvas (expected during dev)",
303 );
304 return;
305 }
306 const msg: WorkerMessage = {
307 type: "initialize",
308 canvas: offscreenCanvas,
309 };
310 worker.postMessage(msg, [offscreenCanvas]);
312 setWorker(worker);
314 return () => {
315 worker.terminate();
316 };
317 }, [canvasElement]);
319 // Calculate yRange from data
320 const yRange = useMemo<Range>(() => {
321 if (dataYAll) {
322 // Calculate range across all channels
323 let min = Infinity;
324 let max = -Infinity;
325 for (const channelData of dataYAll) {
326 const channelMin = computeMin(channelData);
327 const channelMax = computeMax(channelData);
328 if (channelMin < min) min = channelMin;
329 if (channelMax > max) max = channelMax;
330 }
331 return { min, max };
332 } else if (dataY) {
333 let min = computeMin(dataY);
334 let max = computeMax(dataY);
336 // When comparing with reconstructed data, include that data in the range calculation
337 if (comparisonMode === "overlay" || comparisonMode === "side-by-side") {
338 if (dataYReconstructed) {
339 const reconstructedMin = computeMin(dataYReconstructed);
340 const reconstructedMax = computeMax(dataYReconstructed);
341 if (reconstructedMin < min) min = reconstructedMin;
342 if (reconstructedMax > max) max = reconstructedMax;
343 }
344 } else if (comparisonMode === "residuals") {
345 // For residuals mode, use only the residuals range
346 if (dataYResiduals) {
347 min = computeMin(dataYResiduals);
348 max = computeMax(dataYResiduals);
349 }
350 }
352 return { min, max };
353 }
354 return { min: 0, max: 1 };
355 }, [dataY, dataYAll, dataYReconstructed, dataYResiduals, comparisonMode]);
357 // Handle dimension changes
358 useEffect(() => {
359 if (!worker) return;
360 if (!dataT) return;
361 if (!dataY && !dataYAll) return;
363 const msg: WorkerMessage = {
364 type: "render",
365 timeseriesT: dataT,
366 timeseriesY: dataY ? Array.from(dataY) : [],
367 timeseriesYAll: dataYAll ? dataYAll.map(ch => Array.from(ch)) : undefined,
368 timeseriesYReconstructed: dataYReconstructed ? Array.from(dataYReconstructed) : undefined,
369 timeseriesYResiduals: dataYResiduals ? Array.from(dataYResiduals) : undefined,
370 comparisonMode: comparisonMode,
371 width,
372 height,
373 margins,
374 xRange,
375 yRange,
376 };
377 worker.postMessage(msg);
378 }, [width, height, dataT, dataY, dataYAll, dataYReconstructed, dataYResiduals, comparisonMode, worker, margins, xRange, yRange]);
380 // Render cursor on overlay canvas
381 useEffect(() => {
382 if (!overlayCanvasElement || selectedIndex === null || (!dataY && !dataYAll)) return;
383 const ctx = overlayCanvasElement.getContext("2d");
384 if (!ctx) return;
386 // Clear overlay canvas
387 ctx.clearRect(0, 0, width, height);
389 // Draw cursor line
390 const xRatio = (selectedIndex - xRange.min) / (xRange.max - xRange.min);
391 const x = margins.left + xRatio * (width - margins.left - margins.right);
392 ctx.beginPath();
393 ctx.strokeStyle = "#ff0000";
394 ctx.lineWidth = 1;
395 ctx.setLineDash([4, 4]);
396 ctx.moveTo(x, margins.top);
397 ctx.lineTo(x, height - margins.bottom);
398 ctx.stroke();
399 }, [
400 selectedIndex,
401 overlayCanvasElement,
402 width,
403 height,
404 margins,
405 dataT,
406 dataY,
407 dataYAll,
408 xRange,
409 ]);
411 const selectedValue = useMemo(() => {
412 if (selectedIndex === -1 || !dataT) return null;
414 if (dataYAll) {
415 // Return all channel values
416 const values: number[] = [];
417 for (let ch = 0; ch < dataYAll.length; ch++) {
418 for (let i = 0; i < dataT.length; i++) {
419 if (dataT[i] === selectedIndex) {
420 values.push(dataYAll[ch][i]);
421 break;
422 }
423 }
424 }
425 return values.length > 0 ? values : null;
426 } else if (dataY) {
427 // Return single channel value
428 for (let i = 0; i < dataT.length; i++) {
429 if (dataT[i] === selectedIndex) {
430 return dataY[i];
431 }
432 }
433 }
434 return null;
435 }, [selectedIndex, dataT, dataY, dataYAll]);
437 if (error || clientError) {
438 return <div>Error loading data: {error || clientError}</div>;
439 }
441 if (isLoading && !dataY) {
442 return <div>Loading...</div>;
443 }
445 const handleCanvasClick = (e: React.MouseEvent<HTMLDivElement>) => {
446 if (!overlayCanvasElement || (!dataY && !dataYAll) || isDragging) return;
448 const rect = overlayCanvasElement.getBoundingClientRect();
449 const x = e.clientX - rect.left;
450 const xRatio = (x - margins.left) / (width - margins.left - margins.right);
451 const index = Math.round(xRange.min + xRatio * (xRange.max - xRange.min));
452 if (index >= 0) {
453 dispatch({ type: "SET_SELECTED_INDEX", index });
454 }
455 };
457 // Zoom control functions - zoom centered on the selected index (current timepoint)
458 const handleZoomIn = () => {
459 if (!client) return;
460 // Use selectedIndex as center if set, otherwise use view center
461 const center = selectedIndex !== -1 ? selectedIndex : (xRange.min + xRange.max) / 2;
462 const currentRange = xRange.max - xRange.min;
463 const newRange = currentRange / 1.5; // Zoom in by 1.5x
464 const newMin = Math.max(0, center - newRange / 2);
465 const newMax = Math.min(client.getShape() - 1, center + newRange / 2);
466 dispatch({ type: "SET_X_RANGE", range: { min: newMin, max: newMax } });
467 };
469 const handleZoomOut = () => {
470 if (!client) return;
471 // Use selectedIndex as center if set, otherwise use view center
472 const center = selectedIndex !== -1 ? selectedIndex : (xRange.min + xRange.max) / 2;
473 const currentRange = xRange.max - xRange.min;
474 const newRange = currentRange * 1.5; // Zoom out by 1.5x
475 const shape = client.getShape();
476 const newMin = Math.max(0, center - newRange / 2);
477 const newMax = Math.min(shape - 1, center + newRange / 2);
478 dispatch({ type: "SET_X_RANGE", range: { min: newMin, max: newMax } });
479 };
481 const handleZoomReset = () => {
482 if (!client) return;
483 const shape = client.getShape();
484 dispatch({
485 type: "SET_X_RANGE",
486 range: { min: 0, max: Math.min(999, shape - 1) },
487 });
488 };
490 return (
491 <div style={{ position: "relative", width, height: height + 50 }}>
492 <div style={{ marginBottom: 10, height: 20 }}>
493 <TimeseriesNavigationBar
494 width={width}
495 height={20}
496 totalRange={{ min: 0, max: client ? client.getShape() - 1 : 999 }}
497 viewRange={xRange}
498 onViewRangeChange={(range) =>
499 dispatch({ type: "SET_X_RANGE", range })
500 }
501 />
502 </div>
503 {numChannels > 1 && (
504 <div style={{ marginBottom: 10, display: "flex", alignItems: "center", gap: 8 }}>
505 <label htmlFor="channel-select" style={{ fontSize: "14px", color: "#666" }}>
506 Channel:
507 </label>
508 <select
509 id="channel-select"
510 value={selectedChannel}
511 onChange={(e) => {
512 const value = e.target.value;
513 setSelectedChannel(value === "all" ? "all" : Number(value));
514 }}
515 style={{
516 padding: "4px 8px",
517 fontSize: "14px",
518 borderRadius: "4px",
519 border: "1px solid #ccc",
520 backgroundColor: "white",
521 cursor: "pointer",
522 }}
523 >
524 <option value="all">All (overlay)</option>
525 {Array.from({ length: numChannels }, (_, i) => (
526 <option key={i} value={i}>
527 {i}
528 </option>
529 ))}
530 </select>
531 </div>
532 )}
533 {showHint && (
534 <div
535 style={{
536 position: "absolute",
537 top: margins.top + 10,
538 right: margins.right + 10,
539 display: "flex",
540 flexDirection: "column",
541 alignItems: "flex-end",
542 gap: "8px",
543 zIndex: 10,
544 opacity: showHint ? 0.8 : 0,
545 transition: "opacity 0.5s ease-out",
546 pointerEvents: "none",
547 fontSize: "12px",
548 color: "#666",
549 }}
550 >
551 <div
552 style={{
553 display: "flex",
554 alignItems: "center",
555 gap: "4px",
556 backgroundColor: "rgba(255, 255, 255, 0.9)",
557 padding: "2px 6px",
558 borderRadius: "4px",
559 }}
560 >
561 <span>Drag to pan</span>
562 <svg width="14" height="14" viewBox="0 0 24 24" fill="#666">
563 <path d="M15 3h2v5h-2V3zm4 0h2v5h-2V3zm-4 7h2v5h-2v-5zm4 0h2v5h-2v-5zm-4 7h2v5h-2v-5zm4 0h2v5h-2v-5z" />
564 </svg>
565 </div>
566 </div>
567 )}
568 {/* Zoom control buttons - positioned at bottom right to avoid blocking channel selector */}
569 <div
570 style={{
571 position: "absolute",
572 bottom: margins.bottom + 10,
573 right: margins.right + 10,
574 display: "flex",
575 gap: "6px",
576 zIndex: 10,
577 }}
578 >
579 <button
580 onClick={handleZoomIn}
581 disabled={!client}
582 style={{
583 padding: "6px 8px",
584 fontSize: "14px",
585 borderRadius: "4px",
586 border: "1px solid #ccc",
587 backgroundColor: "white",
588 cursor: client ? "pointer" : "not-allowed",
589 opacity: client ? 1 : 0.5,
590 display: "flex",
591 alignItems: "center",
592 justifyContent: "center",
593 }}
594 title="Zoom in"
595 >
596 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
597 <circle cx="11" cy="11" r="8" />
598 <path d="M21 21l-4.35-4.35" />
599 <line x1="8" y1="11" x2="14" y2="11" />
600 <line x1="11" y1="8" x2="11" y2="14" />
601 </svg>
602 </button>
603 <button
604 onClick={handleZoomOut}
605 disabled={!client}
606 style={{
607 padding: "6px 8px",
608 fontSize: "14px",
609 borderRadius: "4px",
610 border: "1px solid #ccc",
611 backgroundColor: "white",
612 cursor: client ? "pointer" : "not-allowed",
613 opacity: client ? 1 : 0.5,
614 display: "flex",
615 alignItems: "center",
616 justifyContent: "center",
617 }}
618 title="Zoom out"
619 >
620 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
621 <circle cx="11" cy="11" r="8" />
622 <path d="M21 21l-4.35-4.35" />
623 <line x1="8" y1="11" x2="14" y2="11" />
624 </svg>
625 </button>
626 <button
627 onClick={handleZoomReset}
628 disabled={!client}
629 style={{
630 padding: "6px 8px",
631 fontSize: "14px",
632 borderRadius: "4px",
633 border: "1px solid #ccc",
634 backgroundColor: "white",
635 cursor: client ? "pointer" : "not-allowed",
636 opacity: client ? 1 : 0.5,
637 display: "flex",
638 alignItems: "center",
639 justifyContent: "center",
640 }}
641 title="Reset zoom"
642 >
643 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
644 <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
645 <path d="M21 3v5h-5" />
646 <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
647 <path d="M3 21v-5h5" />
648 </svg>
649 </button>
650 </div>
651 <div
652 ref={setContainer}
653 style={{ position: "relative", width, height }}
654 onClick={(e) => {
655 handleCanvasClick(e);
656 hideHint();
657 }}
658 onMouseDown={hideHint}
659 >
660 <canvas
661 ref={setCanvasElement}
662 key={`canvas-${width}-${height}`}
663 width={width}
664 height={height}
665 style={{
666 position: "absolute",
667 width: "100%",
668 height: "100%",
669 }}
670 />
671 <canvas
672 ref={setOverlayCanvasElement}
673 width={width}
674 height={height}
675 style={{
676 position: "absolute",
677 width: "100%",
678 height: "100%",
679 pointerEvents: "none",
680 }}
681 />
682 </div>
683 {selectedIndex !== -1 && selectedValue && (
684 <div style={{ height: 30, padding: "5px 0", color: "#666" }}>
685 Index: {selectedIndex},{" "}
686 {Array.isArray(selectedValue)
687 ? `Values: [${selectedValue.slice(0, 5).map(v => v.toFixed(3)).join(", ")}${selectedValue.length > 5 ? ", ..." : ""}]`
688 : `Value: ${selectedValue.toFixed(3)}`}
689 </div>
690 )}
691 </div>
692 );
693};
695const computeMin = (data: SupportedTypedArray) => {
696 let min = Infinity;
697 for (let i = 0; i < data.length; i++) {
698 if (data[i] < min) {
699 min = data[i];
700 }
701 }
702 return min;
703};
705const computeMax = (data: SupportedTypedArray) => {
706 let max = -Infinity;
707 for (let i = 0; i < data.length; i++) {
708 if (data[i] > max) {
709 max = data[i];
710 }
711 }
712 return max;
713};
715export default TimeseriesView;
moveopenescclose