From 396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b Mon Sep 17 00:00:00 2001 From: Fuwn <50817549+Fuwn@users.noreply.github.com> Date: Sat, 24 Jan 2026 13:09:50 +0000 Subject: Initial commit Created from https://vercel.com/new --- src/components/charts/BarChart.tsx | 131 +++++++++++++++++++++++++++++++++ src/components/charts/BubbleChart.tsx | 31 ++++++++ src/components/charts/Chart.tsx | 130 ++++++++++++++++++++++++++++++++ src/components/charts/ChartTooltip.tsx | 23 ++++++ src/components/charts/PieChart.tsx | 31 ++++++++ 5 files changed, 346 insertions(+) create mode 100644 src/components/charts/BarChart.tsx create mode 100644 src/components/charts/BubbleChart.tsx create mode 100644 src/components/charts/Chart.tsx create mode 100644 src/components/charts/ChartTooltip.tsx create mode 100644 src/components/charts/PieChart.tsx (limited to 'src/components/charts') diff --git a/src/components/charts/BarChart.tsx b/src/components/charts/BarChart.tsx new file mode 100644 index 0000000..7bfc72d --- /dev/null +++ b/src/components/charts/BarChart.tsx @@ -0,0 +1,131 @@ +import { useTheme } from '@umami/react-zen'; +import { useMemo, useState } from 'react'; +import { Chart, type ChartProps } from '@/components/charts/Chart'; +import { ChartTooltip } from '@/components/charts/ChartTooltip'; +import { useLocale } from '@/components/hooks'; +import { renderNumberLabels } from '@/lib/charts'; +import { getThemeColors } from '@/lib/colors'; +import { DATE_FORMATS, formatDate } from '@/lib/date'; +import { formatLongCurrency, formatLongNumber } from '@/lib/format'; + +const dateFormats = { + millisecond: 'T', + second: 'pp', + minute: 'p', + hour: 'p - PP', + day: 'PPPP', + week: 'PPPP', + month: 'LLLL yyyy', + quarter: 'qqq', + year: 'yyyy', +}; + +export interface BarChartProps extends ChartProps { + unit?: string; + stacked?: boolean; + currency?: string; + renderXLabel?: (label: string, index: number, values: any[]) => string; + renderYLabel?: (label: string, index: number, values: any[]) => string; + XAxisType?: string; + YAxisType?: string; + minDate?: Date; + maxDate?: Date; +} + +export function BarChart({ + chartData, + renderXLabel, + renderYLabel, + unit, + XAxisType = 'timeseries', + YAxisType = 'linear', + stacked = false, + minDate, + maxDate, + currency, + ...props +}: BarChartProps) { + const [tooltip, setTooltip] = useState(null); + const { theme } = useTheme(); + const { locale } = useLocale(); + const { colors } = useMemo(() => getThemeColors(theme), [theme]); + + const chartOptions: any = useMemo(() => { + return { + __id: Date.now(), + scales: { + x: { + type: XAxisType, + stacked: true, + min: formatDate(minDate, DATE_FORMATS[unit], locale), + max: formatDate(maxDate, DATE_FORMATS[unit], locale), + offset: true, + time: { + unit, + }, + grid: { + display: false, + }, + border: { + color: colors.chart.line, + }, + ticks: { + color: colors.chart.text, + autoSkip: false, + maxRotation: 0, + callback: renderXLabel, + }, + }, + y: { + type: YAxisType, + min: 0, + beginAtZero: true, + stacked: !!stacked, + grid: { + color: colors.chart.line, + }, + border: { + color: colors.chart.line, + }, + ticks: { + color: colors.chart.text, + callback: renderYLabel || renderNumberLabels, + }, + }, + }, + }; + }, [chartData, colors, unit, stacked, renderXLabel, renderYLabel]); + + const handleTooltip = ({ tooltip }: { tooltip: any }) => { + const { opacity, labelColors, dataPoints } = tooltip; + + setTooltip( + opacity + ? { + title: formatDate( + new Date(dataPoints[0].raw?.d || dataPoints[0].raw?.x || dataPoints[0].raw), + dateFormats[unit], + locale, + ), + color: labelColors?.[0]?.backgroundColor, + value: currency + ? formatLongCurrency(dataPoints[0].raw.y, currency) + : `${formatLongNumber(dataPoints[0].raw.y)} ${dataPoints[0].dataset.label}`, + } + : null, + ); + }; + + return ( + <> + + {tooltip && } + + ); +} diff --git a/src/components/charts/BubbleChart.tsx b/src/components/charts/BubbleChart.tsx new file mode 100644 index 0000000..bf487ac --- /dev/null +++ b/src/components/charts/BubbleChart.tsx @@ -0,0 +1,31 @@ +import { useState } from 'react'; +import { Chart, type ChartProps } from '@/components/charts/Chart'; +import { ChartTooltip } from '@/components/charts/ChartTooltip'; + +export interface BubbleChartProps extends ChartProps { + type?: 'bubble'; +} + +export function BubbleChart({ type = 'bubble', ...props }: BubbleChartProps) { + const [tooltip, setTooltip] = useState(null); + + const handleTooltip = ({ tooltip }) => { + const { opacity, labelColors, title, dataPoints } = tooltip; + + setTooltip( + opacity + ? { + color: labelColors?.[0]?.backgroundColor, + value: `${title}: ${dataPoints[0].raw}`, + } + : null, + ); + }; + + return ( + <> + + {tooltip && } + + ); +} 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 ( + + + + + + + ); +} diff --git a/src/components/charts/ChartTooltip.tsx b/src/components/charts/ChartTooltip.tsx new file mode 100644 index 0000000..95ba2a2 --- /dev/null +++ b/src/components/charts/ChartTooltip.tsx @@ -0,0 +1,23 @@ +import { Column, FloatingTooltip, Row, StatusLight } from '@umami/react-zen'; +import type { ReactNode } from 'react'; + +export function ChartTooltip({ + title, + color, + value, +}: { + title?: string; + color?: string; + value?: ReactNode; +}) { + return ( + + + {title && {title}} + + {value} + + + + ); +} diff --git a/src/components/charts/PieChart.tsx b/src/components/charts/PieChart.tsx new file mode 100644 index 0000000..2470fe7 --- /dev/null +++ b/src/components/charts/PieChart.tsx @@ -0,0 +1,31 @@ +import { useState } from 'react'; +import { Chart, type ChartProps } from '@/components/charts/Chart'; +import { ChartTooltip } from '@/components/charts/ChartTooltip'; + +export interface PieChartProps extends ChartProps { + type?: 'doughnut' | 'pie'; +} + +export function PieChart({ type = 'pie', ...props }: PieChartProps) { + const [tooltip, setTooltip] = useState(null); + + const handleTooltip = ({ tooltip }) => { + const { opacity, labelColors, title, dataPoints } = tooltip; + + setTooltip( + opacity + ? { + color: labelColors?.[0]?.backgroundColor, + value: `${title}: ${dataPoints[0].raw}`, + } + : null, + ); + }; + + return ( + <> + + {tooltip && } + + ); +} -- cgit v1.2.3