import { useEffect, useMemo, useReducer, useState, useCallback } from "react"; import TimeseriesNavigationBar from "./TimeseriesNavigationBar"; import { SupportedTypedArray } from "../../hooks/TimeseriesDataClient"; import { useTimeseriesDataClient } from "../../hooks/useTimeseriesDataClient"; import { Dataset } from "../../types"; import { Margins, Range, WorkerMessage } from "./WorkerTypes"; import { initialState, timeseriesViewReducer } from "./timeseriesViewReducer"; interface TimeseriesViewProps { width: number; height: number; dataset: Dataset; } const TimeseriesView: React.FC = ({ width, height, dataset, }) => { const { client, error: clientError } = useTimeseriesDataClient(dataset); const [dataT, setDataT] = useState(null); const [dataY, setDataY] = useState(null); const [error, setError] = useState(clientError); const [isLoading, setIsLoading] = useState(false); const [canvasElement, setCanvasElement] = useState( null, ); const [overlayCanvasElement, setOverlayCanvasElement] = useState(null); const [state, dispatch] = useReducer(timeseriesViewReducer, initialState); const { selectedIndex, isDragging, lastDragX, xRange } = state; const [isWheelEnabled, setIsWheelEnabled] = useState(false); const [showHint, setShowHint] = useState(true); // Hide hint when user interacts with the graph const hideHint = useCallback(() => { setShowHint(false); }, []); // Auto-hide hint after 4 seconds useEffect(() => { if (showHint) { const timer = setTimeout(() => { setShowHint(false); }, 5000); return () => clearTimeout(timer); } }, [showHint]); const [container, setContainer] = useState(null); const [worker, setWorker] = useState(null); const [margins] = useState({ left: 50, right: 20, top: 20, bottom: 50, }); // Load data for current range useEffect(() => { if (!client || !xRange) return; const loadRangeData = async () => { try { setIsLoading(true); const start = Math.floor(xRange.min); const end = Math.ceil(xRange.max) + 1; const rangeData = await client.fetchRange(start, end); setDataY(rangeData); const dT = Array.from( { length: rangeData.length }, (_, i) => i + start, ); setDataT(dT); setError(null); } catch (err) { setError( err instanceof Error ? err.message : "Failed to load data range", ); } finally { setIsLoading(false); } }; loadRangeData(); }, [client, xRange]); // Update xRange when client is initialized useEffect(() => { if (client) { const shape = client.getShape(); dispatch({ type: "SET_X_RANGE", range: { min: 0, max: Math.min(999, shape - 1) }, }); } }, [client]); // Set up wheel event listener useEffect(() => { if (!container || !client) return; const handleWheel = (e: WheelEvent) => { if (!isWheelEnabled) { return; // Allow page scrolling if wheel zoom not enabled } e.preventDefault(); const rect = container.getBoundingClientRect(); const x = e.clientX - rect.left; const xRatio = (x - margins.left) / (width - margins.left - margins.right); // Calculate zoom center in data coordinates const zoomCenter = xRange.min + (xRange.max - xRange.min) * xRatio; // Calculate new range const zoomFactor = e.deltaY > 0 ? 1.1 : 1 / 1.1; const shape = client.getShape(); // Ensure we don't zoom out beyond data bounds const newMin = Math.max( 0, zoomCenter - (zoomCenter - xRange.min) * zoomFactor, ); const newMax = Math.min( shape - 1, zoomCenter + (xRange.max - zoomCenter) * zoomFactor, ); dispatch({ type: "SET_X_RANGE", range: { min: newMin, max: newMax } }); }; container.addEventListener("wheel", handleWheel, { passive: false }); return () => { container.removeEventListener("wheel", handleWheel); }; }, [container, client, width, margins, xRange, isWheelEnabled]); // Set up mouse event listeners for panning useEffect(() => { if (!container || !client) return; const handleMouseDown = (e: MouseEvent) => { dispatch({ type: "SET_IS_DRAGGING", isDragging: true }); dispatch({ type: "SET_LAST_DRAG_X", x: e.clientX }); }; const handleMouseMove = (e: MouseEvent) => { if (!isDragging || lastDragX === 0) return; const deltaX = e.clientX - lastDragX; const xRatio = deltaX / (width - margins.left - margins.right); const dataDelta = (xRange.max - xRange.min) * xRatio; const shape = client.getShape(); if (xRange.min - dataDelta < 0) return; if (xRange.max - dataDelta > shape - 1) return; const newMin = xRange.min - dataDelta; const newMax = xRange.max - dataDelta; // Only update if we're still within bounds if (newMin >= 0 && newMax <= shape - 1) { dispatch({ type: "SET_X_RANGE", range: { min: newMin, max: newMax } }); } dispatch({ type: "SET_LAST_DRAG_X", x: e.clientX }); }; const handleMouseUp = () => { dispatch({ type: "SET_IS_DRAGGING", isDragging: false }); dispatch({ type: "SET_LAST_DRAG_X", x: 0 }); }; container.addEventListener("mousedown", handleMouseDown); window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mouseup", handleMouseUp); return () => { container.removeEventListener("mousedown", handleMouseDown); window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); }; }, [container, client, width, margins, xRange, isDragging, lastDragX]); // Set worker useEffect(() => { if (!canvasElement) return; const worker = new Worker( new URL("./TimeseriesViewWorker", import.meta.url), { type: "module", }, ); let offscreenCanvas: OffscreenCanvas; try { offscreenCanvas = canvasElement.transferControlToOffscreen(); } catch (err) { console.warn(err); console.warn( "Unable to transfer control to offscreen canvas (expected during dev)", ); return; } const msg: WorkerMessage = { type: "initialize", canvas: offscreenCanvas, }; worker.postMessage(msg, [offscreenCanvas]); setWorker(worker); return () => { worker.terminate(); }; }, [canvasElement]); // Calculate yRange from data const yRange = useMemo(() => { if (!dataY) return { min: 0, max: 1 }; return { min: computeMin(dataY), max: computeMax(dataY), }; }, [dataY]); // Handle dimension changes useEffect(() => { if (!worker) return; if (!dataY) return; if (!dataT) return; const msg: WorkerMessage = { type: "render", timeseriesT: dataT, timeseriesY: Array.from(dataY), width, height, margins, xRange, yRange, }; worker.postMessage(msg); }, [width, height, dataT, dataY, worker, margins, xRange, yRange]); // Render cursor on overlay canvas useEffect(() => { if (!overlayCanvasElement || selectedIndex === null || !dataY) return; const ctx = overlayCanvasElement.getContext("2d"); if (!ctx) return; // Clear overlay canvas ctx.clearRect(0, 0, width, height); // Draw cursor line const xRatio = (selectedIndex - xRange.min) / (xRange.max - xRange.min); const x = margins.left + xRatio * (width - margins.left - margins.right); ctx.beginPath(); ctx.strokeStyle = "#ff0000"; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); ctx.moveTo(x, margins.top); ctx.lineTo(x, height - margins.bottom); ctx.stroke(); }, [ selectedIndex, overlayCanvasElement, width, height, margins, dataT, dataY, xRange, ]); const selectedValue = useMemo(() => { if (selectedIndex === -1 || !dataT || !dataY) return null; for (let i = 0; i < dataT.length; i++) { if (dataT[i] === selectedIndex) { return dataY[i]; } } return null; }, [selectedIndex, dataT, dataY]); if (error || clientError) { return
Error loading data: {error || clientError}
; } if (isLoading && !dataY) { return
Loading...
; } const handleCanvasClick = (e: React.MouseEvent) => { if (!overlayCanvasElement || !dataY || isDragging) return; // Enable wheel zooming on first click if (!isWheelEnabled) { setIsWheelEnabled(true); } const rect = overlayCanvasElement.getBoundingClientRect(); const x = e.clientX - rect.left; const xRatio = (x - margins.left) / (width - margins.left - margins.right); const index = Math.round(xRange.min + xRatio * (xRange.max - xRange.min)); if (index >= 0) { dispatch({ type: "SET_SELECTED_INDEX", index }); } }; return (
dispatch({ type: "SET_X_RANGE", range }) } />
{showHint && (
Drag to pan
Scroll to zoom
)}
{ handleCanvasClick(e); hideHint(); }} onMouseDown={hideHint} >
{selectedIndex !== -1 && dataY && (
Index: {selectedIndex}, Value: {selectedValue?.toFixed(3)}
)}
); }; const computeMin = (data: SupportedTypedArray) => { let min = Infinity; for (let i = 0; i < data.length; i++) { if (data[i] < min) { min = data[i]; } } return min; }; const computeMax = (data: SupportedTypedArray) => { let max = -Infinity; for (let i = 0; i < data.length; i++) { if (data[i] > max) { max = data[i]; } } return max; }; export default TimeseriesView;