StackedBarChart.tsx221 lines · main
| 1 | import { useState } from 'react' |
| 2 | import { Bar, BarChart, Cell, Legend, Tooltip, XAxis } from 'recharts' |
| 3 | |
| 4 | import { ChartHeader } from './ChartHeader' |
| 5 | import { |
| 6 | CHART_COLORS, |
| 7 | DateTimeFormats, |
| 8 | DEFAULT_STACK_COLORS, |
| 9 | genStackColorScales, |
| 10 | ValidStackColor, |
| 11 | } from './Charts.constants' |
| 12 | import type { CommonChartProps } from './Charts.types' |
| 13 | import { numberFormatter, precisionFormatter, useChartSize, useStacked } from './Charts.utils' |
| 14 | import NoDataPlaceholder from './NoDataPlaceholder' |
| 15 | import { useChartHoverState } from './useChartHoverState' |
| 16 | import { formatDateTime, useFormatDateTime } from '@/lib/datetime' |
| 17 | |
| 18 | interface 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 | } |
| 31 | const 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 | |
| 221 | export default StackedBarChart |