TimelineChart.tsx184 lines · main
1import { format } from 'date-fns'
2import { SearchIcon } from 'lucide-react'
3import { useTheme } from 'next-themes'
4import { useMemo } from 'react'
5import { Bar, BarChart, CartesianGrid, ReferenceArea, XAxis } from 'recharts'
6import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, cn } from 'ui'
7
8import { useDataTable } from './providers/DataTableProvider'
9import {
10 ChartHighlightAction,
11 ChartHighlightActions,
12} from '@/components/ui/Charts/ChartHighlightActions'
13import { useChartHighlight } from '@/components/ui/Charts/useChartHighlight'
14
15export type BaseChartSchema = { timestamp: number; [key: string]: number }
16export const description = 'A stacked bar chart'
17
18interface 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
37export 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
172function 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}