205 lines
5.9 KiB
TypeScript
205 lines
5.9 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import * as RechartsPrimitive from "recharts"
|
|
|
|
import { cn } from "@/lib/utils"
|
|
|
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
|
const THEMES = { light: "", dark: ".dark" } as const
|
|
|
|
export type ChartConfig = {
|
|
[k in string]: {
|
|
label?: React.ReactNode
|
|
icon?: React.ComponentType
|
|
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> })
|
|
}
|
|
|
|
type ChartContextProps = {
|
|
config: ChartConfig
|
|
}
|
|
|
|
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
|
|
|
function useChart() {
|
|
const context = React.useContext(ChartContext)
|
|
|
|
if (!context) {
|
|
throw new Error("useChart must be used within a <ChartContainer />")
|
|
}
|
|
|
|
return context
|
|
}
|
|
|
|
interface ChartContainerProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
config: Record<string, { label: string; color: string }>
|
|
}
|
|
|
|
const ChartContainer = React.forwardRef<HTMLDivElement, ChartContainerProps>(
|
|
({ className, config, children, ...props }, ref) => {
|
|
const colorVars = Object.entries(config).reduce(
|
|
(acc, [key, value]) => {
|
|
acc[`--color-${key}`] = value.color
|
|
return acc
|
|
},
|
|
{} as Record<string, string>,
|
|
)
|
|
|
|
return (
|
|
<div ref={ref} className={cn("relative", className)} style={colorVars} {...props}>
|
|
{children}
|
|
</div>
|
|
)
|
|
},
|
|
)
|
|
ChartContainer.displayName = "ChartContainer"
|
|
|
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
|
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color)
|
|
|
|
if (!colorConfig.length) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<style
|
|
dangerouslySetInnerHTML={{
|
|
__html: Object.entries(THEMES)
|
|
.map(
|
|
([theme, prefix]) => `
|
|
${prefix} [data-chart=${id}] {
|
|
${colorConfig
|
|
.map(([key, itemConfig]) => {
|
|
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color
|
|
return color ? ` --color-${key}: ${color};` : null
|
|
})
|
|
.join("\n")}
|
|
}
|
|
`,
|
|
)
|
|
.join("\n"),
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
interface ChartTooltipProps {
|
|
children?: React.ReactNode
|
|
}
|
|
|
|
const ChartTooltip = React.forwardRef<HTMLDivElement, ChartTooltipProps>(({ className, children, ...props }, ref) => {
|
|
return <div ref={ref} className={cn("rounded-md border bg-card p-2 shadow-md", className)} {...props} />
|
|
})
|
|
ChartTooltip.displayName = "ChartTooltip"
|
|
|
|
interface ChartTooltipContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
active?: boolean
|
|
payload?: any[]
|
|
label?: string
|
|
labelFormatter?: (value: any) => string
|
|
hideLabel?: boolean
|
|
}
|
|
|
|
const ChartTooltipContent = React.forwardRef<HTMLDivElement, ChartTooltipContentProps>(
|
|
({ active, payload, label, labelFormatter, hideLabel, className, ...props }, ref) => {
|
|
if (!active || !payload) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<div ref={ref} className={cn("rounded-lg border bg-background p-2 shadow-sm", className)} {...props}>
|
|
{!hideLabel && label && (
|
|
<div className="mb-1 text-xs font-medium">{labelFormatter ? labelFormatter(label) : label}</div>
|
|
)}
|
|
<div className="flex flex-col gap-1">
|
|
{payload.map((entry, index) => (
|
|
<div key={index} className="flex items-center gap-2 text-xs">
|
|
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: entry.color }} />
|
|
<span className="font-medium">{entry.name}:</span>
|
|
<span>{entry.value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
},
|
|
)
|
|
ChartTooltipContent.displayName = "ChartTooltipContent"
|
|
|
|
const ChartLegend = RechartsPrimitive.Legend
|
|
|
|
const ChartLegendContent = React.forwardRef<
|
|
HTMLDivElement,
|
|
React.ComponentProps<"div"> &
|
|
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
|
hideIcon?: boolean
|
|
nameKey?: string
|
|
}
|
|
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
|
|
const { config } = useChart()
|
|
|
|
if (!payload?.length) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className={cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className)}
|
|
>
|
|
{payload.map((item) => {
|
|
const key = `${nameKey || item.dataKey || "value"}`
|
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
|
|
|
return (
|
|
<div
|
|
key={item.value}
|
|
className={cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground")}
|
|
>
|
|
{itemConfig?.icon && !hideIcon ? (
|
|
<itemConfig.icon />
|
|
) : (
|
|
<div
|
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
|
style={{
|
|
backgroundColor: item.color,
|
|
}}
|
|
/>
|
|
)}
|
|
{itemConfig?.label}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
})
|
|
ChartLegendContent.displayName = "ChartLegend"
|
|
|
|
// Helper to extract item config from a payload.
|
|
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
|
if (typeof payload !== "object" || payload === null) {
|
|
return undefined
|
|
}
|
|
|
|
const payloadPayload =
|
|
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
|
|
? payload.payload
|
|
: undefined
|
|
|
|
let configLabelKey: string = key
|
|
|
|
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
|
|
configLabelKey = payload[key as keyof typeof payload] as string
|
|
} else if (
|
|
payloadPayload &&
|
|
key in payloadPayload &&
|
|
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
|
) {
|
|
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string
|
|
}
|
|
|
|
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config]
|
|
}
|
|
|
|
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle }
|