211 lines
5.0 KiB
TypeScript
211 lines
5.0 KiB
TypeScript
import * as React from 'react'
|
|
import { useId } from 'react'
|
|
import * as RechartsChart from 'recharts'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
export interface ChartConfig {
|
|
[key: string]: {
|
|
label?: string
|
|
icon?: React.ComponentType
|
|
color?: string
|
|
}
|
|
}
|
|
|
|
type ChartContextProps = {
|
|
config: ChartConfig
|
|
}
|
|
|
|
const THEMES = { light: '', dark: '.dark' } as const
|
|
const CHART_INITIAL_DIMENSION = { width: 1, height: 1 }
|
|
|
|
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
|
|
|
export function useChart() {
|
|
const context = React.useContext(ChartContext)
|
|
|
|
if (!context) {
|
|
throw new Error('useChart must be used within a <ChartContainer>')
|
|
}
|
|
|
|
return context
|
|
}
|
|
|
|
export function ChartContainer({
|
|
id,
|
|
config,
|
|
className,
|
|
children,
|
|
...props
|
|
}: React.ComponentProps<'div'> & {
|
|
config: ChartConfig
|
|
children: React.ComponentProps<
|
|
typeof RechartsChart.ResponsiveContainer
|
|
>['children']
|
|
}) {
|
|
const uniqueId = useId()
|
|
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`
|
|
const containerRef = React.useRef<HTMLDivElement>(null)
|
|
const [isReady, setIsReady] = React.useState(false)
|
|
|
|
React.useLayoutEffect(() => {
|
|
const node = containerRef.current
|
|
if (!node) return
|
|
|
|
const updateSize = () => {
|
|
const rect = node.getBoundingClientRect()
|
|
const hasSize = rect.width > 0 && rect.height > 0
|
|
setIsReady((current) => (current === hasSize ? current : hasSize))
|
|
}
|
|
|
|
updateSize()
|
|
|
|
if (typeof ResizeObserver === 'undefined') return
|
|
|
|
const observer = new ResizeObserver(updateSize)
|
|
observer.observe(node)
|
|
|
|
return () => observer.disconnect()
|
|
}, [])
|
|
|
|
return (
|
|
<ChartContext.Provider value={{ config }}>
|
|
<div
|
|
ref={containerRef}
|
|
data-chart={chartId}
|
|
className={cn(
|
|
'chart flex aspect-video min-h-0 min-w-0 justify-center text-xs',
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
<ChartStyle id={chartId} config={config} />
|
|
{isReady ? (
|
|
<RechartsChart.ResponsiveContainer
|
|
width='100%'
|
|
height='100%'
|
|
minWidth={0}
|
|
initialDimension={CHART_INITIAL_DIMENSION}
|
|
>
|
|
{children}
|
|
</RechartsChart.ResponsiveContainer>
|
|
) : (
|
|
<div className='h-full w-full' />
|
|
)}
|
|
</div>
|
|
</ChartContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function ChartStyle({
|
|
id,
|
|
config,
|
|
}: {
|
|
id: string
|
|
config: ChartConfig
|
|
}) {
|
|
const colorConfig = Object.entries(config).filter(
|
|
([, itemConfig]) => itemConfig.color
|
|
)
|
|
|
|
if (!colorConfig.length) return null
|
|
|
|
return (
|
|
<style
|
|
dangerouslySetInnerHTML={{
|
|
__html: Object.entries(THEMES)
|
|
.map(
|
|
([, prefix]) => `
|
|
${prefix} [data-chart=${id}] {
|
|
${colorConfig
|
|
.map(([key, itemConfig]) => ` --color-${key}: ${itemConfig.color};`)
|
|
.join('\n')}
|
|
}
|
|
`
|
|
)
|
|
.join('\n'),
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export const ChartTooltip = RechartsChart.Tooltip
|
|
|
|
export function ChartTooltipContent({ active, payload, label }: any) {
|
|
const { config } = useChart()
|
|
|
|
if (!active || !payload?.length) return null
|
|
|
|
return (
|
|
<div className='min-w-32 rounded-md border bg-background/95 p-2.5 text-xs shadow-lg backdrop-blur'>
|
|
{label ? (
|
|
<div className='mb-2 font-medium text-foreground'>{label}</div>
|
|
) : null}
|
|
{payload.map((item: any) => (
|
|
<div
|
|
key={item.dataKey}
|
|
className='flex items-center justify-between gap-4 py-0.5'
|
|
>
|
|
<span className='flex items-center gap-2 text-muted-foreground'>
|
|
<span
|
|
className='h-2 w-2 rounded-full'
|
|
style={{ backgroundColor: item.color || item.fill }}
|
|
/>
|
|
{config[item.dataKey]?.label || item.name || item.dataKey}
|
|
</span>
|
|
<span className='font-medium text-foreground tabular-nums'>
|
|
{typeof item.value === 'number'
|
|
? item.value.toLocaleString('en-US')
|
|
: item.value}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function ChartLegendContent({ payload }: any) {
|
|
const { config } = useChart()
|
|
|
|
if (!payload?.length) return null
|
|
|
|
const orderedPayload = [...payload].sort(
|
|
(left: any, right: any) =>
|
|
Object.keys(config).indexOf(left.value) -
|
|
Object.keys(config).indexOf(right.value)
|
|
)
|
|
|
|
return (
|
|
<div className='flex flex-wrap items-center justify-end gap-4 text-xs text-muted-foreground'>
|
|
{orderedPayload.map((item: any) => (
|
|
<div key={item.value} className='flex items-center gap-1.5'>
|
|
<span
|
|
className='h-2 w-2 rounded-full'
|
|
style={{ backgroundColor: item.color }}
|
|
/>
|
|
<span>{config[item.value]?.label || item.value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Re-export Recharts components
|
|
export {
|
|
Area,
|
|
AreaChart,
|
|
Bar,
|
|
BarChart,
|
|
CartesianGrid,
|
|
Cell,
|
|
Legend,
|
|
Pie,
|
|
PieChart,
|
|
Radar,
|
|
RadarChart,
|
|
ResponsiveContainer,
|
|
Sector,
|
|
Tooltip,
|
|
XAxis,
|
|
YAxis,
|
|
} from 'recharts'
|