TimelineChart.tsx 6.5 KB

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