chart-line.tsx304 lines · main
1'use client'
2
3import dayjs from 'dayjs'
4import { useTheme } from 'next-themes'
5import { ReactNode, useState } from 'react'
6import {
7 Area,
8 CartesianGrid,
9 AreaChart as RechartAreaChart,
10 ReferenceArea,
11 ReferenceLine,
12 XAxis,
13 YAxis,
14} from 'recharts'
15import type { CategoricalChartState } from 'recharts/types/chart/types'
16import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, cn } from 'ui'
17
18const CHART_COLORS = {
19 TICK: 'hsl(var(--background-overlay-hover))',
20 AXIS: 'hsl(var(--background-overlay-hover))',
21 BRAND: 'hsl(var(--brand-default))',
22 BRAND_HOVER: 'hsl(var(--brand-500))',
23}
24
25export type ChartLineTick = {
26 timestamp: string
27 [key: string]: string | number
28}
29
30export type ChartHighlight = {
31 handleMouseDown: (e: { activeLabel?: string; coordinates?: string }) => void
32 handleMouseMove: (e: { activeLabel?: string; coordinates?: string }) => void
33 handleMouseUp: (e: { chartX?: number; chartY?: number }) => void
34 coordinates: { left?: string; right?: string }
35 clearHighlight?: () => void
36}
37
38export type ChartHighlightAction = {
39 id: string
40 label: string | ((ctx: { start: string; end: string; clear: () => void }) => string)
41 icon?: ReactNode
42 isDisabled?: (ctx: { start: string; end: string; clear: () => void }) => boolean
43 onSelect: (ctx: { start: string; end: string; clear: () => void }) => void
44}
45
46export type ChartReferenceLine = {
47 y: number
48 label?: string
49 stroke?: string
50 strokeWidth?: number
51 strokeDasharray?: string
52}
53
54export interface ChartLineProps {
55 data: ChartLineTick[]
56 dataKey: string
57 dataKeys?: string[]
58 config?: ChartConfig
59 tooltipDetails?: (datum: ChartLineTick, key: string, value: unknown) => ReactNode
60 onLineClick?: (datum: ChartLineTick, tooltipData?: CategoricalChartState) => void
61 DateTimeFormat?: string
62 isFullHeight?: boolean
63 className?: string
64 color?: string
65 chartHighlight?: ChartHighlight
66 syncId?: string
67 showHighlightArea?: boolean
68 cursor?: string
69 showGrid?: boolean
70 showYAxis?: boolean
71 YAxisProps?: {
72 tick?: boolean
73 tickFormatter?: (value: any) => string
74 width?: number
75 [key: string]: any
76 }
77 strokeWidth?: number
78 referenceLines?: ChartReferenceLine[]
79}
80
81export const ChartLine = ({
82 data,
83 dataKey,
84 dataKeys,
85 config,
86 tooltipDetails,
87 onLineClick,
88 DateTimeFormat = 'MMM D, YYYY, hh:mma',
89 isFullHeight = false,
90 className,
91 color = CHART_COLORS.BRAND,
92 chartHighlight,
93 syncId,
94 showHighlightArea = true,
95 cursor,
96 showGrid = false,
97 showYAxis = false,
98 YAxisProps,
99 strokeWidth = 1.5,
100 referenceLines,
101}: ChartLineProps) => {
102 const [focusDataIndex, setFocusDataIndex] = useState<number | null>(null)
103 const { resolvedTheme } = useTheme()
104 const isDarkMode = resolvedTheme?.includes('dark')
105
106 if (data.length === 0) {
107 return null
108 }
109
110 const keysToRender = dataKeys || [dataKey]
111
112 const chartConfig: ChartConfig =
113 config ||
114 keysToRender.reduce((acc, key) => {
115 acc[key] = { label: key }
116 return acc
117 }, {} as ChartConfig)
118
119 const showHighlightActions =
120 showHighlightArea &&
121 chartHighlight?.coordinates.left &&
122 chartHighlight?.coordinates.right &&
123 chartHighlight?.coordinates.left !== chartHighlight?.coordinates.right
124
125 const chartCursor = cursor || (chartHighlight ? 'crosshair' : 'default')
126
127 const yAxisConfig = {
128 tick: showYAxis,
129 hide: !showYAxis,
130 tickMargin: showYAxis ? (YAxisProps?.tickMargin ?? 4) : 0,
131 width: showYAxis ? (YAxisProps?.width ?? undefined) : 0,
132 axisLine: { stroke: CHART_COLORS.AXIS },
133 tickLine: { stroke: CHART_COLORS.AXIS },
134 ...YAxisProps,
135 }
136
137 const margin = {
138 top: 0,
139 right: 0,
140 left: showYAxis ? -40 : 0,
141 bottom: 0,
142 }
143
144 const formatTooltipValue = (value: unknown) => {
145 if (typeof value === 'number') {
146 return YAxisProps?.tickFormatter ? YAxisProps.tickFormatter(value) : value.toLocaleString()
147 }
148
149 return String(value)
150 }
151
152 return (
153 <div
154 data-testid="chart-line"
155 className={cn('flex flex-col gap-y-3 w-full', isFullHeight ? 'h-full' : 'h-24', className)}
156 >
157 <ChartContainer className="w-full! h-full" config={chartConfig}>
158 <RechartAreaChart
159 data={data}
160 syncId={syncId}
161 margin={margin}
162 style={{ cursor: chartCursor }}
163 onMouseMove={(e: any) => {
164 if (e.activeTooltipIndex !== focusDataIndex) {
165 setFocusDataIndex(e.activeTooltipIndex)
166 }
167
168 if (chartHighlight) {
169 const activeTimestamp = data[e.activeTooltipIndex]?.timestamp
170 chartHighlight.handleMouseMove({
171 activeLabel: activeTimestamp?.toString(),
172 coordinates: e.activeLabel,
173 })
174 }
175 }}
176 onMouseDown={(e: any) => {
177 if (chartHighlight && e.activeTooltipIndex !== undefined) {
178 const activeTimestamp = data[e.activeTooltipIndex]?.timestamp
179 chartHighlight.handleMouseDown({
180 activeLabel: activeTimestamp?.toString(),
181 coordinates: e.activeLabel,
182 })
183 }
184 }}
185 onMouseUp={(e: any) => {
186 if (chartHighlight) {
187 chartHighlight.handleMouseUp({
188 chartX: e?.chartX,
189 chartY: e?.chartY,
190 })
191 }
192 }}
193 onMouseLeave={() => {
194 setFocusDataIndex(null)
195 if (chartHighlight?.clearHighlight) {
196 chartHighlight.clearHighlight()
197 }
198 }}
199 onClick={(tooltipData) => {
200 const datum = tooltipData?.activePayload?.[0]?.payload
201 if (onLineClick) onLineClick(datum, tooltipData)
202 }}
203 >
204 {showGrid && <CartesianGrid vertical={false} stroke={CHART_COLORS.AXIS} />}
205 <YAxis {...yAxisConfig} />
206 <XAxis
207 dataKey="timestamp"
208 interval={data.length - 2}
209 tick={false}
210 axisLine={{ stroke: CHART_COLORS.AXIS }}
211 tickLine={{ stroke: CHART_COLORS.AXIS }}
212 />
213 <ChartTooltip
214 content={
215 <ChartTooltipContent
216 className="text-foreground-light -mt-5"
217 labelFormatter={(v: string) => dayjs(v).format(DateTimeFormat)}
218 formatter={(value, name, item) => {
219 const key = String(item.dataKey || name || dataKey)
220 const itemConfig = chartConfig[key]
221 const indicatorColor = item.payload.fill || item.color || color
222 const detail = tooltipDetails?.(item.payload as ChartLineTick, key, value)
223
224 return (
225 <>
226 <div
227 className="h-2.5 w-2.5 shrink-0 rounded-[2px]"
228 style={{ backgroundColor: indicatorColor }}
229 />
230 <div className="flex flex-1 justify-between gap-3 leading-none">
231 <div className="grid gap-1">
232 <span className="text-foreground-light">
233 {itemConfig?.label || name || key}
234 </span>
235 {detail}
236 </div>
237 <span className="font-mono font-medium tabular-nums text-foreground">
238 {formatTooltipValue(value)}
239 </span>
240 </div>
241 </>
242 )
243 }}
244 />
245 }
246 />
247 {/* Selection highlight area */}
248 {showHighlightActions && (
249 <ReferenceArea
250 x1={chartHighlight?.coordinates.left}
251 x2={chartHighlight?.coordinates.right}
252 strokeOpacity={0.5}
253 stroke={isDarkMode ? '#FFFFFF' : '#0C3925'}
254 fill={isDarkMode ? '#FFFFFF' : '#0C3925'}
255 fillOpacity={0.2}
256 />
257 )}
258 {referenceLines?.map((line, index) => (
259 <ReferenceLine
260 key={`${line.y}-${index}`}
261 y={line.y}
262 stroke={line.stroke ?? CHART_COLORS.TICK}
263 strokeWidth={line.strokeWidth ?? 1.5}
264 strokeDasharray={line.strokeDasharray ?? '4 4'}
265 />
266 ))}
267 {keysToRender.map((key, index) => {
268 const keyConfig = chartConfig[key]
269 const lineColor =
270 keyConfig?.color ||
271 (keyConfig?.theme
272 ? isDarkMode
273 ? keyConfig.theme.dark
274 : keyConfig.theme.light
275 : color)
276 const baseOpacity = 0.2
277 const opacityIncrement = 0.1
278 const maxOpacity = 0.6
279 const fillOpacity = Math.min(baseOpacity + index * opacityIncrement, maxOpacity)
280
281 return (
282 <Area
283 key={key}
284 type="step"
285 dataKey={key}
286 fill={lineColor}
287 fillOpacity={fillOpacity}
288 stroke={lineColor}
289 strokeWidth={strokeWidth}
290 stackId={`stack-${key}`}
291 />
292 )
293 })}
294 </RechartAreaChart>
295 </ChartContainer>
296 {data && data.length > 0 && (
297 <div className="text-foreground-lighter -mt-10 flex items-center justify-between text-[10px] font-mono">
298 <span>{dayjs(data[0]['timestamp']).format(DateTimeFormat)}</span>
299 <span>{dayjs(data[data.length - 1]?.['timestamp']).format(DateTimeFormat)}</span>
300 </div>
301 )}
302 </div>
303 )
304}