Created PieChart component. Refactored charts.

This commit is contained in:
Mike Cao 2024-03-14 20:26:52 -07:00
parent f277580722
commit f6524392e2
15 changed files with 403 additions and 336 deletions

View File

@ -1,24 +1,14 @@
.title {
font-size: 18px;
line-height: 36px;
font-weight: 700;
}
.params {
display: grid;
gap: 10px;
padding: 20px 0;
}
.row {
display: flex;
display: grid;
grid-template-columns: 50% 50%;
gap: 20px;
}
.label {
min-width: 200px;
}
.value {
min-width: 50px;
text-align: right;
border-bottom: 1px solid var(--base300);
padding-bottom: 30px;
margin-bottom: 30px;
}

View File

@ -1,8 +1,11 @@
import { useContext } from 'react';
import { firstBy } from 'thenby';
import { ReportContext } from '../[reportId]/Report';
import { CHART_COLORS, UTM_PARAMS } from 'lib/constants';
import PieChart from 'components/charts/PieChart';
import ListTable from 'components/metrics/ListTable';
import styles from './UTMView.module.css';
import { UTM_PARAMS } from 'lib/constants';
import { useMessages } from 'components/hooks';
function toArray(data: { [key: string]: number }) {
return Object.keys(data)
@ -13,6 +16,7 @@ function toArray(data: { [key: string]: number }) {
}
export default function UTMView() {
const { formatMessage, labels } = useMessages();
const { report } = useContext(ReportContext);
const { data } = report || {};
@ -23,18 +27,35 @@ export default function UTMView() {
return (
<div>
{UTM_PARAMS.map(key => {
const items = toArray(data[key]);
const chartData = {
labels: items.map(({ name }) => name),
datasets: [
{
data: items.map(({ value }) => value),
backgroundColor: CHART_COLORS,
},
],
};
const total = items.reduce((sum, { value }) => {
return +sum + +value;
}, 0);
return (
<div key={key}>
<div className={styles.title}>{key}</div>
<div className={styles.params}>
{toArray(data[key]).map(({ name, value }) => {
return (
<div key={name} className={styles.row}>
<div className={styles.label}>{name}</div>
<div className={styles.value}>{value}</div>
</div>
);
})}
<div key={key} className={styles.row}>
<div>
<div className={styles.title}>{key}</div>
<ListTable
metric={formatMessage(labels.views)}
data={items.map(({ name, value }) => ({
x: name,
y: value,
z: (value / total) * 100,
}))}
/>
</div>
<div>
<PieChart type="doughnut" data={chartData} />
</div>
</div>
);

View File

@ -0,0 +1,85 @@
import { useTheme } from 'components/hooks';
import Chart, { ChartProps } from 'components/charts/Chart';
import { renderNumberLabels } from 'lib/charts';
import { useState } from 'react';
import BarChartTooltip from 'components/charts/BarChartTooltip';
export interface BarChartProps extends ChartProps {
unit: string;
stacked?: boolean;
renderXLabel?: (label: string, index: number, values: any[]) => string;
renderYLabel?: (label: string, index: number, values: any[]) => string;
XAxisType?: string;
YAxisType?: string;
}
export function BarChart(props: BarChartProps) {
const [tooltip, setTooltip] = useState(null);
const { colors } = useTheme();
const {
renderXLabel,
renderYLabel,
unit,
XAxisType = 'time',
YAxisType = 'linear',
stacked = false,
} = props;
const options = {
scales: {
x: {
type: XAxisType,
stacked: 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,
grid: {
color: colors.chart.line,
},
border: {
color: colors.chart.line,
},
ticks: {
color: colors.chart.text,
callback: renderYLabel || renderNumberLabels,
},
},
},
};
const handleTooltip = ({ tooltip }: { tooltip: any }) => {
const { opacity } = tooltip;
setTooltip(opacity ? <BarChartTooltip tooltip={tooltip} unit={unit} /> : null);
};
return (
<Chart
{...props}
type="bar"
chartOptions={options}
tooltip={tooltip}
onTooltip={handleTooltip}
/>
);
}
export default BarChart;

View File

@ -0,0 +1,32 @@
import { formatDate } from 'lib/date';
import { Flexbox, StatusLight } from 'react-basics';
import { formatLongNumber } from 'lib/format';
import { useLocale } from 'components/hooks';
const formats = {
millisecond: 'T',
second: 'pp',
minute: 'p',
hour: 'h:mm aaa - PP',
day: 'PPPP',
week: 'PPPP',
month: 'LLLL yyyy',
quarter: 'qqq',
year: 'yyyy',
};
export default function BarChartTooltip({ tooltip, unit }) {
const { locale } = useLocale();
const { labelColors, dataPoints } = tooltip;
return (
<Flexbox direction="column" gap={10}>
<div>{formatDate(new Date(dataPoints[0].raw.x), formats[unit], locale)}</div>
<div>
<StatusLight color={labelColors?.[0]?.backgroundColor}>
{formatLongNumber(dataPoints[0].raw.y)} {dataPoints[0].dataset.label}
</StatusLight>
</div>
</Flexbox>
);
}

View File

@ -0,0 +1,141 @@
import { useState, useRef, useEffect, ReactNode } from 'react';
import { Loading } from 'react-basics';
import classNames from 'classnames';
import ChartJS, { LegendItem } from 'chart.js/auto';
import HoverTooltip from 'components/common/HoverTooltip';
import Legend from 'components/metrics/Legend';
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
import styles from './Chart.module.css';
export interface ChartProps {
type?: 'bar' | 'bubble' | 'doughnut' | 'pie' | 'line' | 'polarArea' | 'radar' | 'scatter';
data?: object;
isLoading?: boolean;
animationDuration?: number;
updateMode?: string;
onCreate?: (chart: any) => void;
onUpdate?: (chart: any) => void;
onTooltip?: (model: any) => void;
className?: string;
chartOptions?: { [key: string]: any };
tooltip?: ReactNode;
}
export function Chart({
type,
data,
isLoading = false,
animationDuration = DEFAULT_ANIMATION_DURATION,
tooltip,
updateMode,
onCreate,
onUpdate,
onTooltip,
className,
chartOptions,
}: ChartProps) {
const canvas = useRef();
const chart = useRef(null);
const [legendItems, setLegendItems] = useState([]);
const options = {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: animationDuration,
resize: {
duration: 0,
},
active: {
duration: 0,
},
},
plugins: {
legend: {
display: false,
},
tooltip: {
enabled: false,
external: onTooltip,
},
},
...chartOptions,
};
const createChart = (data: any) => {
ChartJS.defaults.font.family = 'Inter';
chart.current = new ChartJS(canvas.current, {
type,
data,
options,
});
onCreate?.(chart.current);
setLegendItems(chart.current.legend.legendItems);
};
const updateChart = (data: any) => {
chart.current.data.datasets.forEach((dataset: { data: any }, index: string | number) => {
dataset.data = data?.datasets[index]?.data;
});
chart.current.options = options;
// Allow config changes before update
onUpdate?.(chart.current);
chart.current.update(updateMode);
setLegendItems(chart.current.legend.legendItems);
};
useEffect(() => {
if (data) {
if (!chart.current) {
createChart(data);
} else {
updateChart(data);
}
}
}, [data]);
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);
};
return (
<>
<div className={classNames(styles.chart, className)}>
{isLoading && <Loading position="page" icon="dots" />}
<canvas ref={canvas} />
</div>
<Legend items={legendItems} onClick={handleLegendClick} />
{tooltip && (
<HoverTooltip>
<div className={styles.tooltip}>{tooltip}</div>
</HoverTooltip>
)}
</>
);
}
export default Chart;

View File

@ -0,0 +1,27 @@
import { Chart, ChartProps } from 'components/charts/Chart';
import { useState } from 'react';
import { StatusLight } from 'react-basics';
import { formatLongNumber } from 'lib/format';
export interface PieChartProps extends ChartProps {
type?: 'doughnut' | 'pie';
}
export default function PieChart(props: PieChartProps) {
const [tooltip, setTooltip] = useState(null);
const { type } = props;
const handleTooltip = ({ tooltip }) => {
const { labelColors, dataPoints } = tooltip;
setTooltip(
tooltip.opacity ? (
<StatusLight color={labelColors?.[0]?.backgroundColor}>
{formatLongNumber(dataPoints?.[0]?.raw)} {dataPoints?.[0]?.label}
</StatusLight>
) : null,
);
};
return <Chart {...props} type={type || 'pie'} tooltip={tooltip} onTooltip={handleTooltip} />;
}

View File

@ -1,6 +1,7 @@
import Link from 'next/link';
import { Flexbox, Icon, Icons, Text } from 'react-basics';
import styles from './Breadcrumb.module.css';
import { Fragment } from 'react';
export interface BreadcrumbProps {
data: {
@ -14,7 +15,7 @@ export function Breadcrumb({ data }: BreadcrumbProps) {
<Flexbox alignItems="center" gap={3} className={styles.bar}>
{data.map((a, i) => {
return (
<>
<Fragment key={i}>
{a.url ? (
<Link href={a.url} className={styles.link}>
<Text>{a.label}</Text>
@ -27,7 +28,7 @@ export function Breadcrumb({ data }: BreadcrumbProps) {
<Icons.ChevronDown />
</Icon>
) : null}
</>
</Fragment>
);
})}
</Flexbox>

View File

@ -1,192 +0,0 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { Loading } from 'react-basics';
import classNames from 'classnames';
import Chart from 'chart.js/auto';
import HoverTooltip from 'components/common/HoverTooltip';
import Legend from 'components/metrics/Legend';
import { useLocale } from 'components/hooks';
import { useTheme } from 'components/hooks';
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
import { renderNumberLabels } from 'lib/charts';
import styles from './BarChart.module.css';
export interface BarChartProps {
datasets?: any[];
unit?: string;
animationDuration?: number;
stacked?: boolean;
isLoading?: boolean;
renderXLabel?: (label: string, index: number, values: any[]) => string;
renderYLabel?: (label: string, index: number, values: any[]) => string;
XAxisType?: string;
YAxisType?: string;
renderTooltipPopup?: (setTooltipPopup: (data: any) => void, model: any) => void;
updateMode?: string;
onCreate?: (chart: any) => void;
onUpdate?: (chart: any) => void;
className?: string;
}
export function BarChart({
datasets = [],
unit,
animationDuration = DEFAULT_ANIMATION_DURATION,
stacked = false,
isLoading = false,
renderXLabel,
renderYLabel,
XAxisType = 'time',
YAxisType = 'linear',
renderTooltipPopup,
updateMode,
onCreate,
onUpdate,
className,
}: BarChartProps) {
const canvas = useRef();
const chart = useRef(null);
const [tooltip, setTooltipPopup] = useState(null);
const { locale } = useLocale();
const { theme, colors } = useTheme();
const [legendItems, setLegendItems] = useState([]);
const getOptions = useCallback(() => {
return {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: animationDuration,
resize: {
duration: 0,
},
active: {
duration: 0,
},
},
plugins: {
legend: {
display: false,
},
tooltip: {
enabled: false,
external: renderTooltipPopup ? renderTooltipPopup.bind(null, setTooltipPopup) : undefined,
},
},
scales: {
x: {
type: XAxisType,
stacked: 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,
grid: {
color: colors.chart.line,
},
border: {
color: colors.chart.line,
},
ticks: {
color: colors.chart.text,
callback: renderYLabel || renderNumberLabels,
},
},
},
};
}, [
animationDuration,
renderTooltipPopup,
renderXLabel,
XAxisType,
YAxisType,
stacked,
colors,
unit,
locale,
]);
const createChart = (datasets: any[]) => {
Chart.defaults.font.family = 'Inter';
chart.current = new Chart(canvas.current, {
type: 'bar',
data: {
datasets,
},
options: getOptions() as any,
});
onCreate?.(chart.current);
setLegendItems(chart.current.legend.legendItems);
};
const updateChart = (datasets: any[]) => {
setTooltipPopup(null);
chart.current.data.datasets.forEach((dataset: { data: any }, index: string | number) => {
dataset.data = datasets[index]?.data;
});
chart.current.options = getOptions();
// Allow config changes before update
onUpdate?.(chart.current);
chart.current.update(updateMode);
setLegendItems(chart.current.legend.legendItems);
};
useEffect(() => {
if (datasets) {
if (!chart.current) {
createChart(datasets);
} else {
updateChart(datasets);
}
}
}, [datasets, unit, theme, animationDuration, locale]);
const handleLegendClick = (index: number) => {
const meta = chart.current.getDatasetMeta(index);
meta.hidden = meta.hidden === null ? !chart.current.data.datasets[index].hidden : null;
chart.current.update();
};
return (
<>
<div className={classNames(styles.chart, className)}>
{isLoading && <Loading position="page" icon="dots" />}
<canvas ref={canvas} />
</div>
<Legend items={legendItems} onClick={handleLegendClick} />
{tooltip && (
<HoverTooltip>
<div className={styles.tooltip}>{tooltip}</div>
</HoverTooltip>
)}
</>
);
}
export default BarChart;

View File

@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { Loading } from 'react-basics';
import { colord } from 'colord';
import BarChart from './BarChart';
import BarChart from 'components/charts/BarChart';
import { getDateArray } from 'lib/date';
import {
useLocale,
@ -10,8 +10,8 @@ import {
useNavigation,
useWebsiteEvents,
} from 'components/hooks';
import { EVENT_COLORS } from 'lib/constants';
import { renderDateLabels, renderStatusTooltipPopup } from 'lib/charts';
import { CHART_COLORS } from 'lib/constants';
import { renderDateLabels } from 'lib/charts';
export interface EventsChartProps {
websiteId: string;
@ -26,7 +26,6 @@ export function EventsChart({ websiteId, className, token }: EventsChartProps) {
const {
query: { url, event },
} = useNavigation();
const { data, isLoading } = useWebsiteEvents(websiteId, {
startAt: +startDate,
endAt: +endDate,
@ -38,9 +37,8 @@ export function EventsChart({ websiteId, className, token }: EventsChartProps) {
offset,
});
const datasets = useMemo(() => {
const chartData = useMemo(() => {
if (!data) return [];
if (isLoading) return data;
const map = (data as any[]).reduce((obj, { x, t, y }) => {
if (!obj[x]) {
@ -56,18 +54,20 @@ export function EventsChart({ websiteId, className, token }: EventsChartProps) {
map[key] = getDateArray(map[key], startDate, endDate, unit);
});
return Object.keys(map).map((key, index) => {
const color = colord(EVENT_COLORS[index % EVENT_COLORS.length]);
return {
label: key,
data: map[key],
lineTension: 0,
backgroundColor: color.alpha(0.6).toRgbString(),
borderColor: color.alpha(0.7).toRgbString(),
borderWidth: 1,
};
});
}, [data, isLoading, startDate, endDate, unit]);
return {
datasets: Object.keys(map).map((key, index) => {
const color = colord(CHART_COLORS[index % CHART_COLORS.length]);
return {
label: key,
data: map[key],
lineTension: 0,
backgroundColor: color.alpha(0.6).toRgbString(),
borderColor: color.alpha(0.7).toRgbString(),
borderWidth: 1,
};
}),
};
}, [data, startDate, endDate, unit]);
if (isLoading) {
return <Loading icon="dots" />;
@ -76,11 +76,10 @@ export function EventsChart({ websiteId, className, token }: EventsChartProps) {
return (
<BarChart
className={className}
datasets={datasets as any[]}
data={chartData}
unit={unit}
stacked={true}
renderXLabel={renderDateLabels(unit, locale)}
renderTooltipPopup={renderStatusTooltipPopup(unit, locale)}
isLoading={isLoading}
/>
);

View File

@ -1,9 +1,8 @@
import { useEffect } from 'react';
import { StatusLight } from 'react-basics';
import { colord } from 'colord';
import classNames from 'classnames';
import { LegendItem } from 'chart.js/auto';
import { useLocale } from 'components/hooks';
import { useForceUpdate } from 'components/hooks';
import styles from './Legend.module.css';
export function Legend({
@ -11,14 +10,9 @@ export function Legend({
onClick,
}: {
items: any[];
onClick: (index: number) => void;
onClick: (index: LegendItem) => void;
}) {
const { locale } = useLocale();
const forceUpdate = useForceUpdate();
useEffect(() => {
forceUpdate();
}, [locale, forceUpdate]);
if (!items.find(({ text }) => text)) {
return null;
@ -26,14 +20,15 @@ export function Legend({
return (
<div className={styles.legend}>
{items.map(({ text, fillStyle, datasetIndex, hidden }) => {
{items.map(item => {
const { text, fillStyle, hidden } = item;
const color = colord(fillStyle);
return (
<div
key={text}
className={classNames(styles.label, { [styles.hidden]: hidden })}
onClick={() => onClick(datasetIndex)}
onClick={() => onClick(item)}
>
<StatusLight color={color.alpha(color.alpha() + 0.2).toHex()}>
<span className={locale}>{text}</span>

View File

@ -1,7 +1,7 @@
import { useMemo } from 'react';
import BarChart, { BarChartProps } from './BarChart';
import BarChart, { BarChartProps } from 'components/charts/BarChart';
import { useLocale, useTheme, useMessages } from 'components/hooks';
import { renderDateLabels, renderStatusTooltipPopup } from 'lib/charts';
import { renderDateLabels } from 'lib/charts';
export interface PageviewsChartProps extends BarChartProps {
data: {
@ -17,33 +17,36 @@ export function PageviewsChart({ data, unit, isLoading, ...props }: PageviewsCha
const { colors } = useTheme();
const { locale } = useLocale();
const datasets = useMemo(() => {
if (!data) return [];
const chartData = useMemo(() => {
if (!data) {
return {};
}
return [
{
label: formatMessage(labels.visitors),
data: data.sessions,
borderWidth: 1,
...colors.chart.visitors,
},
{
label: formatMessage(labels.views),
data: data.pageviews,
borderWidth: 1,
...colors.chart.views,
},
];
}, [data, colors, formatMessage, labels]);
return {
datasets: [
{
label: formatMessage(labels.visitors),
data: data.sessions,
borderWidth: 1,
...colors.chart.visitors,
},
{
label: formatMessage(labels.views),
data: data.pageviews,
borderWidth: 1,
...colors.chart.views,
},
],
};
}, [data]);
return (
<BarChart
{...props}
datasets={datasets}
data={chartData}
unit={unit}
isLoading={isLoading}
renderXLabel={renderDateLabels(unit, locale)}
renderTooltipPopup={renderStatusTooltipPopup(unit, locale)}
/>
);
}

27
src/lib/charts.ts Normal file
View File

@ -0,0 +1,27 @@
import { formatDate } from 'lib/date';
import { formatLongNumber } from 'lib/format';
export function renderNumberLabels(label: string) {
return +label > 1000 ? formatLongNumber(+label) : label;
}
export function renderDateLabels(unit: string, locale: string) {
return (label: string, index: number, values: any[]) => {
const d = new Date(values[index].value);
switch (unit) {
case 'minute':
return formatDate(d, 'h:mm', locale);
case 'hour':
return formatDate(d, 'p', locale);
case 'day':
return formatDate(d, 'MMM d', locale);
case 'month':
return formatDate(d, 'MMM', locale);
case 'year':
return formatDate(d, 'YYY', locale);
default:
return label;
}
};
}

View File

@ -1,62 +0,0 @@
import { StatusLight } from 'react-basics';
import { formatDate } from 'lib/date';
import { formatLongNumber } from 'lib/format';
export function renderNumberLabels(label: string) {
return +label > 1000 ? formatLongNumber(+label) : label;
}
export function renderDateLabels(unit: string, locale: string) {
return (label: string, index: number, values: any[]) => {
const d = new Date(values[index].value);
switch (unit) {
case 'minute':
return formatDate(d, 'h:mm', locale);
case 'hour':
return formatDate(d, 'p', locale);
case 'day':
return formatDate(d, 'MMM d', locale);
case 'month':
return formatDate(d, 'MMM', locale);
case 'year':
return formatDate(d, 'YYY', locale);
default:
return label;
}
};
}
export function renderStatusTooltipPopup(unit: string, locale: string) {
return (setTooltipPopup: (data: any) => void, model: any) => {
const { opacity, labelColors, dataPoints } = model.tooltip;
if (!dataPoints?.length || !opacity) {
setTooltipPopup(null);
return;
}
const formats = {
millisecond: 'T',
second: 'pp',
minute: 'p',
hour: 'h:mm aaa - PP',
day: 'PPPP',
week: 'PPPP',
month: 'LLLL yyyy',
quarter: 'qqq',
year: 'yyyy',
};
setTooltipPopup(
<>
<div>{formatDate(new Date(dataPoints[0].raw.x), formats[unit], locale)}</div>
<div>
<StatusLight color={labelColors?.[0]?.backgroundColor}>
{formatLongNumber(dataPoints[0].raw.y)} {dataPoints[0].dataset.label}
</StatusLight>
</div>
</>,
);
};
}

View File

@ -194,7 +194,7 @@ export const THEME_COLORS = {
},
};
export const EVENT_COLORS = [
export const CHART_COLORS = [
'#2680eb',
'#9256d9',
'#44b556',