diff options
| author | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
| commit | 396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b (patch) | |
| tree | b9df4ca6a70db45cfffbae6fdd7252e20fb8e93c /src/components/charts/Chart.tsx | |
| download | umami-396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b.tar.xz umami-396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b.zip | |
Created from https://vercel.com/new
Diffstat (limited to 'src/components/charts/Chart.tsx')
| -rw-r--r-- | src/components/charts/Chart.tsx | 130 |
1 files changed, 130 insertions, 0 deletions
diff --git a/src/components/charts/Chart.tsx b/src/components/charts/Chart.tsx new file mode 100644 index 0000000..b6ae9d7 --- /dev/null +++ b/src/components/charts/Chart.tsx @@ -0,0 +1,130 @@ +import { Box, type BoxProps, Column } from '@umami/react-zen'; +import ChartJS, { + type ChartData, + type ChartOptions, + type LegendItem, + type UpdateMode, +} from 'chart.js/auto'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Legend } from '@/components/metrics/Legend'; +import { DEFAULT_ANIMATION_DURATION } from '@/lib/constants'; + +ChartJS.defaults.font.family = 'Inter'; + +export interface ChartProps extends BoxProps { + type?: 'bar' | 'bubble' | 'doughnut' | 'pie' | 'line' | 'polarArea' | 'radar' | 'scatter'; + chartData?: ChartData & { focusLabel?: string }; + chartOptions?: ChartOptions; + updateMode?: UpdateMode; + animationDuration?: number; + onTooltip?: (model: any) => void; +} + +export function Chart({ + type, + chartData, + animationDuration = DEFAULT_ANIMATION_DURATION, + updateMode, + onTooltip, + chartOptions, + ...props +}: ChartProps) { + const canvas = useRef(null); + const chart = useRef(null); + const [legendItems, setLegendItems] = useState([]); + + const options = useMemo(() => { + return { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: animationDuration, + resize: { + duration: 0, + }, + active: { + duration: 0, + }, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + intersect: true, + external: onTooltip, + }, + }, + ...chartOptions, + }; + }, [chartOptions]); + + const handleLegendClick = (item: LegendItem) => { + if (type === 'bar') { + const { datasetIndex } = item; + const meta = chart.current.getDatasetMeta(datasetIndex); + + meta.hidden = + meta.hidden === null ? !chart.current.data.datasets[datasetIndex]?.hidden : null; + } else { + const { index } = item; + const meta = chart.current.getDatasetMeta(0); + const hidden = !!meta?.data?.[index]?.hidden; + + meta.data[index].hidden = !hidden; + chart.current.legend.legendItems[index].hidden = !hidden; + } + + chart.current.update(updateMode); + + setLegendItems(chart.current.legend.legendItems); + }; + + // Create chart + useEffect(() => { + if (canvas.current) { + chart.current = new ChartJS(canvas.current, { + type, + data: chartData, + options, + }); + + setLegendItems(chart.current.legend.legendItems); + } + + return () => { + chart.current?.destroy(); + }; + }, []); + + // Update chart + useEffect(() => { + if (chart.current && chartData) { + // Replace labels and datasets *in-place* + chart.current.data.labels = chartData.labels; + chart.current.data.datasets = chartData.datasets; + + if (chartData.focusLabel !== null) { + chart.current.data.datasets.forEach((ds: { hidden: boolean; label: any }) => { + ds.hidden = chartData.focusLabel ? ds.label !== chartData.focusLabel : false; + }); + } + + chart.current.options = options; + + chart.current.update(updateMode); + + setLegendItems(chart.current.legend.legendItems); + } + }, [chartData, options, updateMode]); + + return ( + <Column gap="6"> + <Box {...props}> + <canvas ref={canvas} /> + </Box> + <Legend items={legendItems} onClick={handleLegendClick} /> + </Column> + ); +} |