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