99 Legend ,
1010 ResponsiveContainer ,
1111 ReferenceLine ,
12+ ReferenceArea ,
1213 Line ,
1314} from 'recharts' ;
1415import { formatInTimeZone } from 'date-fns-tz' ;
@@ -24,7 +25,8 @@ import {
2425} from '@/components/ui/select' ;
2526import { Alert , AlertDescription } from '@/components/ui/alert' ;
2627import { 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
3032const 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 >
0 commit comments