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