diff --git a/src/App.tsx b/src/App.tsx index acbaab9..c32018a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -59,7 +59,7 @@ function App() {
-

+

High-Performance CSV Plotter

diff --git a/src/components/DataPlot.tsx b/src/components/DataPlot.tsx index a661e3a..bb98000 100644 --- a/src/components/DataPlot.tsx +++ b/src/components/DataPlot.tsx @@ -93,10 +93,13 @@ const DataPlot: React.FC = React.memo(({ data }) => { }, [containerWidth]); return ( -
+
{!data || data.length === 0 ? (
-

+

Select a CSV file to display the plot

diff --git a/src/components/FileSelector.tsx b/src/components/FileSelector.tsx index 51de68d..d78de10 100644 --- a/src/components/FileSelector.tsx +++ b/src/components/FileSelector.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useRef } from 'react'; import { Button } from './ui/button'; interface FileSelectorProps { @@ -9,6 +9,8 @@ interface FileSelectorProps { const FileSelector: React.FC = React.memo( ({ onFileSelect, isLoading, progress }) => { + const inputRef = useRef(null); + const handleFileChange = useCallback( (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -19,10 +21,18 @@ const FileSelector: React.FC = React.memo( [onFileSelect], ); + const handleTriggerFileSelect = useCallback(() => { + if (inputRef.current) { + inputRef.current.value = ''; + inputRef.current.click(); + } + }, []); + return (
= React.memo( id="file-input" disabled={isLoading} /> - + = React.memo(
{showTooltip && totalPoints === 0 && (
) { ); } -function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { - return ( -
- ); -} - function CardContent({ className, ...props }: React.ComponentProps<'div'>) { return (
) { ); } -function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { - return ( -
- ); -} - -export { - Card, - CardHeader, - CardFooter, - CardTitle, - CardDescription, - CardContent, -}; +export { Card, CardHeader, CardTitle, CardContent }; diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx index 614f8ac..668b605 100644 --- a/src/components/ui/slider.tsx +++ b/src/components/ui/slider.tsx @@ -11,6 +11,7 @@ function Slider({ max = 100, ...props }: React.ComponentProps) { + const thumbIdPrefix = React.useId(); const _values = React.useMemo( () => Array.isArray(value) @@ -47,10 +48,10 @@ function Slider({ )} /> - {Array.from({ length: _values.length }, (_, index) => ( + {_values.map((_, index) => ( ))} diff --git a/src/hooks/useDataStream.ts b/src/hooks/useDataStream.ts index c452e9f..3deef8d 100644 --- a/src/hooks/useDataStream.ts +++ b/src/hooks/useDataStream.ts @@ -1,9 +1,11 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { DataPoint } from '@/types'; +type DataStreamStatus = 'idle' | 'processing'; + export function useDataStream() { const [data, setData] = useState([]); - const [isLoading, setIsLoading] = useState(false); + const [status, setStatus] = useState('idle'); const [progress, setProgress] = useState(0); const [error, setError] = useState(null); @@ -20,7 +22,7 @@ export function useDataStream() { }, []); const processFile = useCallback(async (file: File) => { - setIsLoading(true); + setStatus('processing'); setError(null); setProgress(0); setData([]); @@ -59,13 +61,13 @@ export function useDataStream() { pointsCollectionRef.current.sort((a, b) => a.x - b.x); setData([...pointsCollectionRef.current]); - setIsLoading(false); + setStatus('idle'); } }; workerRef.current.onerror = (e) => { setError(`Worker error: ${e.message}`); - setIsLoading(false); + setStatus('idle'); }; const arrayBuffer = await file.arrayBuffer(); @@ -76,9 +78,10 @@ export function useDataStream() { } catch (err) { const message = err instanceof Error ? err.message : String(err); setError(`Error processing file: ${message}`); - setIsLoading(false); + setStatus('idle'); } }, []); + const isLoading = status === 'processing'; return { data, isLoading, progress, error, processFile }; } diff --git a/src/hooks/usePlotWindow.ts b/src/hooks/usePlotWindow.ts index ec28a89..d2452fd 100644 --- a/src/hooks/usePlotWindow.ts +++ b/src/hooks/usePlotWindow.ts @@ -15,6 +15,12 @@ interface UsePlotWindowProps { defaultDownsamplingThreshold?: number; } +interface PlaybackState { + start: number; + windowSize: number; + isPlaying: boolean; +} + export function usePlotWindow({ data, defaultWindowSize = DEFAULT_WINDOW_SIZE, @@ -23,47 +29,53 @@ export function usePlotWindow({ defaultDownsamplingThreshold = DEFAULT_DOWNSAMPLING_THRESHOLD, }: UsePlotWindowProps) { const dataLength = data.length; - const [start, setStart] = useState(0); - const [windowSize, setWindowSize] = useState(defaultWindowSize); - const [interval, setInterval] = useState(defaultInterval); - const [increment, setIncrement] = useState(defaultIncrement); - const [downsamplingThreshold, setDownsamplingThreshold] = useState( + const [playbackState, setPlaybackState] = useState({ + start: 0, + windowSize: defaultWindowSize, + isPlaying: false, + }); + const [interval, setIntervalState] = useState(defaultInterval); + const [increment, setIncrementState] = useState(defaultIncrement); + const [downsamplingThreshold, setDownsamplingThresholdState] = useState( defaultDownsamplingThreshold, ); - const [isPlaying, setIsPlaying] = useState(false); const animationRef = useRef(null); const lastUpdateTimeRef = useRef(0); - const maxStart = Math.max(dataLength - windowSize, 0); + const maxStart = Math.max(dataLength - playbackState.windowSize, 0); const togglePlay = useCallback(() => { - setIsPlaying((prev) => !prev); + setPlaybackState((prev) => ({ + ...prev, + isPlaying: !prev.isPlaying, + })); }, []); const animateFrame = useCallback( (timestamp: number) => { if (timestamp - lastUpdateTimeRef.current >= interval) { - setStart((prev) => { - const newStart = prev + increment; - if (newStart >= maxStart) { - setIsPlaying(false); - return maxStart; - } - return newStart; + setPlaybackState((prev) => { + const nextStart = Math.min(prev.start + increment, maxStart); + + return { + ...prev, + start: nextStart, + isPlaying: nextStart < maxStart, + }; }); lastUpdateTimeRef.current = timestamp; } - if (isPlaying) { + if (playbackState.isPlaying) { animationRef.current = requestAnimationFrame(animateFrame); } }, - [interval, increment, isPlaying, maxStart], + [increment, interval, maxStart, playbackState.isPlaying], ); useEffect(() => { - if (isPlaying) { + if (playbackState.isPlaying) { lastUpdateTimeRef.current = performance.now(); animationRef.current = requestAnimationFrame(animateFrame); } else if (animationRef.current) { @@ -77,27 +89,26 @@ export function usePlotWindow({ animationRef.current = null; } }; - }, [isPlaying, animateFrame]); + }, [playbackState.isPlaying, animateFrame]); useEffect(() => { - setIsPlaying(false); - setStart(0); - - if (dataLength > 0) { - setWindowSize(Math.min(defaultWindowSize, dataLength)); - } else { - setWindowSize(defaultWindowSize); - } + setPlaybackState((prev) => ({ + ...prev, + isPlaying: false, + start: 0, + windowSize: + dataLength > 0 + ? Math.min(defaultWindowSize, dataLength) + : defaultWindowSize, + })); }, [dataLength, defaultWindowSize]); const handleStartChange = useCallback( (value: number) => { - if (dataLength === 0) { - setStart(0); - return; - } - - setStart(Math.max(0, Math.min(value, maxStart))); + setPlaybackState((prev) => ({ + ...prev, + start: dataLength === 0 ? 0 : Math.max(0, Math.min(value, maxStart)), + })); }, [dataLength, maxStart], ); @@ -105,32 +116,48 @@ export function usePlotWindow({ const handleWindowSizeChange = useCallback( (value: number) => { if (dataLength === 0) { - setWindowSize(defaultWindowSize); - setStart(0); + setPlaybackState((prev) => ({ + ...prev, + start: 0, + windowSize: defaultWindowSize, + })); return; } const nextWindowSize = Math.max(1, Math.min(value, dataLength)); - setWindowSize(nextWindowSize); - setStart((currentStart) => - Math.min(currentStart, Math.max(dataLength - nextWindowSize, 0)), - ); + setPlaybackState((prev) => ({ + ...prev, + windowSize: nextWindowSize, + start: Math.min(prev.start, Math.max(dataLength - nextWindowSize, 0)), + })); }, [dataLength, defaultWindowSize], ); + const handleIntervalChange = useCallback((value: number) => { + setIntervalState(value); + }, []); + + const handleIncrementChange = useCallback((value: number) => { + setIncrementState(value); + }, []); + + const handleDownsamplingThresholdChange = useCallback((value: number) => { + setDownsamplingThresholdState(value); + }, []); + return { - start, + start: playbackState.start, setStart: handleStartChange, - windowSize, + windowSize: playbackState.windowSize, setWindowSize: handleWindowSizeChange, interval, - setInterval, + setInterval: handleIntervalChange, increment, - setIncrement, + setIncrement: handleIncrementChange, downsamplingThreshold, - setDownsamplingThreshold, - isPlaying, + setDownsamplingThreshold: handleDownsamplingThresholdChange, + isPlaying: playbackState.isPlaying, togglePlay, }; } diff --git a/src/workers/csvParser.worker.ts b/src/workers/csvParser.worker.ts index 7bff80c..aca2980 100644 --- a/src/workers/csvParser.worker.ts +++ b/src/workers/csvParser.worker.ts @@ -16,7 +16,13 @@ const BATCH_SIZE = 100000; * Parses the file incrementally to keep memory bounded while streaming * progress updates back to the main thread. */ -async function parseCSV(fileBuffer: ArrayBuffer) { +function yieldToMainThread() { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +function parseCSV(fileBuffer: ArrayBuffer) { let dataPoints: { x: number; y: number }[] = []; let buffer = ''; let totalPoints = 0; @@ -26,7 +32,93 @@ async function parseCSV(fileBuffer: ArrayBuffer) { // Reuse one decoder so streamed reads preserve chunk boundaries correctly. const decoder = new TextDecoder(); - for (let offset = 0; offset < fileBuffer.byteLength; offset += CHUNK_SIZE) { + const processLineBatch = ( + completeLines: string[], + startIndex = 0, + ): Promise => { + for (let index = startIndex; index < completeLines.length; index++) { + const line = completeLines[index]; + totalLines++; + + if (!line.trim()) continue; + + const [x, y] = line.split(',').map((v) => parseFloat(v.trim())); + + if (!isNaN(x) && !isNaN(y)) { + dataPoints.push({ x, y }); + totalPoints++; + } + + if (dataPoints.length >= BATCH_SIZE) { + const pointsBatch = dataPoints.slice(); + + ctx.postMessage({ + action: 'result', + points: pointsBatch, + count: pointsBatch.length, + currentTotal: totalPoints, + }); + + dataPoints = []; + reportProgress( + processedBytes, + fileBuffer.byteLength, + totalPoints, + totalLines, + ); + + return yieldToMainThread().then(() => + processLineBatch(completeLines, index + 1), + ); + } + } + + return Promise.resolve(); + }; + + const finishParsing = () => { + if (buffer.trim()) { + totalLines++; + const [x, y] = buffer + .trim() + .split(',') + .map((v) => parseFloat(v.trim())); + if (!isNaN(x) && !isNaN(y)) { + dataPoints.push({ x, y }); + totalPoints++; + } + } + + if (dataPoints.length > 0) { + ctx.postMessage({ + action: 'result', + points: dataPoints.slice(), + count: dataPoints.length, + currentTotal: totalPoints, + }); + } + + ctx.postMessage({ + action: 'complete', + totalPoints, + totalLines, + processedBytes: fileBuffer.byteLength, + }); + + reportProgress( + fileBuffer.byteLength, + fileBuffer.byteLength, + totalPoints, + totalLines, + ); + }; + + const processChunk = (offset: number): Promise => { + if (offset >= fileBuffer.byteLength) { + finishParsing(); + return Promise.resolve(); + } + const chunkEnd = Math.min(offset + CHUNK_SIZE, fileBuffer.byteLength); const chunk = fileBuffer.slice(offset, chunkEnd); @@ -41,40 +133,16 @@ async function parseCSV(fileBuffer: ArrayBuffer) { const completeLines = buffer.substring(0, lastNewlineIndex).split('\n'); buffer = buffer.substring(lastNewlineIndex + 1); - for (const line of completeLines) { - totalLines++; - - if (!line.trim()) continue; - - const [x, y] = line.split(',').map((v) => parseFloat(v.trim())); - - if (!isNaN(x) && !isNaN(y)) { - dataPoints.push({ x, y }); - totalPoints++; - } - - if (dataPoints.length >= BATCH_SIZE) { - const pointsBatch = dataPoints.slice(); - - ctx.postMessage({ - action: 'result', - points: pointsBatch, - count: pointsBatch.length, - currentTotal: totalPoints, - }); - - dataPoints = []; - reportProgress( - processedBytes, - fileBuffer.byteLength, - totalPoints, - totalLines, - ); + return processLineBatch(completeLines).then(() => { + reportProgress( + processedBytes, + fileBuffer.byteLength, + totalPoints, + totalLines, + ); - // Yield between batches so the UI can apply progress updates. - await new Promise((resolve) => setTimeout(resolve, 0)); - } - } + return yieldToMainThread().then(() => processChunk(chunkEnd)); + }); } reportProgress( @@ -84,44 +152,10 @@ async function parseCSV(fileBuffer: ArrayBuffer) { totalLines, ); - // Yield between chunk reads to keep parsing responsive on large files. - await new Promise((resolve) => setTimeout(resolve, 0)); - } - - if (buffer.trim()) { - totalLines++; - const [x, y] = buffer - .trim() - .split(',') - .map((v) => parseFloat(v.trim())); - if (!isNaN(x) && !isNaN(y)) { - dataPoints.push({ x, y }); - totalPoints++; - } - } - - if (dataPoints.length > 0) { - ctx.postMessage({ - action: 'result', - points: dataPoints.slice(), - count: dataPoints.length, - currentTotal: totalPoints, - }); - } - - ctx.postMessage({ - action: 'complete', - totalPoints, - totalLines, - processedBytes: fileBuffer.byteLength, - }); + return yieldToMainThread().then(() => processChunk(chunkEnd)); + }; - reportProgress( - fileBuffer.byteLength, - fileBuffer.byteLength, - totalPoints, - totalLines, - ); + return processChunk(0); } function reportProgress(