Skip to content

Commit

Permalink
refactor: refactor time event chart to recharts
Browse files Browse the repository at this point in the history
  • Loading branch information
moonrailgun committed Oct 1, 2024
1 parent 055f57e commit 1337eaa
Show file tree
Hide file tree
Showing 5 changed files with 280 additions and 58 deletions.
130 changes: 81 additions & 49 deletions src/client/components/TimeEventChart.tsx
Original file line number Diff line number Diff line change
@@ -1,63 +1,95 @@
import { useMemo } from 'react';
import { useTheme } from '../hooks/useTheme';
import { DateUnit } from '@tianji/shared';
import React from 'react';
import { formatDate, formatDateWithUnit } from '../utils/date';
import { Column, ColumnConfig } from '@ant-design/charts';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useTranslation } from '@i18next-toolkit/react';
import { formatDateWithUnit } from '../utils/date';
import {
Area,
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<{
labelMapping?: Record<string, string>;
data: { x: string; y: number; type: string }[];
data: { date: string; [key: string]: number | string }[];
unit: DateUnit;
}> = React.memo((props) => {
const { colors } = useTheme();
const isMobile = useIsMobile();
const { t } = useTranslation();

const labelMapping = props.labelMapping ?? {
pageview: t('pageview'),
session: t('session'),
const [calcStrokeDasharray, strokes] = useStrokeDasharray({});
const [strokeDasharray, setStrokeDasharray] = React.useState([...strokes]);
const handleAnimationEnd = () => setStrokeDasharray([...strokes]);
const getStrokeDasharray = (name: string) => {
const lineDasharray = strokeDasharray.find((s) => s.name === name);
return lineDasharray ? lineDasharray.strokeDasharray : undefined;
};

const config = useMemo(
() =>
({
data: props.data,
isStack: true,
xField: 'x',
yField: 'y',
seriesField: 'type',
label: {
position: 'middle' as const,
style: {
fill: '#FFFFFF',
opacity: 0.6,
},
},
tooltip: {
title: (t) => formatDate(t),
},
color: [colors.chart.pv, colors.chart.uv],
legend: isMobile
? false
: {
itemName: {
formatter: (text) => labelMapping[text] ?? text,
},
},
xAxis: {
label: {
autoHide: true,
autoRotate: false,
formatter: (text) => formatDateWithUnit(text, props.unit),
},
},
}) satisfies ColumnConfig,
[props.data, props.unit, props.labelMapping]
return (
<ChartContainer config={chartConfig}>
<AreaChart
data={props.data}
margin={{ top: 10, right: 0, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={colors.chart.pv} stopOpacity={0.8} />
<stop offset="95%" stopColor={colors.chart.pv} stopOpacity={0} />
</linearGradient>
<linearGradient id="colorPv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={colors.chart.uv} stopOpacity={0.8} />
<stop offset="95%" stopColor={colors.chart.uv} stopOpacity={0} />
</linearGradient>
</defs>
<Customized component={calcStrokeDasharray} />
<XAxis
dataKey="date"
tickFormatter={(text) => formatDateWithUnit(text, props.unit)}
/>
<YAxis mirror />
<ChartLegend content={<ChartLegendContent />} />
<CartesianGrid vertical={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area
type="monotone"
dataKey="pv"
stroke={colors.chart.pv}
fillOpacity={1}
fill="url(#colorUv)"
strokeWidth={2}
strokeDasharray={getStrokeDasharray('pv')}
onAnimationEnd={handleAnimationEnd}
/>
<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';
9 changes: 5 additions & 4 deletions src/client/components/telemetry/TelemetryOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,11 @@ export const TelemetryOverview: React.FC<{
const pageviewsArr = getDateArray(pageviews, startDate, endDate, unit);
const sessionsArr = getDateArray(sessions, startDate, endDate, unit);

return [
...pageviewsArr.map((item) => ({ ...item, type: 'pageview' })),
...sessionsArr.map((item) => ({ ...item, type: 'session' })),
];
return pageviewsArr.map((item, i) => ({
pv: item.y,
uv: sessionsArr[i]?.y ?? 0,
date: item.x,
}));
},
}
);
Expand Down
9 changes: 5 additions & 4 deletions src/client/components/website/WebsiteOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,11 @@ export const WebsiteOverview: React.FC<{
const pageviewsArr = getDateArray(pageviews, startDate, endDate, unit);
const sessionsArr = getDateArray(sessions, startDate, endDate, unit);

return [
...pageviewsArr.map((item) => ({ ...item, type: 'pageview' })),
...sessionsArr.map((item) => ({ ...item, type: 'session' })),
];
return pageviewsArr.map((item, i) => ({
pv: item.y,
uv: sessionsArr[i]?.y ?? 0,
date: item.x,
}));
},
}
);
Expand Down
188 changes: 188 additions & 0 deletions src/client/hooks/useStrokeDasharray.ts
Original file line number Diff line number Diff line change
@@ -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];
}
2 changes: 1 addition & 1 deletion src/server/tests/seeds/website.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { getLocation } from '../../utils/detect.js';
import dayjs from 'dayjs';
import { WebsiteSession } from '@prisma/client';

const websiteId = 'cm1cemqhw0009g2snzh3eqjzr';
const websiteId = 'cly5yay7a001v5tp6xdkzmygh';

async function main() {
Array.from({ length: 200 }).map(async (_, i) => {
Expand Down

0 comments on commit 1337eaa

Please sign in to comment.