/ concept-collection / ephys_compression_tests
Sign in
concept-collection / ephys_compression_tests
ephys_compression_tests / web-ui / src / components / dataset / TimeseriesView.tsx
710 lines · 22.9 KBCodeBlameHistory
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";
1047e0dsave reconstructed arrays for lossyJeremy Magland 8import { ReconstructedDataInfo, ComparisonMode } from "../../types/comparison";
9import { TimeseriesDataClient } from "../../hooks/TimeseriesDataClient";
552a4baadd web-uiJeremy Magland 10
11interface TimeseriesViewProps {
12 width: number;
13 height: number;
14 dataset: Dataset;
1047e0dsave reconstructed arrays for lossyJeremy Magland 15 comparisonMode?: ComparisonMode;
16 reconstructedInfo?: ReconstructedDataInfo | null;
552a4baadd web-uiJeremy Magland 17}
19const TimeseriesView: React.FC<TimeseriesViewProps> = ({
20 width,
21 height,
22 dataset,
1047e0dsave reconstructed arrays for lossyJeremy Magland 23 comparisonMode = "original",
24 reconstructedInfo = null,
552a4baadd web-uiJeremy Magland 25}) => {
26 const { client, error: clientError } = useTimeseriesDataClient(dataset);
27 const [dataT, setDataT] = useState<number[] | null>(null);
28 const [dataY, setDataY] = useState<SupportedTypedArray | null>(null);
c6f25e2multi-channel in uiJeremy Magland 29 const [dataYAll, setDataYAll] = useState<SupportedTypedArray[] | null>(null);
1047e0dsave reconstructed arrays for lossyJeremy Magland 30 const [dataYReconstructed, setDataYReconstructed] = useState<SupportedTypedArray | null>(null);
31 const [dataYResiduals, setDataYResiduals] = useState<SupportedTypedArray | null>(null);
32 const [reconstructedClient, setReconstructedClient] = useState<TimeseriesDataClient | null>(null);
552a4baadd web-uiJeremy Magland 33 const [error, setError] = useState<string | null>(clientError);
34 const [isLoading, setIsLoading] = useState(false);
c6f25e2multi-channel in uiJeremy Magland 35 const [selectedChannel, setSelectedChannel] = useState<number | "all">(0);
36 const [numChannels, setNumChannels] = useState<number>(1);
552a4baadd web-uiJeremy Magland 37
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;
d36478fchange timeseries zoom behaviorJeremy Magland 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
552a4baadd web-uiJeremy Magland 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;
c6f25e2multi-channel in uiJeremy Magland 84
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 }
552a4baadd web-uiJeremy Magland 101 const dT = Array.from(
c6f25e2multi-channel in uiJeremy Magland 102 { length: end - start },
552a4baadd web-uiJeremy Magland 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();
c6f25e2multi-channel in uiJeremy Magland 117 }, [client, xRange, selectedChannel, numChannels]);
552a4baadd web-uiJeremy Magland 118
3215a30recon comparison viewsJeremy Magland 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]);
552a4baadd web-uiJeremy Magland 178 // Update xRange when client is initialized
179 useEffect(() => {
180 if (client) {
181 const shape = client.getShape();
c6f25e2multi-channel in uiJeremy Magland 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);
552a4baadd web-uiJeremy Magland 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();
d36478fchange timeseries zoom behaviorJeremy Magland 202 e.stopPropagation();
552a4baadd web-uiJeremy Magland 203
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 };
d36478fchange timeseries zoom behaviorJeremy Magland 233 }, [container, client, width, margins, isWheelEnabled]);
552a4baadd web-uiJeremy Magland 234
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>(() => {
c6f25e2multi-channel in uiJeremy Magland 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) {
5538528resid plotJeremy Magland 328 let min = computeMin(dataY);
329 let max = computeMax(dataY);
331 // When comparing with reconstructed data, include that data in the range calculation
332 if (comparisonMode === "overlay" || comparisonMode === "side-by-side") {
333 if (dataYReconstructed) {
334 const reconstructedMin = computeMin(dataYReconstructed);
335 const reconstructedMax = computeMax(dataYReconstructed);
336 if (reconstructedMin < min) min = reconstructedMin;
337 if (reconstructedMax > max) max = reconstructedMax;
338 }
339 } else if (comparisonMode === "residuals") {
340 // For residuals mode, use only the residuals range
341 if (dataYResiduals) {
342 min = computeMin(dataYResiduals);
343 max = computeMax(dataYResiduals);
344 }
345 }
347 return { min, max };
c6f25e2multi-channel in uiJeremy Magland 348 }
349 return { min: 0, max: 1 };
5538528resid plotJeremy Magland 350 }, [dataY, dataYAll, dataYReconstructed, dataYResiduals, comparisonMode]);
552a4baadd web-uiJeremy Magland 351
352 // Handle dimension changes
353 useEffect(() => {
354 if (!worker) return;
355 if (!dataT) return;
c6f25e2multi-channel in uiJeremy Magland 356 if (!dataY && !dataYAll) return;
552a4baadd web-uiJeremy Magland 357
358 const msg: WorkerMessage = {
359 type: "render",
360 timeseriesT: dataT,
c6f25e2multi-channel in uiJeremy Magland 361 timeseriesY: dataY ? Array.from(dataY) : [],
362 timeseriesYAll: dataYAll ? dataYAll.map(ch => Array.from(ch)) : undefined,
3215a30recon comparison viewsJeremy Magland 363 timeseriesYReconstructed: dataYReconstructed ? Array.from(dataYReconstructed) : undefined,
364 timeseriesYResiduals: dataYResiduals ? Array.from(dataYResiduals) : undefined,
365 comparisonMode: comparisonMode,
552a4baadd web-uiJeremy Magland 366 width,
367 height,
368 margins,
369 xRange,
370 yRange,
371 };
372 worker.postMessage(msg);
3215a30recon comparison viewsJeremy Magland 373 }, [width, height, dataT, dataY, dataYAll, dataYReconstructed, dataYResiduals, comparisonMode, worker, margins, xRange, yRange]);
552a4baadd web-uiJeremy Magland 374
375 // Render cursor on overlay canvas
376 useEffect(() => {
c6f25e2multi-channel in uiJeremy Magland 377 if (!overlayCanvasElement || selectedIndex === null || (!dataY && !dataYAll)) return;
552a4baadd web-uiJeremy Magland 378 const ctx = overlayCanvasElement.getContext("2d");
379 if (!ctx) return;
381 // Clear overlay canvas
382 ctx.clearRect(0, 0, width, height);
384 // Draw cursor line
385 const xRatio = (selectedIndex - xRange.min) / (xRange.max - xRange.min);
386 const x = margins.left + xRatio * (width - margins.left - margins.right);
387 ctx.beginPath();
388 ctx.strokeStyle = "#ff0000";
389 ctx.lineWidth = 1;
390 ctx.setLineDash([4, 4]);
391 ctx.moveTo(x, margins.top);
392 ctx.lineTo(x, height - margins.bottom);
393 ctx.stroke();
394 }, [
395 selectedIndex,
396 overlayCanvasElement,
397 width,
398 height,
399 margins,
400 dataT,
401 dataY,
c6f25e2multi-channel in uiJeremy Magland 402 dataYAll,
552a4baadd web-uiJeremy Magland 403 xRange,
404 ]);
406 const selectedValue = useMemo(() => {
c6f25e2multi-channel in uiJeremy Magland 407 if (selectedIndex === -1 || !dataT) return null;
409 if (dataYAll) {
410 // Return all channel values
411 const values: number[] = [];
412 for (let ch = 0; ch < dataYAll.length; ch++) {
413 for (let i = 0; i < dataT.length; i++) {
414 if (dataT[i] === selectedIndex) {
415 values.push(dataYAll[ch][i]);
416 break;
417 }
418 }
419 }
420 return values.length > 0 ? values : null;
421 } else if (dataY) {
422 // Return single channel value
423 for (let i = 0; i < dataT.length; i++) {
424 if (dataT[i] === selectedIndex) {
425 return dataY[i];
426 }
552a4baadd web-uiJeremy Magland 427 }
428 }
429 return null;
c6f25e2multi-channel in uiJeremy Magland 430 }, [selectedIndex, dataT, dataY, dataYAll]);
552a4baadd web-uiJeremy Magland 431
432 if (error || clientError) {
433 return <div>Error loading data: {error || clientError}</div>;
434 }
436 if (isLoading && !dataY) {
437 return <div>Loading...</div>;
438 }
440 const handleCanvasClick = (e: React.MouseEvent<HTMLDivElement>) => {
c6f25e2multi-channel in uiJeremy Magland 441 if (!overlayCanvasElement || (!dataY && !dataYAll) || isDragging) return;
552a4baadd web-uiJeremy Magland 442
443 const rect = overlayCanvasElement.getBoundingClientRect();
444 const x = e.clientX - rect.left;
445 const xRatio = (x - margins.left) / (width - margins.left - margins.right);
446 const index = Math.round(xRange.min + xRatio * (xRange.max - xRange.min));
447 if (index >= 0) {
448 dispatch({ type: "SET_SELECTED_INDEX", index });
449 }
450 };
d36478fchange timeseries zoom behaviorJeremy Magland 452 // Zoom control functions - zoom centered on the selected index (current timepoint)
453 const handleZoomIn = () => {
454 if (!client) return;
455 // Use selectedIndex as center if set, otherwise use view center
456 const center = selectedIndex !== -1 ? selectedIndex : (xRange.min + xRange.max) / 2;
457 const currentRange = xRange.max - xRange.min;
458 const newRange = currentRange / 1.5; // Zoom in by 1.5x
459 const newMin = Math.max(0, center - newRange / 2);
460 const newMax = Math.min(client.getShape() - 1, center + newRange / 2);
461 dispatch({ type: "SET_X_RANGE", range: { min: newMin, max: newMax } });
462 };
464 const handleZoomOut = () => {
465 if (!client) return;
466 // Use selectedIndex as center if set, otherwise use view center
467 const center = selectedIndex !== -1 ? selectedIndex : (xRange.min + xRange.max) / 2;
468 const currentRange = xRange.max - xRange.min;
469 const newRange = currentRange * 1.5; // Zoom out by 1.5x
470 const shape = client.getShape();
471 const newMin = Math.max(0, center - newRange / 2);
472 const newMax = Math.min(shape - 1, center + newRange / 2);
473 dispatch({ type: "SET_X_RANGE", range: { min: newMin, max: newMax } });
474 };
476 const handleZoomReset = () => {
477 if (!client) return;
478 const shape = client.getShape();
479 dispatch({
480 type: "SET_X_RANGE",
481 range: { min: 0, max: Math.min(999, shape - 1) },
482 });
483 };
552a4baadd web-uiJeremy Magland 485 return (
486 <div style={{ position: "relative", width, height: height + 50 }}>
487 <div style={{ marginBottom: 10, height: 20 }}>
488 <TimeseriesNavigationBar
489 width={width}
490 height={20}
491 totalRange={{ min: 0, max: client ? client.getShape() - 1 : 999 }}
492 viewRange={xRange}
493 onViewRangeChange={(range) =>
494 dispatch({ type: "SET_X_RANGE", range })
495 }
496 />
497 </div>
c6f25e2multi-channel in uiJeremy Magland 498 {numChannels > 1 && (
499 <div style={{ marginBottom: 10, display: "flex", alignItems: "center", gap: 8 }}>
500 <label htmlFor="channel-select" style={{ fontSize: "14px", color: "#666" }}>
501 Channel:
502 </label>
503 <select
504 id="channel-select"
505 value={selectedChannel}
506 onChange={(e) => {
507 const value = e.target.value;
508 setSelectedChannel(value === "all" ? "all" : Number(value));
509 }}
510 style={{
511 padding: "4px 8px",
512 fontSize: "14px",
513 borderRadius: "4px",
514 border: "1px solid #ccc",
515 backgroundColor: "white",
516 cursor: "pointer",
517 }}
518 >
519 <option value="all">All (overlay)</option>
520 {Array.from({ length: numChannels }, (_, i) => (
521 <option key={i} value={i}>
522 {i}
523 </option>
524 ))}
525 </select>
526 </div>
527 )}
552a4baadd web-uiJeremy Magland 528 {showHint && (
529 <div
530 style={{
531 position: "absolute",
532 top: margins.top + 10,
533 right: margins.right + 10,
534 display: "flex",
535 flexDirection: "column",
536 alignItems: "flex-end",
537 gap: "8px",
538 zIndex: 10,
539 opacity: showHint ? 0.8 : 0,
540 transition: "opacity 0.5s ease-out",
541 pointerEvents: "none",
542 fontSize: "12px",
543 color: "#666",
544 }}
545 >
546 <div
547 style={{
548 display: "flex",
549 alignItems: "center",
550 gap: "4px",
551 backgroundColor: "rgba(255, 255, 255, 0.9)",
552 padding: "2px 6px",
553 borderRadius: "4px",
554 }}
555 >
556 <span>Drag to pan</span>
557 <svg width="14" height="14" viewBox="0 0 24 24" fill="#666">
558 <path d="M15 3h2v5h-2V3zm4 0h2v5h-2V3zm-4 7h2v5h-2v-5zm4 0h2v5h-2v-5zm-4 7h2v5h-2v-5zm4 0h2v5h-2v-5z" />
559 </svg>
560 </div>
561 </div>
562 )}
d36478fchange timeseries zoom behaviorJeremy Magland 563 {/* Zoom control buttons - positioned at bottom right to avoid blocking channel selector */}
564 <div
565 style={{
566 position: "absolute",
567 bottom: margins.bottom + 10,
568 right: margins.right + 10,
569 display: "flex",
570 gap: "6px",
571 zIndex: 10,
572 }}
573 >
574 <button
575 onClick={handleZoomIn}
576 disabled={!client}
577 style={{
578 padding: "6px 8px",
579 fontSize: "14px",
580 borderRadius: "4px",
581 border: "1px solid #ccc",
582 backgroundColor: "white",
583 cursor: client ? "pointer" : "not-allowed",
584 opacity: client ? 1 : 0.5,
585 display: "flex",
586 alignItems: "center",
587 justifyContent: "center",
588 }}
589 title="Zoom in"
590 >
591 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
592 <circle cx="11" cy="11" r="8" />
593 <path d="M21 21l-4.35-4.35" />
594 <line x1="8" y1="11" x2="14" y2="11" />
595 <line x1="11" y1="8" x2="11" y2="14" />
596 </svg>
597 </button>
598 <button
599 onClick={handleZoomOut}
600 disabled={!client}
601 style={{
602 padding: "6px 8px",
603 fontSize: "14px",
604 borderRadius: "4px",
605 border: "1px solid #ccc",
606 backgroundColor: "white",
607 cursor: client ? "pointer" : "not-allowed",
608 opacity: client ? 1 : 0.5,
609 display: "flex",
610 alignItems: "center",
611 justifyContent: "center",
612 }}
613 title="Zoom out"
614 >
615 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
616 <circle cx="11" cy="11" r="8" />
617 <path d="M21 21l-4.35-4.35" />
618 <line x1="8" y1="11" x2="14" y2="11" />
619 </svg>
620 </button>
621 <button
622 onClick={handleZoomReset}
623 disabled={!client}
624 style={{
625 padding: "6px 8px",
626 fontSize: "14px",
627 borderRadius: "4px",
628 border: "1px solid #ccc",
629 backgroundColor: "white",
630 cursor: client ? "pointer" : "not-allowed",
631 opacity: client ? 1 : 0.5,
632 display: "flex",
633 alignItems: "center",
634 justifyContent: "center",
635 }}
636 title="Reset zoom"
637 >
638 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
639 <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
640 <path d="M21 3v5h-5" />
641 <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
642 <path d="M3 21v-5h5" />
643 </svg>
644 </button>
645 </div>
552a4baadd web-uiJeremy Magland 646 <div
647 ref={setContainer}
648 style={{ position: "relative", width, height }}
649 onClick={(e) => {
650 handleCanvasClick(e);
651 hideHint();
652 }}
653 onMouseDown={hideHint}
654 >
655 <canvas
656 ref={setCanvasElement}
657 key={`canvas-${width}-${height}`}
658 width={width}
659 height={height}
660 style={{
661 position: "absolute",
662 width: "100%",
663 height: "100%",
664 }}
665 />
666 <canvas
667 ref={setOverlayCanvasElement}
668 width={width}
669 height={height}
670 style={{
671 position: "absolute",
672 width: "100%",
673 height: "100%",
674 pointerEvents: "none",
675 }}
676 />
677 </div>
c6f25e2multi-channel in uiJeremy Magland 678 {selectedIndex !== -1 && selectedValue && (
552a4baadd web-uiJeremy Magland 679 <div style={{ height: 30, padding: "5px 0", color: "#666" }}>
c6f25e2multi-channel in uiJeremy Magland 680 Index: {selectedIndex},{" "}
681 {Array.isArray(selectedValue)
682 ? `Values: [${selectedValue.slice(0, 5).map(v => v.toFixed(3)).join(", ")}${selectedValue.length > 5 ? ", ..." : ""}]`
683 : `Value: ${selectedValue.toFixed(3)}`}
552a4baadd web-uiJeremy Magland 684 </div>
685 )}
686 </div>
687 );
688};
690const computeMin = (data: SupportedTypedArray) => {
691 let min = Infinity;
692 for (let i = 0; i < data.length; i++) {
693 if (data[i] < min) {
694 min = data[i];
695 }
696 }
697 return min;
698};
700const computeMax = (data: SupportedTypedArray) => {
701 let max = -Infinity;
702 for (let i = 0; i < data.length; i++) {
703 if (data[i] > max) {
704 max = data[i];
705 }
706 }
707 return max;
708};
710export default TimeseriesView;
moveopenescclose