chart.tsx 10 KB

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