refactor: refactor time event chart to recharts

This commit is contained in:
moonrailgun 2024-10-01 19:26:03 +08:00
parent 055f57e087
commit 1337eaa2c0
5 changed files with 280 additions and 58 deletions

View File

@ -1,63 +1,95 @@
import { useMemo } from 'react';
import { useTheme } from '../hooks/useTheme'; import { useTheme } from '../hooks/useTheme';
import { DateUnit } from '@tianji/shared'; import { DateUnit } from '@tianji/shared';
import React from 'react'; import React from 'react';
import { formatDate, formatDateWithUnit } from '../utils/date'; import { formatDateWithUnit } from '../utils/date';
import { Column, ColumnConfig } from '@ant-design/charts'; import {
import { useIsMobile } from '@/hooks/useIsMobile'; Area,
import { useTranslation } from '@i18next-toolkit/react'; AreaChart,
CartesianGrid,
Customized,
XAxis,
YAxis,
} from 'recharts';
import {
ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from './ui/chart';
import { useStrokeDasharray } from '@/hooks/useStrokeDasharray';
const chartConfig = {
pv: {
label: 'PV',
},
uv: {
label: 'UV',
},
} satisfies ChartConfig;
export const TimeEventChart: React.FC<{ export const TimeEventChart: React.FC<{
labelMapping?: Record<string, string>; labelMapping?: Record<string, string>;
data: { x: string; y: number; type: string }[]; data: { date: string; [key: string]: number | string }[];
unit: DateUnit; unit: DateUnit;
}> = React.memo((props) => { }> = React.memo((props) => {
const { colors } = useTheme(); const { colors } = useTheme();
const isMobile = useIsMobile(); const [calcStrokeDasharray, strokes] = useStrokeDasharray({});
const { t } = useTranslation(); const [strokeDasharray, setStrokeDasharray] = React.useState([...strokes]);
const handleAnimationEnd = () => setStrokeDasharray([...strokes]);
const labelMapping = props.labelMapping ?? { const getStrokeDasharray = (name: string) => {
pageview: t('pageview'), const lineDasharray = strokeDasharray.find((s) => s.name === name);
session: t('session'), return lineDasharray ? lineDasharray.strokeDasharray : undefined;
}; };
const config = useMemo( return (
() => <ChartContainer config={chartConfig}>
({ <AreaChart
data: props.data, data={props.data}
isStack: true, margin={{ top: 10, right: 0, left: 0, bottom: 0 }}
xField: 'x', >
yField: 'y', <defs>
seriesField: 'type', <linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
label: { <stop offset="5%" stopColor={colors.chart.pv} stopOpacity={0.8} />
position: 'middle' as const, <stop offset="95%" stopColor={colors.chart.pv} stopOpacity={0} />
style: { </linearGradient>
fill: '#FFFFFF', <linearGradient id="colorPv" x1="0" y1="0" x2="0" y2="1">
opacity: 0.6, <stop offset="5%" stopColor={colors.chart.uv} stopOpacity={0.8} />
}, <stop offset="95%" stopColor={colors.chart.uv} stopOpacity={0} />
}, </linearGradient>
tooltip: { </defs>
title: (t) => formatDate(t), <Customized component={calcStrokeDasharray} />
}, <XAxis
color: [colors.chart.pv, colors.chart.uv], dataKey="date"
legend: isMobile tickFormatter={(text) => formatDateWithUnit(text, props.unit)}
? false />
: { <YAxis mirror />
itemName: { <ChartLegend content={<ChartLegendContent />} />
formatter: (text) => labelMapping[text] ?? text, <CartesianGrid vertical={false} />
}, <ChartTooltip content={<ChartTooltipContent />} />
}, <Area
xAxis: { type="monotone"
label: { dataKey="pv"
autoHide: true, stroke={colors.chart.pv}
autoRotate: false, fillOpacity={1}
formatter: (text) => formatDateWithUnit(text, props.unit), fill="url(#colorUv)"
}, strokeWidth={2}
}, strokeDasharray={getStrokeDasharray('pv')}
}) satisfies ColumnConfig, onAnimationEnd={handleAnimationEnd}
[props.data, props.unit, props.labelMapping] />
<Area
type="monotone"
dataKey="uv"
stroke={colors.chart.uv}
fillOpacity={1}
fill="url(#colorPv)"
strokeWidth={2}
strokeDasharray={getStrokeDasharray('uv')}
onAnimationEnd={handleAnimationEnd}
/>
</AreaChart>
</ChartContainer>
); );
return <Column {...config} />;
}); });
TimeEventChart.displayName = 'TimeEventChart'; TimeEventChart.displayName = 'TimeEventChart';

View File

@ -51,10 +51,11 @@ export const TelemetryOverview: React.FC<{
const pageviewsArr = getDateArray(pageviews, startDate, endDate, unit); const pageviewsArr = getDateArray(pageviews, startDate, endDate, unit);
const sessionsArr = getDateArray(sessions, startDate, endDate, unit); const sessionsArr = getDateArray(sessions, startDate, endDate, unit);
return [ return pageviewsArr.map((item, i) => ({
...pageviewsArr.map((item) => ({ ...item, type: 'pageview' })), pv: item.y,
...sessionsArr.map((item) => ({ ...item, type: 'session' })), uv: sessionsArr[i]?.y ?? 0,
]; date: item.x,
}));
}, },
} }
); );

View File

@ -50,10 +50,11 @@ export const WebsiteOverview: React.FC<{
const pageviewsArr = getDateArray(pageviews, startDate, endDate, unit); const pageviewsArr = getDateArray(pageviews, startDate, endDate, unit);
const sessionsArr = getDateArray(sessions, startDate, endDate, unit); const sessionsArr = getDateArray(sessions, startDate, endDate, unit);
return [ return pageviewsArr.map((item, i) => ({
...pageviewsArr.map((item) => ({ ...item, type: 'pageview' })), pv: item.y,
...sessionsArr.map((item) => ({ ...item, type: 'session' })), uv: sessionsArr[i]?.y ?? 0,
]; date: item.x,
}));
}, },
} }
); );

View File

@ -0,0 +1,188 @@
/**
* Reference: https://bit.cloud/teambit/analytics/hooks/use-recharts-line-stroke-dasharray
*/
import { useCallback, useRef } from 'react';
type GraphicalItemPoint = {
/**
* x point coordinate.
*/
x?: number;
/**
* y point coordinate.
*/
y?: number;
};
type GraphicalItemProps = {
/**
* graphical item points.
*/
points?: GraphicalItemPoint[];
};
type ItemProps = {
/**
* item data key.
*/
dataKey?: string;
};
type ItemType = {
/**
* recharts item display name.
*/
displayName?: string;
};
type Item = {
/**
* item props.
*/
props?: ItemProps;
/**
* recharts item class.
*/
type?: ItemType;
};
type GraphicalItem = {
/**
* from recharts internal state and props of chart.
*/
props?: GraphicalItemProps;
/**
* from recharts internal state and props of chart.
*/
item?: Item;
};
type RechartsChartProps = {
/**
* from recharts internal state and props of chart.
*/
formattedGraphicalItems?: GraphicalItem[];
};
type CalculateStrokeDasharray = (props?: any) => any;
type LineStrokeDasharray = {
/**
* line name.
*/
name?: string;
/**
* line strokeDasharray.
*/
strokeDasharray?: string;
};
type LinesStrokeDasharray = LineStrokeDasharray[];
type LineProps = {
/**
* line name.
*/
name?: string;
/**
* specifies the starting index of the first dot in the dash pattern.
*/
dotIndex?: number;
/**
* defines the pattern of dashes and gaps. an array of [gap length, dash length].
*/
strokeDasharray?: [number, number];
/**
* adjusts the percentage correction of the first line segment for better alignment in curved lines.
*/
curveCorrection?: number;
};
export type UseStrokeDasharrayProps = {
/**
* an array of properties to target specific line(s) and override default settings.
*/
linesProps?: LineProps[];
} & LineProps;
export function useStrokeDasharray({
linesProps = [],
dotIndex = -2,
strokeDasharray: restStroke = [5, 3],
curveCorrection = 1,
}: UseStrokeDasharrayProps): [CalculateStrokeDasharray, LinesStrokeDasharray] {
const linesStrokeDasharray = useRef<LinesStrokeDasharray>([]);
const calculateStrokeDasharray = useCallback(
(props: RechartsChartProps): null => {
const items = props?.formattedGraphicalItems;
const getLineWidth = (points: GraphicalItemPoint[]) => {
const width = points?.reduce((acc, point, index) => {
if (!index) return acc;
const prevPoint = points?.[index - 1];
const xAxis = point?.x || 0;
const prevXAxis = prevPoint?.x || 0;
const xWidth = xAxis - prevXAxis;
const yAxis = point?.y || 0;
const prevYAxis = prevPoint?.y || 0;
const yWidth = Math.abs(yAxis - prevYAxis);
const hypotenuse = Math.sqrt(xWidth * xWidth + yWidth * yWidth);
acc += hypotenuse;
return acc;
}, 0);
return width || 0;
};
items?.forEach((line) => {
const linePoints = line?.props?.points ?? [];
const lineWidth = getLineWidth(linePoints);
const name = line?.item?.props?.dataKey;
const targetLine = linesProps?.find((target) => target?.name === name);
const targetIndex = targetLine?.dotIndex ?? dotIndex;
const dashedPoints = linePoints?.slice(targetIndex);
const dashedWidth = getLineWidth(dashedPoints);
if (!lineWidth || !dashedWidth) return;
const firstWidth = lineWidth - dashedWidth;
const targetCurve = targetLine?.curveCorrection ?? curveCorrection;
const correctionWidth = (firstWidth * targetCurve) / 100;
const firstDasharray = firstWidth + correctionWidth;
const targetRestStroke = targetLine?.strokeDasharray || restStroke;
const gapDashWidth = targetRestStroke?.[0] + targetRestStroke?.[1] || 1;
const restDasharrayLength = dashedWidth / gapDashWidth;
const restDasharray = new Array(Math.ceil(restDasharrayLength)).fill(
targetRestStroke.join(' ')
);
const strokeDasharray = `${firstDasharray} ${restDasharray.join(' ')}`;
const lineStrokeDasharray = { name, strokeDasharray };
const dasharrayIndex = linesStrokeDasharray.current.findIndex((d) => {
return d.name === line?.item?.props?.dataKey;
});
if (dasharrayIndex === -1) {
linesStrokeDasharray.current.push(lineStrokeDasharray);
return;
}
linesStrokeDasharray.current[dasharrayIndex] = lineStrokeDasharray;
});
return null;
},
[dotIndex]
);
return [calculateStrokeDasharray, linesStrokeDasharray.current];
}

View File

@ -5,7 +5,7 @@ import { getLocation } from '../../utils/detect.js';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { WebsiteSession } from '@prisma/client'; import { WebsiteSession } from '@prisma/client';
const websiteId = 'cm1cemqhw0009g2snzh3eqjzr'; const websiteId = 'cly5yay7a001v5tp6xdkzmygh';
async function main() { async function main() {
Array.from({ length: 200 }).map(async (_, i) => { Array.from({ length: 200 }).map(async (_, i) => {