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