chart.tsx 9.5 KB

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