chart.tsx 9.6 KB

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