Skip to content

Commit 2d8223e

Browse files
committed
feat: add click-and-drag zoom to bandwidth and stream count charts
Add useChartZoom hook that enables click-and-drag time range selection on the BandwidthChart with synchronized zoom on the StreamCountChart. Includes 10px drag threshold, 30-second minimum selection, right-to-left normalization, blue ReferenceArea overlay during selection, tooltip suppression while dragging, zoom-aware XAxis formatting (HH:mm:ss when zoomed ≤1hr), and text selection prevention during drag. Three reset mechanisms: toolbar button, double-click, or time range dropdown change.
1 parent 0038174 commit 2d8223e

5 files changed

Lines changed: 334 additions & 22 deletions

File tree

frontend/src/components/ActiveStreams.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
import { Play, Pause, StopCircle, Loader2, AlertCircle, X } from 'lucide-react';
2828
import { StreamCountChart } from './StreamCountChart';
2929
import type { TimeRange, DataInterval } from './BandwidthChart';
30+
import type { ZoomRange } from '@/hooks/useChartZoom';
3031
import { formatInTimeZone } from 'date-fns-tz';
3132

3233
const getStateIcon = (state: string) => {
@@ -58,9 +59,10 @@ const getStateBadgeVariant = (state: string): "default" | "secondary" | "destruc
5859
interface ActiveStreamsProps {
5960
timeRange: TimeRange;
6061
dataInterval: DataInterval;
62+
zoomRange?: ZoomRange | null;
6163
}
6264

63-
export const ActiveStreams: React.FC<ActiveStreamsProps> = ({ timeRange, dataInterval }) => {
65+
export const ActiveStreams: React.FC<ActiveStreamsProps> = ({ timeRange, dataInterval, zoomRange }) => {
6466
const { user } = useAuth();
6567
const isAdmin = user?.role === 'admin';
6668

@@ -119,7 +121,7 @@ export const ActiveStreams: React.FC<ActiveStreamsProps> = ({ timeRange, dataInt
119121
if (isLoading) {
120122
return (
121123
<>
122-
<StreamCountChart timeRange={timeRange} dataInterval={dataInterval} />
124+
<StreamCountChart timeRange={timeRange} dataInterval={dataInterval} zoomRange={zoomRange} />
123125
<Card>
124126
<CardContent className="flex justify-center p-8">
125127
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
@@ -131,7 +133,7 @@ export const ActiveStreams: React.FC<ActiveStreamsProps> = ({ timeRange, dataInt
131133

132134
return (
133135
<>
134-
<StreamCountChart timeRange={timeRange} dataInterval={dataInterval} />
136+
<StreamCountChart timeRange={timeRange} dataInterval={dataInterval} zoomRange={zoomRange} />
135137
<Card>
136138
<CardHeader>
137139
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">

frontend/src/components/BandwidthChart.tsx

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
Legend,
1010
ResponsiveContainer,
1111
ReferenceLine,
12+
ReferenceArea,
1213
Line,
1314
} from 'recharts';
1415
import { formatInTimeZone } from 'date-fns-tz';
@@ -24,7 +25,8 @@ import {
2425
} from '@/components/ui/select';
2526
import { Alert, AlertDescription } from '@/components/ui/alert';
2627
import { Button } from '@/components/ui/button';
27-
import { Loader2, AlertCircle, Layers, BarChart3, ArrowUpDown } from 'lucide-react';
28+
import { Loader2, AlertCircle, Layers, BarChart3, ArrowUpDown, ZoomOut } from 'lucide-react';
29+
import { useChartZoom, type ZoomRange } from '@/hooks/useChartZoom';
2830

2931
// Gradient ID mapping for each client
3032
const DOWNLOAD_GRADIENT_IDS: Record<string, string> = {
@@ -137,6 +139,7 @@ interface BandwidthChartProps {
137139
dataInterval: DataInterval;
138140
setDataInterval: (interval: DataInterval) => void;
139141
timeRanges: TimeRange[];
142+
onZoomChange?: (zoomRange: ZoomRange | null) => void;
140143
}
141144

142145
// Default visible series configuration
@@ -188,6 +191,7 @@ export const BandwidthChart: React.FC<BandwidthChartProps> = ({
188191
dataInterval,
189192
setDataInterval,
190193
timeRanges,
194+
onZoomChange,
191195
}) => {
192196
const [rawData, setRawData] = useState<ChartDataPoint[]>([]);
193197
const [isInitialLoad, setIsInitialLoad] = useState(true);
@@ -211,6 +215,26 @@ export const BandwidthChart: React.FC<BandwidthChartProps> = ({
211215
return [];
212216
});
213217

218+
// Chart zoom state
219+
const {
220+
isSelecting,
221+
selectionStart,
222+
selectionEnd,
223+
zoomRange,
224+
isZoomed,
225+
handleMouseDown: zoomMouseDown,
226+
handleMouseMove: zoomMouseMove,
227+
handleMouseUp: zoomMouseUp,
228+
handleDoubleClick: zoomDoubleClick,
229+
resetZoom,
230+
filterDataByZoom,
231+
} = useChartZoom();
232+
233+
// Notify parent of zoom range changes
234+
useEffect(() => {
235+
onZoomChange?.(zoomRange);
236+
}, [zoomRange, onZoomChange]);
237+
214238
// Save stacking preferences to localStorage
215239
useEffect(() => {
216240
localStorage.setItem('speedarr_chart_stacked', JSON.stringify(stackChart));
@@ -377,17 +401,20 @@ export const BandwidthChart: React.FC<BandwidthChartProps> = ({
377401
}
378402
}, [visibleSeries]);
379403

380-
// Memoize aggregation - only recomputes when rawData or dataInterval changes
404+
// Apply zoom filter before aggregation
405+
const zoomedRawData = useMemo(() => filterDataByZoom(rawData), [rawData, filterDataByZoom]);
406+
407+
// Memoize aggregation - only recomputes when zoomedRawData or dataInterval changes
381408
const aggregatedData = useMemo(() => {
382-
if (rawData.length === 0) return [];
383-
if (dataInterval === 'raw') return rawData;
409+
if (zoomedRawData.length === 0) return [];
410+
if (dataInterval === 'raw') return zoomedRawData;
384411

385412
const intervalMinutes = dataInterval as number;
386413
const intervalMs = intervalMinutes * 60 * 1000;
387414
const buckets: Map<number, ChartDataPoint[]> = new Map();
388415

389416
// Group data points into time buckets
390-
rawData.forEach((point) => {
417+
zoomedRawData.forEach((point) => {
391418
const timestamp = new Date(point.timestamp).getTime();
392419
const bucketKey = Math.floor(timestamp / intervalMs) * intervalMs;
393420

@@ -433,7 +460,7 @@ export const BandwidthChart: React.FC<BandwidthChartProps> = ({
433460
});
434461

435462
return aggregated.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
436-
}, [rawData, dataInterval]);
463+
}, [zoomedRawData, dataInterval]);
437464

438465
const fetchData = useCallback(async () => {
439466
setError('');
@@ -551,13 +578,27 @@ export const BandwidthChart: React.FC<BandwidthChartProps> = ({
551578
}, [fetchData]);
552579

553580

581+
// Reset zoom when time range dropdown changes
582+
useEffect(() => {
583+
resetZoom();
584+
}, [timeRange, resetZoom]);
585+
586+
// Calculate zoomed duration for XAxis formatting
587+
const zoomedDurationHours = useMemo(() => {
588+
if (!isZoomed || zoomedRawData.length < 2) return null;
589+
const first = new Date((zoomedRawData[0].timestamp.endsWith('Z') ? zoomedRawData[0].timestamp : zoomedRawData[0].timestamp + 'Z')).getTime();
590+
const last = new Date((zoomedRawData[zoomedRawData.length - 1].timestamp.endsWith('Z') ? zoomedRawData[zoomedRawData.length - 1].timestamp : zoomedRawData[zoomedRawData.length - 1].timestamp + 'Z')).getTime();
591+
return (last - first) / (1000 * 60 * 60);
592+
}, [isZoomed, zoomedRawData]);
593+
554594
const formatXAxis = (timestamp: string) => {
555595
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
556596
// Ensure timestamp is parsed as UTC (API returns UTC without 'Z' suffix)
557597
const utcTimestamp = timestamp.endsWith('Z') ? timestamp : timestamp + 'Z';
558-
if (timeRange.hours <= 6) {
559-
return formatInTimeZone(new Date(utcTimestamp), tz, 'HH:mm');
560-
} else if (timeRange.hours <= 24) {
598+
const effectiveHours = zoomedDurationHours ?? timeRange.hours;
599+
if (effectiveHours <= 1) {
600+
return formatInTimeZone(new Date(utcTimestamp), tz, 'HH:mm:ss');
601+
} else if (effectiveHours <= 24) {
561602
return formatInTimeZone(new Date(utcTimestamp), tz, 'HH:mm');
562603
} else {
563604
return formatInTimeZone(new Date(utcTimestamp), tz, 'MM/dd HH:mm');
@@ -679,6 +720,19 @@ export const BandwidthChart: React.FC<BandwidthChartProps> = ({
679720
<ArrowUpDown className="h-4 w-4" aria-hidden="true" />
680721
{flipped ? 'UL on Top' : 'DL on Top'}
681722
</Button>
723+
{isZoomed && (
724+
<Button
725+
variant="outline"
726+
size="sm"
727+
onClick={resetZoom}
728+
className="gap-2"
729+
title="Reset zoom to full time range"
730+
aria-label="Reset zoom"
731+
>
732+
<ZoomOut className="h-4 w-4" aria-hidden="true" />
733+
Reset Zoom
734+
</Button>
735+
)}
682736
</div>
683737
</div>
684738
</CardHeader>
@@ -706,11 +760,17 @@ export const BandwidthChart: React.FC<BandwidthChartProps> = ({
706760
<AlertDescription>All metrics are hidden. Click on a legend item below to show data.</AlertDescription>
707761
</Alert>
708762
)}
763+
<div style={{ touchAction: isSelecting ? 'none' : 'pan-y', userSelect: 'none', WebkitUserSelect: 'none' }}>
709764
<ResponsiveContainer width="100%" height={700}>
710765
<ComposedChart
711766
key={`chart-${flipped}`}
712767
data={data}
713768
margin={{ top: 10, right: 10, left: 10, bottom: 20 }}
769+
onMouseDown={zoomMouseDown}
770+
onMouseMove={zoomMouseMove}
771+
onMouseUp={zoomMouseUp}
772+
onDoubleClick={zoomDoubleClick}
773+
style={{ cursor: isSelecting ? 'col-resize' : 'crosshair' }}
714774
>
715775
<defs key={`chart-defs-${flipped}`}>
716776
{/* Download gradients - only for enabled clients */}
@@ -792,6 +852,7 @@ export const BandwidthChart: React.FC<BandwidthChartProps> = ({
792852
allowDataOverflow={!!domainExtent}
793853
/>
794854
<Tooltip
855+
active={isSelecting ? false : undefined}
795856
formatter={formatTooltip}
796857
labelFormatter={(label) => {
797858
const utcLabel = String(label).endsWith('Z') ? label : label + 'Z';
@@ -817,6 +878,17 @@ export const BandwidthChart: React.FC<BandwidthChartProps> = ({
817878
stroke="#999"
818879
strokeWidth={2}
819880
/>
881+
{isSelecting && selectionStart !== null && selectionEnd !== null && (
882+
<ReferenceArea
883+
yAxisId="left"
884+
x1={new Date(Math.min(selectionStart, selectionEnd)).toISOString().replace('Z', '')}
885+
x2={new Date(Math.max(selectionStart, selectionEnd)).toISOString().replace('Z', '')}
886+
fill="#3b82f6"
887+
fillOpacity={0.15}
888+
stroke="#3b82f6"
889+
strokeOpacity={0.4}
890+
/>
891+
)}
820892
{/* Per-client download limit lines - only show for enabled clients */}
821893
{isClientEnabled('qbittorrent') && (
822894
<Line
@@ -1050,6 +1122,7 @@ export const BandwidthChart: React.FC<BandwidthChartProps> = ({
10501122
})}
10511123
</ComposedChart>
10521124
</ResponsiveContainer>
1125+
</div>
10531126
</>
10541127
)}
10551128
</CardContent>

frontend/src/components/StreamCountChart.tsx

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useEffect } from 'react';
1+
import React, { useState, useEffect, useMemo } from 'react';
22
import {
33
LineChart,
44
Line,
@@ -15,13 +15,15 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
1515
import { Alert, AlertDescription } from '@/components/ui/alert';
1616
import { Loader2, AlertCircle } from 'lucide-react';
1717
import type { TimeRange, DataInterval } from './BandwidthChart';
18+
import { filterDataByZoomRange, type ZoomRange } from '@/hooks/useChartZoom';
1819

1920
interface StreamCountChartProps {
2021
timeRange: TimeRange;
2122
dataInterval: DataInterval;
23+
zoomRange?: ZoomRange | null;
2224
}
2325

24-
export const StreamCountChart: React.FC<StreamCountChartProps> = ({ timeRange, dataInterval }) => {
26+
export const StreamCountChart: React.FC<StreamCountChartProps> = ({ timeRange, dataInterval, zoomRange }) => {
2527
const [rawData, setRawData] = useState<ChartDataPoint[]>([]);
2628
const [data, setData] = useState<any[]>([]);
2729
const [isInitialLoad, setIsInitialLoad] = useState(true);
@@ -73,37 +75,49 @@ export const StreamCountChart: React.FC<StreamCountChartProps> = ({ timeRange, d
7375
}
7476
};
7577

76-
// Process data when interval changes or raw data updates
78+
// Apply zoom filter before processing
79+
const zoomedRawData = useMemo(() => filterDataByZoomRange(rawData, zoomRange ?? null), [rawData, zoomRange]);
80+
81+
// Process data when interval changes or zoomed data updates
7782
useEffect(() => {
78-
if (rawData.length === 0) {
83+
if (zoomedRawData.length === 0) {
7984
setData([]);
8085
return;
8186
}
8287

8388
// Aggregate or use raw data based on interval
8489
const processedData = dataInterval === 'raw'
85-
? rawData.map(point => ({
90+
? zoomedRawData.map(point => ({
8691
timestamp: point.timestamp,
8792
stream_count: point.active_streams_count || 0,
8893
}))
89-
: aggregateData(rawData, dataInterval);
94+
: aggregateData(zoomedRawData, dataInterval);
9095

9196
setData(processedData);
92-
}, [rawData, dataInterval]);
97+
}, [zoomedRawData, dataInterval]);
9398

9499
useEffect(() => {
95100
fetchData();
96101
const interval = setInterval(fetchData, 30000); // Refresh every 30s
97102
return () => clearInterval(interval);
98103
}, [timeRange.hours]);
99104

105+
// Calculate zoomed duration for XAxis formatting
106+
const zoomedDurationHours = useMemo(() => {
107+
if (!zoomRange || zoomedRawData.length < 2) return null;
108+
const first = new Date((zoomedRawData[0].timestamp.endsWith('Z') ? zoomedRawData[0].timestamp : zoomedRawData[0].timestamp + 'Z')).getTime();
109+
const last = new Date((zoomedRawData[zoomedRawData.length - 1].timestamp.endsWith('Z') ? zoomedRawData[zoomedRawData.length - 1].timestamp : zoomedRawData[zoomedRawData.length - 1].timestamp + 'Z')).getTime();
110+
return (last - first) / (1000 * 60 * 60);
111+
}, [zoomRange, zoomedRawData]);
112+
100113
const formatXAxis = (timestamp: string) => {
101114
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
102115
// Ensure timestamp is parsed as UTC (API returns UTC without 'Z' suffix)
103116
const utcTimestamp = timestamp.endsWith('Z') ? timestamp : timestamp + 'Z';
104-
if (timeRange.hours <= 6) {
105-
return formatInTimeZone(new Date(utcTimestamp), tz, 'HH:mm');
106-
} else if (timeRange.hours <= 24) {
117+
const effectiveHours = zoomedDurationHours ?? timeRange.hours;
118+
if (effectiveHours <= 1) {
119+
return formatInTimeZone(new Date(utcTimestamp), tz, 'HH:mm:ss');
120+
} else if (effectiveHours <= 24) {
107121
return formatInTimeZone(new Date(utcTimestamp), tz, 'HH:mm');
108122
} else {
109123
return formatInTimeZone(new Date(utcTimestamp), tz, 'MM/dd HH:mm');

0 commit comments

Comments
 (0)