aboutsummaryrefslogtreecommitdiff
path: root/src/components/charts/Chart.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/charts/Chart.tsx')
-rw-r--r--src/components/charts/Chart.tsx130
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>
+ );
+}