StackedBarChart.tsx221 lines · main
1import { useState } from 'react'
2import { Bar, BarChart, Cell, Legend, Tooltip, XAxis } from 'recharts'
3
4import { ChartHeader } from './ChartHeader'
5import {
6 CHART_COLORS,
7 DateTimeFormats,
8 DEFAULT_STACK_COLORS,
9 genStackColorScales,
10 ValidStackColor,
11} from './Charts.constants'
12import type { CommonChartProps } from './Charts.types'
13import { numberFormatter, precisionFormatter, useChartSize, useStacked } from './Charts.utils'
14import NoDataPlaceholder from './NoDataPlaceholder'
15import { useChartHoverState } from './useChartHoverState'
16import { formatDateTime, useFormatDateTime } from '@/lib/datetime'
17
18interface Props extends CommonChartProps<any> {
19 xAxisKey: string
20 yAxisKey: string
21 stackKey: string
22 onBarClick?: () => void
23 variant?: 'values' | 'percentages'
24 xAxisFormatAsDate?: boolean
25 displayDateInUtc?: boolean
26 hideLegend?: boolean
27 hideHeader?: boolean
28 stackColors?: ValidStackColor[]
29 syncId?: string
30}
31const StackedBarChart: React.FC<Props> = ({
32 size,
33 data,
34 xAxisKey,
35 stackKey,
36 yAxisKey,
37 customDateFormat = DateTimeFormats.FULL,
38 title,
39 highlightedValue,
40 highlightedLabel,
41 format,
42 minimalHeader = false,
43 valuePrecision,
44 onBarClick,
45 variant,
46 xAxisFormatAsDate = true,
47 displayDateInUtc,
48 hideLegend = false,
49 hideHeader = false,
50 stackColors = DEFAULT_STACK_COLORS,
51 syncId,
52}) => {
53 const { Container } = useChartSize(size)
54 const { hoveredIndex, syncTooltip, setHover, clearHover } = useChartHoverState(
55 syncId || 'default'
56 )
57 const { dataKeys, stackedData, percentagesStackedData } = useStacked({
58 data,
59 xAxisKey,
60 stackKey,
61 yAxisKey,
62 variant,
63 })
64 const [focusDataIndex, setFocusDataIndex] = useState<number | null>(null)
65
66 // When `displayDateInUtc` is set the chart explicitly wants UTC labels.
67 // Otherwise honour the user's selected timezone via the picker.
68 const formatPickerDate = useFormatDateTime()
69 const formatChartDate = (value: number | string) =>
70 displayDateInUtc
71 ? formatDateTime(value, { tz: 'UTC', format: customDateFormat })
72 : formatPickerDate(value, customDateFormat)
73
74 const resolvedHighlightedLabel =
75 (focusDataIndex !== null &&
76 data &&
77 data[focusDataIndex] !== undefined &&
78 formatChartDate(data[focusDataIndex][xAxisKey])) ||
79 highlightedLabel
80
81 const resolvedHighlightedValue =
82 focusDataIndex !== null ? data[focusDataIndex]?.[yAxisKey] : highlightedValue
83
84 if (!data || data.length === 0) {
85 return (
86 <NoDataPlaceholder
87 description="It may take up to 24 hours for data to refresh"
88 size={size}
89 attribute={title}
90 format={format}
91 />
92 )
93 }
94
95 const stackColorScales = genStackColorScales(stackColors)
96 return (
97 <div className="w-full">
98 {!hideHeader && (
99 <ChartHeader
100 title={title}
101 format={format}
102 customDateFormat={customDateFormat}
103 minimalHeader={minimalHeader}
104 highlightedValue={
105 typeof resolvedHighlightedValue === 'number'
106 ? numberFormatter(resolvedHighlightedValue, valuePrecision)
107 : resolvedHighlightedValue
108 }
109 highlightedLabel={resolvedHighlightedLabel}
110 syncId={syncId}
111 data={data}
112 xAxisKey={xAxisKey}
113 yAxisKey={yAxisKey}
114 xAxisIsDate={xAxisFormatAsDate}
115 displayDateInUtc={displayDateInUtc}
116 valuePrecision={valuePrecision}
117 attributes={[]}
118 />
119 )}
120 <Container>
121 <BarChart
122 data={variant === 'percentages' ? percentagesStackedData : stackedData}
123 margin={{
124 top: 20,
125 right: 20,
126 left: 20,
127 bottom: 5,
128 }}
129 className="cursor-pointer overflow-visible"
130 // mouse hover focusing logic
131 onMouseMove={(e: any) => {
132 if (e.activeTooltipIndex !== focusDataIndex) {
133 setFocusDataIndex(e.activeTooltipIndex)
134 }
135
136 setHover(e.activeTooltipIndex)
137 }}
138 onMouseLeave={() => {
139 setFocusDataIndex(null)
140
141 clearHover()
142 }}
143 >
144 {!hideLegend && (
145 <Legend
146 wrapperStyle={{ top: -6, fontSize: '0.8rem' }}
147 iconSize={8}
148 iconType="circle"
149 verticalAlign="top"
150 />
151 )}
152 <XAxis
153 dataKey={xAxisKey}
154 interval={data.length - 2}
155 angle={0}
156 tick={false}
157 axisLine={{ stroke: CHART_COLORS.AXIS }}
158 tickLine={{ stroke: CHART_COLORS.AXIS }}
159 />
160 {dataKeys.map((datum, stackIndex) => (
161 <Bar
162 key={stackIndex}
163 dataKey={datum}
164 type="monotone"
165 legendType="circle"
166 fill={stackColorScales[stackIndex].base}
167 stackId={1}
168 animationDuration={300}
169 maxBarSize={48}
170 className={onBarClick ? 'cursor-pointer' : ''}
171 >
172 {stackedData?.map((_entry: unknown, index: any) => (
173 <Cell
174 key={`cell-${index}`}
175 className={`transition-all duration-300`}
176 opacity={focusDataIndex === index ? 0.85 : 1}
177 />
178 ))}
179 </Bar>
180 ))}
181 <Tooltip
182 labelFormatter={
183 xAxisFormatAsDate ? (label) => formatChartDate(label as number | string) : undefined
184 }
185 formatter={(value, name, props) => {
186 const suffix = format || ''
187 if (variant === 'percentages' && percentagesStackedData) {
188 const index = percentagesStackedData.findIndex(
189 (pStack) => pStack === props.payload!
190 )
191 const val = stackedData[index][name]
192 const percentage = precisionFormatter(Number(value) * 100, 1) + '%'
193 return `${percentage} (${val}${suffix})`
194 }
195 return String(value) + suffix
196 }}
197 cursor={false}
198 labelClassName="text-white"
199 contentStyle={{
200 backgroundColor: '#444444',
201 borderColor: '#444444',
202 fontSize: '12px',
203 }}
204 wrapperClassName="bg-gray-600 rounded-sm min-w-md"
205 active={!!syncId && syncTooltip && hoveredIndex !== null}
206 />
207 </BarChart>
208 </Container>
209 {stackedData && stackedData[0] && (
210 <div className="text-foreground-lighter -mt-5 flex items-center justify-between text-xs">
211 <span>{formatChartDate(stackedData[0][xAxisKey] as number | string)}</span>
212 <span>
213 {formatChartDate(stackedData[stackedData?.length - 1][xAxisKey] as number | string)}
214 </span>
215 </div>
216 )}
217 </div>
218 )
219}
220
221export default StackedBarChart