) {
);
}
-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(