60
common/resources/client/charts/bar-chart.tsx
Executable file
60
common/resources/client/charts/bar-chart.tsx
Executable file
@@ -0,0 +1,60 @@
|
||||
import {BaseChart, BaseChartProps} from './base-chart';
|
||||
import {ChartData, ChartOptions} from 'chart.js';
|
||||
import {ChartColors} from './chart-colors';
|
||||
import {useSelectedLocale} from '../i18n/selected-locale';
|
||||
import {FormattedDatasetItem} from './data/formatted-dataset-item';
|
||||
import {useMemo} from 'react';
|
||||
import {formatReportData} from './data/format-report-data';
|
||||
import {DatasetItem, ReportMetric} from '../admin/analytics/report-metric';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface BarChartProps extends Omit<BaseChartProps<'bar'>, 'type' | 'data'> {
|
||||
direction?: 'horizontal' | 'vertical';
|
||||
individualBarColors?: boolean;
|
||||
data?: ReportMetric<DatasetItem>;
|
||||
}
|
||||
export function BarChart({
|
||||
data,
|
||||
direction = 'vertical',
|
||||
individualBarColors = false,
|
||||
className,
|
||||
...props
|
||||
}: BarChartProps) {
|
||||
const {localeCode} = useSelectedLocale();
|
||||
const formattedData: ChartData<'bar', FormattedDatasetItem[]> =
|
||||
useMemo(() => {
|
||||
const formattedData = formatReportData(data, {localeCode});
|
||||
formattedData.datasets = formattedData.datasets.map((dataset, i) => ({
|
||||
...dataset,
|
||||
backgroundColor: individualBarColors
|
||||
? ChartColors.map(c => c[1])
|
||||
: ChartColors[i][1],
|
||||
borderColor: individualBarColors
|
||||
? ChartColors.map(c => c[0])
|
||||
: ChartColors[i][0],
|
||||
borderWidth: 2,
|
||||
}));
|
||||
return formattedData;
|
||||
}, [data, localeCode, individualBarColors]);
|
||||
|
||||
const isHorizontal = direction === 'horizontal';
|
||||
const options: ChartOptions<'bar'> = useMemo(() => {
|
||||
return {
|
||||
indexAxis: isHorizontal ? 'y' : 'x',
|
||||
parsing: {
|
||||
xAxisKey: isHorizontal ? 'value' : 'label',
|
||||
yAxisKey: isHorizontal ? 'label' : 'value',
|
||||
},
|
||||
};
|
||||
}, [isHorizontal]);
|
||||
|
||||
return (
|
||||
<BaseChart
|
||||
type="bar"
|
||||
className={clsx(className, 'min-w-500')}
|
||||
data={formattedData}
|
||||
options={options}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
33
common/resources/client/charts/base-chart.tsx
Executable file
33
common/resources/client/charts/base-chart.tsx
Executable file
@@ -0,0 +1,33 @@
|
||||
import type {ChartData, ChartOptions, ChartType} from 'chart.js';
|
||||
import {lazy, Suspense} from 'react';
|
||||
import {ChartLayout, ChartLayoutProps} from './chart-layout';
|
||||
import {ChartLoadingIndicator} from '@common/charts/chart-loading-indicator';
|
||||
|
||||
const LazyChart = lazy(() => import('./lazy-chart'));
|
||||
|
||||
export interface BaseChartProps<Type extends ChartType = ChartType>
|
||||
extends Omit<ChartLayoutProps, 'children'> {
|
||||
type: Type;
|
||||
data: ChartData<Type, unknown>;
|
||||
options?: ChartOptions<Type>;
|
||||
hideLegend?: boolean;
|
||||
}
|
||||
export function BaseChart<Type extends ChartType = ChartType>(
|
||||
props: BaseChartProps<Type>
|
||||
) {
|
||||
const {title, description, className, contentRef, isLoading} = props;
|
||||
|
||||
return (
|
||||
<ChartLayout
|
||||
title={title}
|
||||
description={description}
|
||||
className={className}
|
||||
contentRef={contentRef}
|
||||
>
|
||||
<Suspense fallback={<ChartLoadingIndicator />}>
|
||||
<LazyChart {...props} />
|
||||
{isLoading && <ChartLoadingIndicator />}
|
||||
</Suspense>
|
||||
</ChartLayout>
|
||||
);
|
||||
}
|
||||
14
common/resources/client/charts/chart-colors.tsx
Executable file
14
common/resources/client/charts/chart-colors.tsx
Executable file
@@ -0,0 +1,14 @@
|
||||
import {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';
|
||||
|
||||
const primaryColor = getBootstrapData().themes.all[0].values['--be-primary'];
|
||||
export const ChartColors = [
|
||||
[
|
||||
`rgb(${primaryColor.replaceAll(' ', ',')})`,
|
||||
`rgba(${primaryColor.replaceAll(' ', ',')},0.2)`,
|
||||
],
|
||||
['rgb(255,112,67)', 'rgb(255,112,67,0.2)'],
|
||||
['rgb(255,167,38)', 'rgb(255,167,38,0.2)'],
|
||||
['rgb(141,110,99)', 'rgb(141,110,99,0.2)'],
|
||||
['rgb(102,187,106)', 'rgba(102,187,106,0.2)'],
|
||||
['rgb(92,107,192)', 'rgb(92,107,192,0.2)'],
|
||||
];
|
||||
51
common/resources/client/charts/chart-layout.tsx
Executable file
51
common/resources/client/charts/chart-layout.tsx
Executable file
@@ -0,0 +1,51 @@
|
||||
import {ReactNode, Ref} from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface ChartLayoutProps {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
contentIsFlex?: boolean;
|
||||
contentClassName?: string;
|
||||
minHeight?: string;
|
||||
contentRef?: Ref<HTMLDivElement>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
export function ChartLayout(props: ChartLayoutProps) {
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
contentIsFlex = true,
|
||||
contentClassName,
|
||||
contentRef,
|
||||
minHeight = 'min-h-440',
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-panel flex h-full flex-auto flex-col border bg',
|
||||
minHeight,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-shrink-0 items-center justify-between p-14 text-xs">
|
||||
<div className="text-sm font-semibold">{title}</div>
|
||||
{description && <div className="text-muted">{description}</div>}
|
||||
</div>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={clsx(
|
||||
'relative p-14',
|
||||
contentIsFlex && 'flex flex-auto items-center justify-center',
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
common/resources/client/charts/chart-loading-indicator.tsx
Executable file
11
common/resources/client/charts/chart-loading-indicator.tsx
Executable file
@@ -0,0 +1,11 @@
|
||||
import {ProgressCircle} from '@common/ui/progress/progress-circle';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
|
||||
export function ChartLoadingIndicator() {
|
||||
return (
|
||||
<div className="flex items-center gap-10 text-sm absolute mx-auto">
|
||||
<ProgressCircle isIndeterminate size="sm" />
|
||||
<Trans message="Chart loading" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
common/resources/client/charts/data/format-report-data.ts
Executable file
165
common/resources/client/charts/data/format-report-data.ts
Executable file
@@ -0,0 +1,165 @@
|
||||
import {DateFormatter, parseAbsoluteToLocal} from '@internationalized/date';
|
||||
import memoize from 'nano-memoize';
|
||||
import {ChartType} from 'chart.js';
|
||||
import {
|
||||
FormattedDatasetItem,
|
||||
FormattedReportData,
|
||||
} from './formatted-dataset-item';
|
||||
import {
|
||||
DatasetItem,
|
||||
RangedDatasetGranularity,
|
||||
ReportMetric,
|
||||
} from '../../admin/analytics/report-metric';
|
||||
import {shallowEqual} from '../../utils/shallow-equal';
|
||||
|
||||
interface Options {
|
||||
localeCode: string;
|
||||
shareFirstDatasetLabels?: boolean;
|
||||
}
|
||||
|
||||
type FormattedDatasetLabels = Omit<FormattedDatasetItem, 'value'>;
|
||||
|
||||
export function formatReportData(
|
||||
report: ReportMetric<DatasetItem> | undefined,
|
||||
{localeCode = 'en', shareFirstDatasetLabels = true}: Options,
|
||||
): FormattedReportData {
|
||||
if (!report) return {datasets: []};
|
||||
|
||||
const firstDatasetLabels: FormattedDatasetLabels[] = [];
|
||||
|
||||
return {
|
||||
...report,
|
||||
datasets: report.datasets.map((dataset, datasetIndex) => {
|
||||
const data = dataset.data.map((datasetItem, itemIndex) => {
|
||||
let label: FormattedDatasetLabels;
|
||||
// when there are multiple datasets, we'll need to use labels from the first dataset, so charts are
|
||||
// overlapped over one another, otherwise they will be side by side, if labels in all datasets are not identical.
|
||||
if (datasetIndex === 0 || !shareFirstDatasetLabels) {
|
||||
label = generateDatasetLabels(
|
||||
datasetItem,
|
||||
report.granularity,
|
||||
localeCode,
|
||||
);
|
||||
firstDatasetLabels[itemIndex] = label;
|
||||
} else {
|
||||
label = firstDatasetLabels[itemIndex];
|
||||
}
|
||||
return {
|
||||
...label,
|
||||
value: datasetItem.value,
|
||||
};
|
||||
});
|
||||
|
||||
return {...dataset, data};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function generateDatasetLabels<T extends ChartType = ChartType>(
|
||||
datum: DatasetItem,
|
||||
granularity: RangedDatasetGranularity | undefined,
|
||||
locale: string,
|
||||
): FormattedDatasetLabels {
|
||||
if (datum.label) {
|
||||
return {label: datum.label};
|
||||
}
|
||||
|
||||
if (!datum.date) {
|
||||
return {label: ''};
|
||||
}
|
||||
|
||||
return generateTimeLabels(datum, granularity, locale);
|
||||
}
|
||||
|
||||
function generateTimeLabels(
|
||||
{date: isoDate, endDate: isoEndDate}: DatasetItem,
|
||||
granularity: RangedDatasetGranularity | undefined = 'day',
|
||||
locale: string,
|
||||
): Omit<FormattedDatasetItem, 'value'> {
|
||||
const date = parseAbsoluteToLocal(isoDate!).toDate();
|
||||
const endDate = isoEndDate ? parseAbsoluteToLocal(isoEndDate).toDate() : null;
|
||||
|
||||
switch (granularity) {
|
||||
case 'minute':
|
||||
return {
|
||||
label: getFormatter(locale, {
|
||||
second: '2-digit',
|
||||
}).format(date),
|
||||
tooltipTitle: getFormatter(locale, {
|
||||
day: '2-digit',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: '2-digit',
|
||||
}).format(date),
|
||||
};
|
||||
case 'hour':
|
||||
return {
|
||||
label: getFormatter(locale, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
}).format(date),
|
||||
tooltipTitle: getFormatter(locale, {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
}).format(date),
|
||||
};
|
||||
case 'day':
|
||||
return {
|
||||
label: getFormatter(locale, {
|
||||
day: '2-digit',
|
||||
weekday: 'short',
|
||||
}).format(date),
|
||||
tooltipTitle: getFormatter(locale, {
|
||||
day: '2-digit',
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
}).format(date),
|
||||
};
|
||||
case 'week':
|
||||
return {
|
||||
label: getFormatter(locale, {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
}).format(date),
|
||||
tooltipTitle: getFormatter(locale, {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).formatRange(date, endDate as Date),
|
||||
};
|
||||
case 'month':
|
||||
return {
|
||||
label: getFormatter(locale, {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}).format(date),
|
||||
tooltipTitle: getFormatter(locale, {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(date),
|
||||
};
|
||||
case 'year':
|
||||
return {
|
||||
label: getFormatter(locale, {
|
||||
year: 'numeric',
|
||||
}).format(date),
|
||||
tooltipTitle: getFormatter(locale, {
|
||||
year: 'numeric',
|
||||
}).format(date),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const getFormatter = memoize(
|
||||
(locale, options: Intl.DateTimeFormatOptions) => {
|
||||
return new DateFormatter(locale, options);
|
||||
},
|
||||
{
|
||||
equals: (a, b) => {
|
||||
return shallowEqual(a, b);
|
||||
},
|
||||
callTimeout: undefined as any,
|
||||
},
|
||||
);
|
||||
11
common/resources/client/charts/data/formatted-dataset-item.ts
Executable file
11
common/resources/client/charts/data/formatted-dataset-item.ts
Executable file
@@ -0,0 +1,11 @@
|
||||
import {ReportMetric} from '../../admin/analytics/report-metric';
|
||||
|
||||
export interface FormattedReportData extends Omit<ReportMetric, 'datasets'> {
|
||||
datasets: {label: string; data: FormattedDatasetItem[]}[];
|
||||
}
|
||||
|
||||
export interface FormattedDatasetItem {
|
||||
label: string;
|
||||
value: number;
|
||||
tooltipTitle?: string;
|
||||
}
|
||||
91
common/resources/client/charts/lazy-chart.tsx
Executable file
91
common/resources/client/charts/lazy-chart.tsx
Executable file
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
ArcElement,
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart,
|
||||
ChartOptions,
|
||||
ChartType,
|
||||
Filler,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
PolarAreaController,
|
||||
RadialLinearScale,
|
||||
Tooltip,
|
||||
} from 'chart.js';
|
||||
import {useEffect, useRef} from 'react';
|
||||
import {BaseChartProps} from './base-chart';
|
||||
import {FormattedDatasetItem} from './data/formatted-dataset-item';
|
||||
import deepMerge from 'deepmerge';
|
||||
|
||||
Chart.register([
|
||||
LineElement,
|
||||
PointElement,
|
||||
BarElement,
|
||||
ArcElement,
|
||||
LineController,
|
||||
BarController,
|
||||
PolarAreaController,
|
||||
RadialLinearScale,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
Tooltip,
|
||||
Filler,
|
||||
Legend,
|
||||
]);
|
||||
|
||||
export default function LazyChart({
|
||||
type,
|
||||
data,
|
||||
options,
|
||||
hideLegend,
|
||||
}: Omit<BaseChartProps<any>, 'children'>) {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
const chartRef = useRef<Chart<ChartType, unknown>>();
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
chartRef.current = new Chart(ref.current, {
|
||||
type,
|
||||
data,
|
||||
options: deepMerge(
|
||||
{
|
||||
maintainAspectRatio: false,
|
||||
animation: {
|
||||
duration: 250,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
display: !hideLegend,
|
||||
},
|
||||
tooltip: {
|
||||
padding: 16,
|
||||
cornerRadius: 4,
|
||||
callbacks: {
|
||||
title: ([item]) => {
|
||||
const data = item.raw as FormattedDatasetItem;
|
||||
return data.tooltipTitle ?? item.label;
|
||||
},
|
||||
label: item => {
|
||||
return ` ${item.dataset.label}: ${item.formattedValue}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
options as ChartOptions
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
chartRef.current?.destroy();
|
||||
};
|
||||
}, [data, type, options, hideLegend]);
|
||||
|
||||
return <canvas ref={ref}></canvas>;
|
||||
}
|
||||
58
common/resources/client/charts/line-chart.tsx
Executable file
58
common/resources/client/charts/line-chart.tsx
Executable file
@@ -0,0 +1,58 @@
|
||||
import {BaseChart, BaseChartProps} from './base-chart';
|
||||
import {DatasetItem, ReportMetric} from '../admin/analytics/report-metric';
|
||||
import {useMemo} from 'react';
|
||||
import {formatReportData} from './data/format-report-data';
|
||||
import {useSelectedLocale} from '../i18n/selected-locale';
|
||||
import {ChartData, ChartOptions} from 'chart.js';
|
||||
import {ChartColors} from './chart-colors';
|
||||
import {FormattedDatasetItem} from './data/formatted-dataset-item';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const LineChartOptions: ChartOptions<'line'> = {
|
||||
parsing: {
|
||||
xAxisKey: 'label',
|
||||
yAxisKey: 'value',
|
||||
},
|
||||
datasets: {
|
||||
line: {
|
||||
fill: 'origin',
|
||||
tension: 0.1,
|
||||
pointBorderWidth: 4,
|
||||
pointHitRadius: 10,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface LineChartProps extends Omit<BaseChartProps<'line'>, 'type' | 'data'> {
|
||||
data?: ReportMetric<DatasetItem>;
|
||||
}
|
||||
export function LineChart({data, className, ...props}: LineChartProps) {
|
||||
const {localeCode} = useSelectedLocale();
|
||||
const formattedData: ChartData<'line', FormattedDatasetItem[]> =
|
||||
useMemo(() => {
|
||||
const formattedData = formatReportData(data, {localeCode});
|
||||
formattedData.datasets = formattedData.datasets.map((dataset, i) => ({
|
||||
...dataset,
|
||||
backgroundColor: ChartColors[i][1],
|
||||
borderColor: ChartColors[i][0],
|
||||
pointBackgroundColor: ChartColors[i][0],
|
||||
}));
|
||||
return formattedData;
|
||||
}, [data, localeCode]);
|
||||
|
||||
return (
|
||||
<BaseChart
|
||||
{...props}
|
||||
className={clsx(className, 'min-w-500')}
|
||||
data={formattedData}
|
||||
type="line"
|
||||
options={LineChartOptions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
54
common/resources/client/charts/polar-area-chart.tsx
Executable file
54
common/resources/client/charts/polar-area-chart.tsx
Executable file
@@ -0,0 +1,54 @@
|
||||
import {BaseChart, BaseChartProps} from './base-chart';
|
||||
import {ChartData, ChartOptions} from 'chart.js';
|
||||
import {ChartColors} from './chart-colors';
|
||||
import {useSelectedLocale} from '../i18n/selected-locale';
|
||||
import {useMemo} from 'react';
|
||||
import {formatReportData} from './data/format-report-data';
|
||||
import {DatasetItem, ReportMetric} from '../admin/analytics/report-metric';
|
||||
import {FormattedDatasetItem} from './data/formatted-dataset-item';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const PolarAreaChartOptions: ChartOptions<'polarArea'> = {
|
||||
parsing: {
|
||||
key: 'value',
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
intersect: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface PolarAreaChartProps
|
||||
extends Omit<BaseChartProps<'polarArea'>, 'type' | 'data'> {
|
||||
data?: ReportMetric<DatasetItem>;
|
||||
}
|
||||
export function PolarAreaChart({
|
||||
data,
|
||||
className,
|
||||
...props
|
||||
}: PolarAreaChartProps) {
|
||||
const {localeCode} = useSelectedLocale();
|
||||
const formattedData: ChartData<'polarArea', FormattedDatasetItem[]> =
|
||||
useMemo(() => {
|
||||
const formattedData = formatReportData(data, {localeCode});
|
||||
formattedData.labels = formattedData.datasets[0]?.data.map(d => d.label);
|
||||
formattedData.datasets = formattedData.datasets.map((dataset, i) => ({
|
||||
...dataset,
|
||||
backgroundColor: ChartColors.map(c => c[1]),
|
||||
borderColor: ChartColors.map(c => c[0]),
|
||||
borderWidth: 2,
|
||||
}));
|
||||
return formattedData;
|
||||
}, [data, localeCode]);
|
||||
|
||||
return (
|
||||
<BaseChart
|
||||
type="polarArea"
|
||||
data={formattedData}
|
||||
options={PolarAreaChartOptions}
|
||||
className={clsx(className, 'min-w-500')}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user