aboutsummaryrefslogtreecommitdiff
path: root/src/components/charts
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-24 13:09:50 +0000
committerFuwn <[email protected]>2026-01-24 13:09:50 +0000
commit396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b (patch)
treeb9df4ca6a70db45cfffbae6fdd7252e20fb8e93c /src/components/charts
downloadumami-396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b.tar.xz
umami-396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b.zip
Initial commitHEADmain
Created from https://vercel.com/new
Diffstat (limited to 'src/components/charts')
-rw-r--r--src/components/charts/BarChart.tsx131
-rw-r--r--src/components/charts/BubbleChart.tsx31
-rw-r--r--src/components/charts/Chart.tsx130
-rw-r--r--src/components/charts/ChartTooltip.tsx23
-rw-r--r--src/components/charts/PieChart.tsx31
5 files changed, 346 insertions, 0 deletions
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 (
+ <>
+ <Chart
+ {...props}
+ type="bar"
+ chartData={chartData}
+ chartOptions={chartOptions}
+ onTooltip={handleTooltip}
+ />
+ {tooltip && <ChartTooltip {...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 (
+ <>
+ <Chart {...props} type={type} onTooltip={handleTooltip} />
+ {tooltip && <ChartTooltip {...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 (
+ <Column gap="6">
+ <Box {...props}>
+ <canvas ref={canvas} />
+ </Box>
+ <Legend items={legendItems} onClick={handleLegendClick} />
+ </Column>
+ );
+}
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 (
+ <FloatingTooltip>
+ <Column gap="3" fontSize="1">
+ {title && <Row alignItems="center">{title}</Row>}
+ <Row alignItems="center">
+ <StatusLight color={color}>{value}</StatusLight>
+ </Row>
+ </Column>
+ </FloatingTooltip>
+ );
+}
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 (
+ <>
+ <Chart {...props} type={type} onTooltip={handleTooltip} />
+ {tooltip && <ChartTooltip {...tooltip} />}
+ </>
+ );
+}