chart.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. 'use client';
  2. import * as React from 'react';
  3. import * as RechartsPrimitive from 'recharts';
  4. import type { TooltipValueType } from 'recharts';
  5. import { cn } from '@/lib/utils';
  6. // Format: { THEME_NAME: CSS_SELECTOR }
  7. const THEMES = { light: '', dark: '.dark' } as const;
  8. const INITIAL_DIMENSION = { width: 320, height: 200 } as const;
  9. type TooltipNameType = number | string;
  10. export type ChartConfig = Record<
  11. string,
  12. {
  13. label?: React.ReactNode;
  14. icon?: React.ComponentType;
  15. } & (
  16. | { color?: string; theme?: never }
  17. | { color?: never; theme: Record<keyof typeof THEMES, string> }
  18. )
  19. >;
  20. type ChartContextProps = {
  21. config: ChartConfig;
  22. };
  23. const ChartContext = React.createContext<ChartContextProps | null>(null);
  24. function useChart() {
  25. const context = React.useContext(ChartContext);
  26. if (!context) {
  27. throw new Error('useChart must be used within a <ChartContainer />');
  28. }
  29. return context;
  30. }
  31. function ChartContainer({
  32. id,
  33. className,
  34. children,
  35. config,
  36. initialDimension = INITIAL_DIMENSION,
  37. ...props
  38. }: React.ComponentProps<'div'> & {
  39. config: ChartConfig;
  40. children: React.ComponentProps<
  41. typeof RechartsPrimitive.ResponsiveContainer
  42. >['children'];
  43. initialDimension?: {
  44. width: number;
  45. height: number;
  46. };
  47. }) {
  48. const uniqueId = React.useId();
  49. const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`;
  50. return (
  51. <ChartContext.Provider value={{ config }}>
  52. <div
  53. data-slot="chart"
  54. data-chart={chartId}
  55. className={cn(
  56. "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
  57. className,
  58. )}
  59. {...props}
  60. >
  61. <ChartStyle id={chartId} config={config} />
  62. <RechartsPrimitive.ResponsiveContainer
  63. initialDimension={initialDimension}
  64. >
  65. {children}
  66. </RechartsPrimitive.ResponsiveContainer>
  67. </div>
  68. </ChartContext.Provider>
  69. );
  70. }
  71. const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
  72. const colorConfig = Object.entries(config).filter(
  73. ([, config]) => config.theme ?? config.color,
  74. );
  75. if (!colorConfig.length) {
  76. return null;
  77. }
  78. return (
  79. <style
  80. dangerouslySetInnerHTML={{
  81. __html: Object.entries(THEMES)
  82. .map(
  83. ([theme, prefix]) => `
  84. ${prefix} [data-chart=${id}] {
  85. ${colorConfig
  86. .map(([key, itemConfig]) => {
  87. const color =
  88. itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
  89. itemConfig.color;
  90. return color ? ` --color-${key}: ${color};` : null;
  91. })
  92. .join('\n')}
  93. }
  94. `,
  95. )
  96. .join('\n'),
  97. }}
  98. />
  99. );
  100. };
  101. const ChartTooltip = RechartsPrimitive.Tooltip;
  102. function ChartTooltipContent({
  103. active,
  104. payload,
  105. className,
  106. indicator = 'dot',
  107. hideLabel = false,
  108. hideIndicator = false,
  109. label,
  110. labelFormatter,
  111. labelClassName,
  112. formatter,
  113. color,
  114. nameKey,
  115. labelKey,
  116. }: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
  117. React.ComponentProps<'div'> & {
  118. hideLabel?: boolean;
  119. hideIndicator?: boolean;
  120. indicator?: 'line' | 'dot' | 'dashed';
  121. nameKey?: string;
  122. labelKey?: string;
  123. } & Omit<
  124. RechartsPrimitive.DefaultTooltipContentProps<
  125. TooltipValueType,
  126. TooltipNameType
  127. >,
  128. 'accessibilityLayer'
  129. >) {
  130. const { config } = useChart();
  131. const tooltipLabel = React.useMemo(() => {
  132. if (hideLabel || !payload?.length) {
  133. return null;
  134. }
  135. const [item] = payload;
  136. const key = `${labelKey ?? item?.dataKey ?? item?.name ?? 'value'}`;
  137. const itemConfig = getPayloadConfigFromPayload(config, item, key);
  138. const value =
  139. !labelKey && typeof label === 'string'
  140. ? (config[label]?.label ?? label)
  141. : itemConfig?.label;
  142. if (labelFormatter) {
  143. return (
  144. <div className={cn('font-medium', labelClassName)}>
  145. {labelFormatter(value, payload)}
  146. </div>
  147. );
  148. }
  149. if (!value) {
  150. return null;
  151. }
  152. return <div className={cn('font-medium', labelClassName)}>{value}</div>;
  153. }, [
  154. label,
  155. labelFormatter,
  156. payload,
  157. hideLabel,
  158. labelClassName,
  159. config,
  160. labelKey,
  161. ]);
  162. if (!active || !payload?.length) {
  163. return null;
  164. }
  165. const nestLabel = payload.length === 1 && indicator !== 'dot';
  166. return (
  167. <div
  168. className={cn(
  169. 'border-border/50 bg-background gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl grid min-w-32 items-start',
  170. className,
  171. )}
  172. >
  173. {!nestLabel ? tooltipLabel : null}
  174. <div className="grid gap-1.5">
  175. {payload
  176. .filter((item) => item.type !== 'none')
  177. .map((item, index) => {
  178. const key = `${nameKey ?? item.name ?? item.dataKey ?? 'value'}`;
  179. const itemConfig = getPayloadConfigFromPayload(config, item, key);
  180. const indicatorColor = color ?? item.payload?.fill ?? item.color;
  181. return (
  182. <div
  183. key={index}
  184. className={cn(
  185. 'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
  186. indicator === 'dot' && 'items-center',
  187. )}
  188. >
  189. {formatter && item?.value !== undefined && item.name ? (
  190. formatter(item.value, item.name, item, index, item.payload)
  191. ) : (
  192. <>
  193. {itemConfig?.icon ? (
  194. <itemConfig.icon />
  195. ) : (
  196. !hideIndicator && (
  197. <div
  198. className={cn(
  199. 'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)',
  200. {
  201. 'h-2.5 w-2.5': indicator === 'dot',
  202. 'w-1': indicator === 'line',
  203. 'w-0 border-[1.5px] border-dashed bg-transparent':
  204. indicator === 'dashed',
  205. 'my-0.5': nestLabel && indicator === 'dashed',
  206. },
  207. )}
  208. style={
  209. {
  210. '--color-bg': indicatorColor,
  211. '--color-border': indicatorColor,
  212. } as React.CSSProperties
  213. }
  214. />
  215. )
  216. )}
  217. <div
  218. className={cn(
  219. 'flex flex-1 justify-between leading-none',
  220. nestLabel ? 'items-end' : 'items-center',
  221. )}
  222. >
  223. <div className="grid gap-1.5">
  224. {nestLabel ? tooltipLabel : null}
  225. <span className="text-muted-foreground">
  226. {itemConfig?.label ?? item.name}
  227. </span>
  228. </div>
  229. {item.value != null && (
  230. <span className="font-mono font-medium text-foreground tabular-nums">
  231. {typeof item.value === 'number'
  232. ? item.value.toLocaleString()
  233. : String(item.value)}
  234. </span>
  235. )}
  236. </div>
  237. </>
  238. )}
  239. </div>
  240. );
  241. })}
  242. </div>
  243. </div>
  244. );
  245. }
  246. const ChartLegend = RechartsPrimitive.Legend;
  247. function ChartLegendContent({
  248. className,
  249. hideIcon = false,
  250. payload,
  251. verticalAlign = 'bottom',
  252. nameKey,
  253. }: React.ComponentProps<'div'> & {
  254. hideIcon?: boolean;
  255. nameKey?: string;
  256. } & RechartsPrimitive.DefaultLegendContentProps) {
  257. const { config } = useChart();
  258. if (!payload?.length) {
  259. return null;
  260. }
  261. return (
  262. <div
  263. className={cn(
  264. 'flex items-center justify-center gap-4',
  265. verticalAlign === 'top' ? 'pb-3' : 'pt-3',
  266. className,
  267. )}
  268. >
  269. {payload
  270. .filter((item) => item.type !== 'none')
  271. .map((item, index) => {
  272. const key = `${nameKey ?? item.dataKey ?? 'value'}`;
  273. const itemConfig = getPayloadConfigFromPayload(config, item, key);
  274. return (
  275. <div
  276. key={index}
  277. className={cn(
  278. 'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground',
  279. )}
  280. >
  281. {itemConfig?.icon && !hideIcon ? (
  282. <itemConfig.icon />
  283. ) : (
  284. <div
  285. className="h-2 w-2 shrink-0 rounded-[2px]"
  286. style={{
  287. backgroundColor: item.color,
  288. }}
  289. />
  290. )}
  291. {itemConfig?.label}
  292. </div>
  293. );
  294. })}
  295. </div>
  296. );
  297. }
  298. function getPayloadConfigFromPayload(
  299. config: ChartConfig,
  300. payload: unknown,
  301. key: string,
  302. ) {
  303. if (typeof payload !== 'object' || payload === null) {
  304. return undefined;
  305. }
  306. const payloadPayload =
  307. 'payload' in payload &&
  308. typeof payload.payload === 'object' &&
  309. payload.payload !== null
  310. ? payload.payload
  311. : undefined;
  312. let configLabelKey: string = key;
  313. if (
  314. key in payload &&
  315. typeof payload[key as keyof typeof payload] === 'string'
  316. ) {
  317. configLabelKey = payload[key as keyof typeof payload] as string;
  318. } else if (
  319. payloadPayload &&
  320. key in payloadPayload &&
  321. typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
  322. ) {
  323. configLabelKey = payloadPayload[
  324. key as keyof typeof payloadPayload
  325. ] as string;
  326. }
  327. return configLabelKey in config ? config[configLabelKey] : config[key];
  328. }
  329. export {
  330. ChartContainer,
  331. ChartTooltip,
  332. ChartTooltipContent,
  333. ChartLegend,
  334. ChartLegendContent,
  335. ChartStyle,
  336. };