TimelineChart.tsx184 lines · main
| 1 | import { format } from 'date-fns' |
| 2 | import { SearchIcon } from 'lucide-react' |
| 3 | import { useTheme } from 'next-themes' |
| 4 | import { useMemo } from 'react' |
| 5 | import { Bar, BarChart, CartesianGrid, ReferenceArea, XAxis } from 'recharts' |
| 6 | import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, cn } from 'ui' |
| 7 | |
| 8 | import { useDataTable } from './providers/DataTableProvider' |
| 9 | import { |
| 10 | ChartHighlightAction, |
| 11 | ChartHighlightActions, |
| 12 | } from '@/components/ui/Charts/ChartHighlightActions' |
| 13 | import { useChartHighlight } from '@/components/ui/Charts/useChartHighlight' |
| 14 | |
| 15 | export type BaseChartSchema = { timestamp: number; [key: string]: number } |
| 16 | export const description = 'A stacked bar chart' |
| 17 | |
| 18 | interface TimelineChartProps<TChart extends BaseChartSchema> { |
| 19 | className?: string |
| 20 | /** |
| 21 | * The table column id to filter by - needs to be a type of `timerange` (e.g. "date"). |
| 22 | * TBD: if using keyof TData to be closer to the data table props |
| 23 | */ |
| 24 | columnId: string |
| 25 | /** |
| 26 | * Optional override for the column id used when applying the time range filter. |
| 27 | * Defaults to `columnId` if not provided. |
| 28 | */ |
| 29 | filterColumnId?: string |
| 30 | /** |
| 31 | * Same data as of the InfiniteQueryMeta. |
| 32 | */ |
| 33 | data: TChart[] |
| 34 | chartConfig: ChartConfig |
| 35 | } |
| 36 | |
| 37 | export function TimelineChart<TChart extends BaseChartSchema>({ |
| 38 | data, |
| 39 | className, |
| 40 | columnId, |
| 41 | filterColumnId, |
| 42 | chartConfig, |
| 43 | }: TimelineChartProps<TChart>) { |
| 44 | const resolvedFilterColumnId = filterColumnId ?? columnId |
| 45 | const { resolvedTheme } = useTheme() |
| 46 | const isDarkMode = resolvedTheme?.includes('dark') |
| 47 | |
| 48 | const { table } = useDataTable() |
| 49 | const chartHighlight = useChartHighlight() |
| 50 | |
| 51 | const showHighlight = |
| 52 | chartHighlight?.left && chartHighlight?.right && chartHighlight?.left !== chartHighlight?.right |
| 53 | |
| 54 | // REMINDER: date has to be a string for tooltip label to work - don't ask me why |
| 55 | const chart = useMemo( |
| 56 | () => |
| 57 | data.map((item) => ({ |
| 58 | ...item, |
| 59 | [columnId]: new Date(item.timestamp).toString(), |
| 60 | })), |
| 61 | [data] |
| 62 | ) |
| 63 | |
| 64 | const timerange = useMemo(() => { |
| 65 | if (data.length === 0) return { interval: 0, period: undefined } |
| 66 | const first = data[0].timestamp |
| 67 | const last = data[data.length - 1].timestamp |
| 68 | const interval = Math.abs(first - last) // in ms |
| 69 | return { interval, period: calculatePeriod(interval) } |
| 70 | }, [data]) |
| 71 | |
| 72 | const highlightActions: ChartHighlightAction[] = useMemo( |
| 73 | () => [ |
| 74 | { |
| 75 | id: 'zoom-in', |
| 76 | label: 'Filter logs to selected range', |
| 77 | icon: <SearchIcon className="text-foreground-lighter" size={12} />, |
| 78 | onSelect: ({ start, end, clear }) => { |
| 79 | const [left, right] = [start, end].sort( |
| 80 | (a, b) => new Date(a).getTime() - new Date(b).getTime() |
| 81 | ) |
| 82 | table.getColumn(resolvedFilterColumnId)?.setFilterValue([new Date(left), new Date(right)]) |
| 83 | clear() |
| 84 | }, |
| 85 | }, |
| 86 | ], |
| 87 | [table, resolvedFilterColumnId] |
| 88 | ) |
| 89 | |
| 90 | return ( |
| 91 | <div className="relative w-full"> |
| 92 | <ChartContainer |
| 93 | config={chartConfig} |
| 94 | className={cn( |
| 95 | 'aspect-auto h-[60px] w-full px-2', |
| 96 | '[&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted/50', // otherwise same color as 200 |
| 97 | 'select-none', // disable text selection |
| 98 | className |
| 99 | )} |
| 100 | > |
| 101 | <BarChart |
| 102 | data={chart} |
| 103 | margin={{ top: 0, left: 0, right: 0, bottom: 0 }} |
| 104 | onMouseDown={({ activeLabel, activeTooltipIndex }) => { |
| 105 | if (activeTooltipIndex === undefined || activeTooltipIndex === null) return |
| 106 | chartHighlight.handleMouseDown({ activeLabel, coordinates: activeLabel }) |
| 107 | }} |
| 108 | onMouseMove={({ activeLabel, activeTooltipIndex }) => { |
| 109 | if (activeTooltipIndex === undefined || activeTooltipIndex === null) return |
| 110 | chartHighlight.handleMouseMove({ activeLabel, coordinates: activeLabel }) |
| 111 | }} |
| 112 | onMouseUp={chartHighlight.handleMouseUp} |
| 113 | style={{ cursor: 'crosshair' }} |
| 114 | > |
| 115 | <CartesianGrid vertical={false} horizontal={false} /> |
| 116 | <XAxis |
| 117 | dataKey={columnId} |
| 118 | tickLine={false} |
| 119 | minTickGap={32} |
| 120 | axisLine={false} |
| 121 | tickFormatter={(value) => { |
| 122 | const date = new Date(value) |
| 123 | if (isNaN(date.getTime())) return 'N/A' |
| 124 | if (timerange.period === '10m') { |
| 125 | return format(date, 'HH:mm:ss') |
| 126 | } else if (timerange.period === '1d') { |
| 127 | return format(date, 'HH:mm') |
| 128 | } else if (timerange.period === '1w') { |
| 129 | return format(date, 'LLL dd HH:mm') |
| 130 | } |
| 131 | return format(date, 'LLL dd, y') |
| 132 | }} |
| 133 | /> |
| 134 | {!chartHighlight.popoverPosition && ( |
| 135 | <ChartTooltip |
| 136 | content={ |
| 137 | <ChartTooltipContent |
| 138 | labelFormatter={(value) => { |
| 139 | const date = new Date(value) |
| 140 | if (isNaN(date.getTime())) return 'N/A' |
| 141 | if (timerange.period === '10m') { |
| 142 | return format(date, 'LLL dd, HH:mm:ss') |
| 143 | } |
| 144 | return format(date, 'LLL dd, y HH:mm') |
| 145 | }} |
| 146 | /> |
| 147 | } |
| 148 | /> |
| 149 | )} |
| 150 | {/* TODO: we could use the `{timestamp, ...rest} = data[0]` to dynamically create the bars but that would mean the order can be very much random */} |
| 151 | <Bar dataKey="error" stackId="a" fill="var(--color-error)" /> |
| 152 | <Bar dataKey="warning" stackId="a" fill="var(--color-warning)" /> |
| 153 | <Bar dataKey="success" stackId="a" fill="var(--color-success)" /> |
| 154 | {showHighlight && ( |
| 155 | <ReferenceArea |
| 156 | x1={chartHighlight.left} |
| 157 | x2={chartHighlight.right} |
| 158 | strokeOpacity={0.5} |
| 159 | stroke={isDarkMode ? '#FFFFFF' : '#0C3925'} |
| 160 | fill={isDarkMode ? '#FFFFFF' : '#0C3925'} |
| 161 | fillOpacity={0.2} |
| 162 | /> |
| 163 | )} |
| 164 | </BarChart> |
| 165 | </ChartContainer> |
| 166 | <ChartHighlightActions chartHighlight={chartHighlight} actions={highlightActions} /> |
| 167 | </div> |
| 168 | ) |
| 169 | } |
| 170 | |
| 171 | // TODO: check what's a good abbreviation for month vs. minutes |
| 172 | function calculatePeriod(interval: number): '10m' | '1d' | '1w' | '1mo' { |
| 173 | if (interval <= 1000 * 60 * 10) { |
| 174 | // less than 10 minutes |
| 175 | return '10m' |
| 176 | } else if (interval <= 1000 * 60 * 60 * 24) { |
| 177 | // less than 1 day |
| 178 | return '1d' |
| 179 | } else if (interval <= 1000 * 60 * 60 * 24 * 7) { |
| 180 | // less than 1 week |
| 181 | return '1w' |
| 182 | } |
| 183 | return '1mo' // defaults to 1 month |
| 184 | } |