BarChart.tsx233 lines · main
| 1 | import { ComponentProps, useMemo, useState } from 'react' |
| 2 | import { |
| 3 | Bar, |
| 4 | CartesianGrid, |
| 5 | Cell, |
| 6 | Legend, |
| 7 | BarChart as RechartBarChart, |
| 8 | Tooltip, |
| 9 | XAxis, |
| 10 | YAxis, |
| 11 | } from 'recharts' |
| 12 | import type { CategoricalChartState } from 'recharts/types/chart/types' |
| 13 | |
| 14 | import { ChartHeader } from './ChartHeader' |
| 15 | import type { CommonChartProps, Datum } from './Charts.types' |
| 16 | import { numberFormatter, useChartSize } from './Charts.utils' |
| 17 | import NoDataPlaceholder from './NoDataPlaceholder' |
| 18 | import { useChartHoverState } from './useChartHoverState' |
| 19 | import { CHART_COLORS, DateTimeFormats } from '@/components/ui/Charts/Charts.constants' |
| 20 | import { formatDateTime, useFormatDateTime } from '@/lib/datetime' |
| 21 | |
| 22 | export interface BarChartProps<D = Datum> extends CommonChartProps<D> { |
| 23 | yAxisKey: string |
| 24 | xAxisKey: string |
| 25 | customDateFormat?: string |
| 26 | displayDateInUtc?: boolean |
| 27 | onBarClick?: (datum: D, tooltipData?: CategoricalChartState) => void |
| 28 | emptyStateMessage?: string |
| 29 | showLegend?: boolean |
| 30 | xAxisIsDate?: boolean |
| 31 | XAxisProps?: ComponentProps<typeof XAxis> |
| 32 | YAxisProps?: ComponentProps<typeof YAxis> |
| 33 | showGrid?: boolean |
| 34 | syncId?: string |
| 35 | } |
| 36 | |
| 37 | function BarChart<D extends Datum = Datum>({ |
| 38 | data, |
| 39 | yAxisKey, |
| 40 | xAxisKey, |
| 41 | format, |
| 42 | customDateFormat = DateTimeFormats.FULL, |
| 43 | title, |
| 44 | highlightedValue, |
| 45 | highlightedLabel, |
| 46 | displayDateInUtc, |
| 47 | minimalHeader, |
| 48 | valuePrecision, |
| 49 | className = '', |
| 50 | size = 'normal', |
| 51 | emptyStateMessage, |
| 52 | onBarClick, |
| 53 | showLegend = false, |
| 54 | xAxisIsDate = true, |
| 55 | XAxisProps, |
| 56 | YAxisProps, |
| 57 | showGrid = false, |
| 58 | syncId, |
| 59 | }: BarChartProps<D>) { |
| 60 | const { hoveredIndex, isHovered, isCurrentChart, setHover, clearHover } = |
| 61 | useChartHoverState('default') |
| 62 | const { Container } = useChartSize(size) |
| 63 | const [focusDataIndex, setFocusDataIndex] = useState<number | null>(null) |
| 64 | |
| 65 | // Transform data to ensure yAxisKey values are numbers |
| 66 | const transformedData = useMemo(() => { |
| 67 | return data.map((item) => ({ |
| 68 | ...item, |
| 69 | [yAxisKey]: typeof item[yAxisKey] === 'string' ? Number(item[yAxisKey]) : item[yAxisKey], |
| 70 | })) |
| 71 | }, [data, yAxisKey]) |
| 72 | |
| 73 | // Default props |
| 74 | const _XAxisProps = XAxisProps || { |
| 75 | interval: data.length - 2, |
| 76 | angle: 0, |
| 77 | tick: false, |
| 78 | } |
| 79 | |
| 80 | const _YAxisProps = YAxisProps || { |
| 81 | tickFormatter: (value) => numberFormatter(value, valuePrecision), |
| 82 | tick: false, |
| 83 | width: 0, |
| 84 | } |
| 85 | |
| 86 | // When `displayDateInUtc` is set the chart explicitly wants UTC labels. |
| 87 | // Otherwise honour the user's selected timezone via the picker, which |
| 88 | // `useFormatDateTime` reads from context. |
| 89 | const formatPickerDate = useFormatDateTime() |
| 90 | const formatChartDate = (value: number | string) => |
| 91 | displayDateInUtc |
| 92 | ? formatDateTime(value, { tz: 'UTC', format: customDateFormat }) |
| 93 | : formatPickerDate(value, customDateFormat) |
| 94 | |
| 95 | function getHeaderLabel() { |
| 96 | if (!xAxisIsDate) { |
| 97 | if (!focusDataIndex) return highlightedLabel |
| 98 | return data[focusDataIndex]?.[xAxisKey] |
| 99 | } |
| 100 | return ( |
| 101 | (focusDataIndex !== null && |
| 102 | data && |
| 103 | data[focusDataIndex] !== undefined && |
| 104 | formatChartDate(data[focusDataIndex][xAxisKey] as number | string)) || |
| 105 | highlightedLabel |
| 106 | ) |
| 107 | } |
| 108 | |
| 109 | const resolvedHighlightedLabel = getHeaderLabel() |
| 110 | |
| 111 | const resolvedHighlightedValue = |
| 112 | focusDataIndex !== null ? data[focusDataIndex]?.[yAxisKey] : highlightedValue |
| 113 | |
| 114 | if (data.length === 0) { |
| 115 | return ( |
| 116 | <NoDataPlaceholder |
| 117 | message={emptyStateMessage} |
| 118 | description="It may take up to 24 hours for data to refresh" |
| 119 | size={size} |
| 120 | className={className} |
| 121 | attribute={title} |
| 122 | format={format} |
| 123 | /> |
| 124 | ) |
| 125 | } |
| 126 | |
| 127 | return ( |
| 128 | <div className={['flex flex-col gap-y-3', className].join(' ')}> |
| 129 | <ChartHeader |
| 130 | title={title} |
| 131 | format={format} |
| 132 | customDateFormat={customDateFormat} |
| 133 | highlightedValue={resolvedHighlightedValue} |
| 134 | highlightedLabel={resolvedHighlightedLabel} |
| 135 | minimalHeader={minimalHeader} |
| 136 | syncId={syncId} |
| 137 | data={data} |
| 138 | xAxisKey={xAxisKey} |
| 139 | yAxisKey={yAxisKey} |
| 140 | xAxisIsDate={xAxisIsDate} |
| 141 | displayDateInUtc={displayDateInUtc} |
| 142 | valuePrecision={valuePrecision} |
| 143 | attributes={[]} |
| 144 | /> |
| 145 | <Container> |
| 146 | <RechartBarChart |
| 147 | data={transformedData} |
| 148 | className="overflow-visible" |
| 149 | onMouseMove={(e: any) => { |
| 150 | if (e.activeTooltipIndex !== focusDataIndex) { |
| 151 | setFocusDataIndex(e.activeTooltipIndex) |
| 152 | } |
| 153 | |
| 154 | setHover(e.activeTooltipIndex) |
| 155 | }} |
| 156 | onMouseLeave={() => { |
| 157 | setFocusDataIndex(null) |
| 158 | |
| 159 | clearHover() |
| 160 | }} |
| 161 | onClick={(tooltipData) => { |
| 162 | const datum = tooltipData?.activePayload?.[0]?.payload |
| 163 | if (onBarClick) onBarClick(datum, tooltipData) |
| 164 | }} |
| 165 | > |
| 166 | {showLegend && <Legend />} |
| 167 | {showGrid && <CartesianGrid stroke={CHART_COLORS.AXIS} />} |
| 168 | <YAxis |
| 169 | {..._YAxisProps} |
| 170 | axisLine={{ stroke: CHART_COLORS.AXIS }} |
| 171 | tickLine={{ stroke: CHART_COLORS.AXIS }} |
| 172 | key={yAxisKey} |
| 173 | /> |
| 174 | <XAxis |
| 175 | {..._XAxisProps} |
| 176 | axisLine={{ stroke: CHART_COLORS.AXIS }} |
| 177 | tickLine={{ stroke: CHART_COLORS.AXIS }} |
| 178 | key={xAxisKey} |
| 179 | /> |
| 180 | <Tooltip |
| 181 | content={(_props) => |
| 182 | syncId && isHovered && isCurrentChart && hoveredIndex !== null ? ( |
| 183 | <div className="bg-black/90 text-white p-2 rounded-sm text-xs"> |
| 184 | <div className="font-medium"> |
| 185 | {formatChartDate(data[hoveredIndex]?.[xAxisKey] as number | string)} |
| 186 | </div> |
| 187 | <div> |
| 188 | {numberFormatter(Number(data[hoveredIndex]?.[yAxisKey]) || 0, valuePrecision)} |
| 189 | {typeof format === 'string' ? format : ''} |
| 190 | </div> |
| 191 | </div> |
| 192 | ) : null |
| 193 | } |
| 194 | /> |
| 195 | <Bar |
| 196 | dataKey={yAxisKey} |
| 197 | fill={CHART_COLORS.GREEN_1} |
| 198 | animationDuration={300} |
| 199 | maxBarSize={48} |
| 200 | > |
| 201 | {data?.map((_entry: D, index: number) => ( |
| 202 | <Cell |
| 203 | key={`cell-${index}`} |
| 204 | className={`transition-all duration-300 ${onBarClick ? 'cursor-pointer' : ''}`} |
| 205 | fill={ |
| 206 | focusDataIndex === index || focusDataIndex === null |
| 207 | ? CHART_COLORS.GREEN_1 |
| 208 | : CHART_COLORS.GREEN_2 |
| 209 | } |
| 210 | enableBackground={12} |
| 211 | /> |
| 212 | ))} |
| 213 | </Bar> |
| 214 | </RechartBarChart> |
| 215 | </Container> |
| 216 | {data && ( |
| 217 | <div className="text-foreground-lighter -mt-10 flex items-center justify-between text-[10px] font-mono"> |
| 218 | <span> |
| 219 | {xAxisIsDate |
| 220 | ? formatChartDate(data[0][xAxisKey] as number | string) |
| 221 | : data[0][xAxisKey]} |
| 222 | </span> |
| 223 | <span> |
| 224 | {xAxisIsDate |
| 225 | ? formatChartDate(data[data?.length - 1]?.[xAxisKey] as number | string) |
| 226 | : data[data?.length - 1]?.[xAxisKey]} |
| 227 | </span> |
| 228 | </div> |
| 229 | )} |
| 230 | </div> |
| 231 | ) |
| 232 | } |
| 233 | export default BarChart |