1- import { useEffect , useState , useMemo } from 'react'
1+ import { useEffect , useState , useMemo , useCallback } from 'react'
22import { Bar , BarChart , CartesianGrid , XAxis , YAxis } from 'recharts'
33import { DateRange } from 'react-day-picker'
44import { Card , CardContent , CardDescription , CardHeader , CardTitle } from '@/components/ui/card'
5- import { type ChartConfig , ChartContainer , ChartTooltip , ChartLegend , ChartLegendContent } from '@/components/ui/chart'
5+ import { type ChartConfig , ChartContainer , ChartTooltip } from '@/components/ui/chart'
66import { useTranslation } from 'react-i18next'
77import useDirDetection from '@/hooks/use-dir-detection'
88import { getUsage , Period , type NodeUsageStat } from '@/service/api'
@@ -159,57 +159,44 @@ export function AllNodesStackedBarChart() {
159159 // Build color palette for nodes
160160 const nodeList : NodeResponse [ ] = useMemo ( ( ) => ( Array . isArray ( nodesData ) ? nodesData : [ ] ) , [ nodesData ] )
161161
162- // Helper function to extract hue from HSL string
163- const extractHue = ( hslString : string ) : number => {
164- const match = hslString . match ( / h s l \( ( [ ^ , ] + ) / )
165- if ( match ) {
166- const hue = parseFloat ( match [ 1 ] )
167- return isNaN ( hue ) ? 0 : hue
168- }
169- return 0
170- }
171162
172163 // Function to generate distinct colors based on theme
173- const generateDistinctColor = ( index : number , _totalNodes : number , isDark : boolean ) : string => {
174- // Get current theme colors for reference
175- const root = document . documentElement
176- const computedStyle = getComputedStyle ( root )
177-
178- // Extract base colors from current theme
179- const primaryHue = extractHue ( computedStyle . getPropertyValue ( '--primary' ) )
180- const chart1Hue = extractHue ( computedStyle . getPropertyValue ( '--chart-1' ) )
181- const chart2Hue = extractHue ( computedStyle . getPropertyValue ( '--chart-2' ) )
182-
183- // Create a palette of distinct hues
184- const baseHues = [ primaryHue , chart1Hue , chart2Hue ]
185- const additionalHues = [
186- ( primaryHue + 60 ) % 360 , // Complementary
187- ( primaryHue + 120 ) % 360 , // Triadic
188- ( primaryHue + 180 ) % 360 , // Opposite
189- ( primaryHue + 240 ) % 360 , // Triadic
190- ( primaryHue + 300 ) % 360 , // Triadic
191- ( chart1Hue + 60 ) % 360 ,
192- ( chart1Hue + 120 ) % 360 ,
193- ( chart2Hue + 60 ) % 360 ,
194- ( chart2Hue + 120 ) % 360 ,
195- ( primaryHue + 30 ) % 360 , // Analogous
196- ( primaryHue + 90 ) % 360 , // Split complementary
197- ( primaryHue + 150 ) % 360 ,
198- ( primaryHue + 210 ) % 360 ,
199- ( primaryHue + 270 ) % 360 ,
200- ( primaryHue + 330 ) % 360 ,
164+ const generateDistinctColor = useCallback ( ( index : number , _totalNodes : number , isDark : boolean ) : string => {
165+ // Define a more distinct color palette with better contrast
166+ const distinctHues = [
167+ 0 , // Red
168+ 30 , // Orange
169+ 60 , // Yellow
170+ 120 , // Green
171+ 180 , // Cyan
172+ 210 , // Blue
173+ 240 , // Indigo
174+ 270 , // Purple
175+ 300 , // Magenta
176+ 330 , // Pink
177+ 15 , // Red-orange
178+ 45 , // Yellow-orange
179+ 75 , // Yellow-green
180+ 150 , // Green-cyan
181+ 200 , // Cyan-blue
182+ 225 , // Blue-indigo
183+ 255 , // Indigo-purple
184+ 285 , // Purple-magenta
185+ 315 , // Magenta-pink
186+ 345 , // Pink-red
201187 ]
202188
203- const allHues = [ ...baseHues , ...additionalHues ]
204- const hue = allHues [ index % allHues . length ]
189+ const hue = distinctHues [ index % distinctHues . length ]
190+
191+ // Create more distinct saturation and lightness values
192+ const saturationVariations = [ 65 , 75 , 85 , 70 , 80 , 60 , 90 , 55 , 95 , 50 ]
193+ const lightnessVariations = isDark ? [ 45 , 55 , 35 , 50 , 40 , 60 , 30 , 65 , 25 , 70 ] : [ 40 , 50 , 30 , 45 , 35 , 55 , 25 , 60 , 20 , 65 ]
205194
206- // For dark theme: higher saturation, moderate lightness
207- // For light theme: moderate saturation, lower lightness
208- const saturation = isDark ? 70 + ( index % 20 ) : 60 + ( index % 25 )
209- const lightness = isDark ? 50 + ( index % 15 ) : 45 + ( index % 20 )
195+ const saturation = saturationVariations [ index % saturationVariations . length ]
196+ const lightness = lightnessVariations [ index % lightnessVariations . length ]
210197
211198 return `hsl(${ hue } , ${ saturation } %, ${ lightness } %)`
212- }
199+ } , [ ] )
213200
214201 // Build chart config dynamically based on nodes
215202 const chartConfig = useMemo ( ( ) => {
@@ -233,33 +220,12 @@ export function AllNodesStackedBarChart() {
233220 }
234221 } )
235222 return config
236- } , [ nodeList , resolvedTheme ] )
237-
238- // Chart configuration for the container
239- const chartContainerConfig = useMemo ( ( ) => {
240- const config : ChartConfig = { }
241- const isDark = resolvedTheme === 'dark'
242- nodeList . forEach ( ( node , idx ) => {
243- let color
244- if ( idx === 0 ) {
245- // First node uses primary color like CostumeBarChart
246- color = 'hsl(var(--primary))'
247- } else if ( idx < 5 ) {
248- // Use palette colors for nodes 2-5: --chart-2, --chart-3, ...
249- color = `hsl(var(--chart-${ idx + 1 } ))`
250- } else {
251- // Generate distinct colors for nodes beyond palette
252- color = generateDistinctColor ( idx , nodeList . length , isDark )
253- }
254- config [ node . name ] = {
255- label : node . name ,
256- color : color ,
257- }
258- } )
259- return config
260- } , [ nodeList , resolvedTheme ] )
223+ } , [ nodeList , resolvedTheme , generateDistinctColor ] )
261224
262225 useEffect ( ( ) => {
226+ let isCancelled = false
227+ let timeoutId : NodeJS . Timeout
228+
263229 const fetchUsageData = async ( ) => {
264230 if ( ! dateRange ?. from || ! dateRange ?. to ) {
265231 setChartData ( null )
@@ -273,12 +239,8 @@ export function AllNodesStackedBarChart() {
273239 const endDate = dateRange . to
274240 const period = getPeriodFromDateRange ( dateRange )
275241
276- // Check if end date is today
277- const today = dateUtils . toDayjs ( new Date ( ) )
278- const isEndDateToday = dateUtils . toDayjs ( endDate ) . isSame ( today , 'day' )
279-
280- // Use current time if end date is today, otherwise use end of day
281- const endTime = isEndDateToday ? new Date ( ) . toISOString ( ) : dateUtils . toDayjs ( endDate ) . endOf ( 'day' ) . toISOString ( )
242+ // Always use end of day for daily period to avoid extra bars
243+ const endTime = period === Period . day ? dateUtils . toDayjs ( endDate ) . endOf ( 'day' ) . toISOString ( ) : new Date ( ) . toISOString ( )
282244
283245 const params : Parameters < typeof getUsage > [ 0 ] = {
284246 period : period ,
@@ -287,6 +249,9 @@ export function AllNodesStackedBarChart() {
287249 group_by_node : true ,
288250 }
289251 const response = await getUsage ( params )
252+
253+ // Check if component is still mounted
254+ if ( isCancelled ) return
290255
291256 // API response and nodes list logged
292257
@@ -348,12 +313,14 @@ export function AllNodesStackedBarChart() {
348313 } )
349314
350315 // Final chart data (aggregated) processed
351- setChartData ( data )
352-
353- // Calculate total usage
354- const total = aggregatedStats . reduce ( ( sum : number , point : NodeUsageStat ) => sum + point . uplink + point . downlink , 0 )
355- const formattedTotal = formatBytes ( total , 2 )
356- if ( typeof formattedTotal === 'string' ) setTotalUsage ( formattedTotal )
316+ if ( ! isCancelled ) {
317+ setChartData ( data )
318+
319+ // Calculate total usage
320+ const total = aggregatedStats . reduce ( ( sum : number , point : NodeUsageStat ) => sum + point . uplink + point . downlink , 0 )
321+ const formattedTotal = formatBytes ( total , 2 )
322+ if ( typeof formattedTotal === 'string' ) setTotalUsage ( formattedTotal )
323+ }
357324 } else {
358325 setChartData ( null )
359326 setTotalUsage ( '0' )
@@ -408,13 +375,15 @@ export function AllNodesStackedBarChart() {
408375 } )
409376
410377 // Final chart data processed
411- setChartData ( data )
412-
413- // Calculate total usage
414- let total = 0
415- Object . values ( statsByNode ) . forEach ( ( arr ) => arr . forEach ( ( stat ) => { total += stat . uplink + stat . downlink } ) )
416- const formattedTotal = formatBytes ( total , 2 )
417- if ( typeof formattedTotal === 'string' ) setTotalUsage ( formattedTotal )
378+ if ( ! isCancelled ) {
379+ setChartData ( data )
380+
381+ // Calculate total usage
382+ let total = 0
383+ Object . values ( statsByNode ) . forEach ( ( arr ) => arr . forEach ( ( stat ) => { total += stat . uplink + stat . downlink } ) )
384+ const formattedTotal = formatBytes ( total , 2 )
385+ if ( typeof formattedTotal === 'string' ) setTotalUsage ( formattedTotal )
386+ }
418387 } else {
419388 // No periods found, setting empty data
420389 setChartData ( null )
@@ -427,10 +396,23 @@ export function AllNodesStackedBarChart() {
427396 setTotalUsage ( '0' )
428397 console . error ( 'Error fetching usage data:' , err )
429398 } finally {
430- setIsLoading ( false )
399+ if ( ! isCancelled ) {
400+ setIsLoading ( false )
401+ }
402+ }
403+ }
404+
405+ // Debounce the API call to prevent excessive requests during zoom
406+ timeoutId = setTimeout ( ( ) => {
407+ fetchUsageData ( )
408+ } , 300 )
409+
410+ return ( ) => {
411+ isCancelled = true
412+ if ( timeoutId ) {
413+ clearTimeout ( timeoutId )
431414 }
432415 }
433- fetchUsageData ( )
434416 } , [ dateRange , nodeList ] )
435417
436418 // Add effect to update dateRange when selectedTime changes
@@ -443,11 +425,15 @@ export function AllNodesStackedBarChart() {
443425 } else if ( selectedTime === '24h' ) {
444426 from = new Date ( now . getTime ( ) - 24 * 60 * 60 * 1000 )
445427 } else if ( selectedTime === '3d' ) {
446- from = new Date ( now . getTime ( ) - 3 * 24 * 60 * 60 * 1000 )
428+ from = new Date ( now . getTime ( ) - 2 * 24 * 60 * 60 * 1000 )
447429 } else if ( selectedTime === '1w' ) {
448- from = new Date ( now . getTime ( ) - 7 * 24 * 60 * 60 * 1000 )
430+ from = new Date ( now . getTime ( ) - 6 * 24 * 60 * 60 * 1000 )
431+ }
432+ if ( from ) {
433+ // For 1w and 3d, set to end of current day to avoid extra bar
434+ const to = ( selectedTime === '1w' || selectedTime === '3d' ) ? dateUtils . toDayjs ( now ) . endOf ( 'day' ) . toDate ( ) : now
435+ setDateRange ( { from, to } )
449436 }
450- if ( from ) setDateRange ( { from, to : now } )
451437 }
452438 } , [ selectedTime , showCustomRange ] )
453439
@@ -493,61 +479,83 @@ export function AllNodesStackedBarChart() {
493479 className = "max-h-[400px] min-h-[200px]"
494480 />
495481 ) : (
496- < ChartContainer
497- dir = { 'ltr' }
498- config = { chartContainerConfig }
499- className = "max-h-[400px] min-h-[200px] w-full overflow-x-auto"
500- >
501- { chartData && chartData . length > 0 ? (
502- < BarChart
503- accessibilityLayer
504- data = { chartData }
505- margin = { { top : 5 , right : 10 , left : 10 , bottom : 5 } }
506- >
507- < CartesianGrid direction = { 'ltr' } vertical = { false } />
508- < XAxis
509- direction = { 'ltr' }
510- dataKey = "time"
511- tickLine = { false }
512- tickMargin = { 10 }
513- axisLine = { false }
514- minTickGap = { 5 }
515- />
516- < YAxis
517- direction = { 'ltr' }
518- tickLine = { false }
519- axisLine = { false }
520- tickFormatter = { value => `${ value . toFixed ( 2 ) } GB` }
521- tick = { {
522- fill : 'hsl(var(--muted-foreground))' ,
523- fontSize : 9 ,
524- fontWeight : 500 ,
525- } }
526- width = { 32 }
527- tickMargin = { 2 }
528- />
529- { /* When using ChartTooltip, pass period as a prop */ }
530- < ChartTooltip cursor = { false } content = { < CustomTooltip chartConfig = { chartConfig } dir = { dir } period = { getPeriodFromDateRange ( dateRange ) } /> } />
531- < ChartLegend className = { "overflow-x-auto justify-evenly" } content = { < ChartLegendContent /> } />
532- { nodeList . map ( ( node , idx ) => (
533- < Bar
534- key = { node . id }
535- dataKey = { node . name }
536- stackId = "a"
537- fill = { chartConfig [ node . name ] ?. color || `hsl(var(--chart-${ ( idx % 5 ) + 1 } ))` }
538- radius = { idx === 0 ? [ 0 , 0 , 4 , 4 ] : idx === nodeList . length - 1 ? [ 4 , 4 , 0 , 0 ] : [ 0 , 0 , 0 , 0 ] }
482+ < div className = "w-full max-w-7xl mx-auto" >
483+ < ChartContainer
484+ dir = { 'ltr' }
485+ config = { chartConfig }
486+ className = "max-h-[400px] min-h-[200px] w-full"
487+ >
488+ { chartData && chartData . length > 0 ? (
489+ < BarChart
490+ accessibilityLayer
491+ data = { chartData }
492+ margin = { { top : 5 , right : 10 , left : 10 , bottom : 5 } }
493+ >
494+ < CartesianGrid direction = { 'ltr' } vertical = { false } />
495+ < XAxis
496+ direction = { 'ltr' }
497+ dataKey = "time"
498+ tickLine = { false }
499+ tickMargin = { 10 }
500+ axisLine = { false }
501+ minTickGap = { 5 }
539502 />
540- ) ) }
541- </ BarChart >
542- ) : (
543- < EmptyState
544- type = "no-data"
545- title = { t ( 'statistics.noDataInRange' ) }
546- description = { t ( 'statistics.noDataInRangeDescription' ) }
547- className = "max-h-[400px] min-h-[200px]"
548- />
503+ < YAxis
504+ direction = { 'ltr' }
505+ tickLine = { false }
506+ axisLine = { false }
507+ tickFormatter = { value => `${ value . toFixed ( 2 ) } GB` }
508+ tick = { {
509+ fill : 'hsl(var(--muted-foreground))' ,
510+ fontSize : 9 ,
511+ fontWeight : 500 ,
512+ } }
513+ width = { 32 }
514+ tickMargin = { 2 }
515+ />
516+ { /* When using ChartTooltip, pass period as a prop */ }
517+ < ChartTooltip cursor = { false } content = { < CustomTooltip chartConfig = { chartConfig } dir = { dir } period = { getPeriodFromDateRange ( dateRange ) } /> } />
518+ { nodeList . map ( ( node , idx ) => (
519+ < Bar
520+ key = { node . id }
521+ dataKey = { node . name }
522+ stackId = "a"
523+ fill = { chartConfig [ node . name ] ?. color || `hsl(var(--chart-${ ( idx % 5 ) + 1 } ))` }
524+ radius = { nodeList . length === 1 ? [ 4 , 4 , 4 , 4 ] : idx === 0 ? [ 0 , 0 , 4 , 4 ] : idx === nodeList . length - 1 ? [ 4 , 4 , 0 , 0 ] : [ 0 , 0 , 0 , 0 ] }
525+ />
526+ ) ) }
527+ </ BarChart >
528+ ) : (
529+ < EmptyState
530+ type = "no-data"
531+ title = { t ( 'statistics.noDataInRange' ) }
532+ description = { t ( 'statistics.noDataInRangeDescription' ) }
533+ className = "max-h-[400px] min-h-[200px]"
534+ />
535+ ) }
536+ </ ChartContainer >
537+ { /* Separate scrollable legend */ }
538+ { chartData && chartData . length > 0 && (
539+ < div className = "overflow-x-auto pt-3" >
540+ < div className = "flex items-center justify-center gap-4 min-w-max" >
541+ { nodeList . map ( ( node ) => {
542+ const itemConfig = chartConfig [ node . name ]
543+ return (
544+ < div key = { node . id } className = "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground" >
545+ < div
546+ className = "h-2 w-2 shrink-0 rounded-[2px]"
547+ style = { {
548+ backgroundColor : itemConfig ?. color || 'hsl(var(--chart-1))' ,
549+ } }
550+ />
551+ < span className = "whitespace-nowrap text-xs" > { node . name } </ span >
552+ </ div >
553+ )
554+ } ) }
555+ </ div >
556+ </ div >
549557 ) }
550- </ ChartContainer >
558+ </ div >
551559 ) }
552560 </ CardContent >
553561 </ Card >
0 commit comments