diff --git a/src/client/components/TimeEventChart.tsx b/src/client/components/TimeEventChart.tsx index a79286b..47bdc18 100644 --- a/src/client/components/TimeEventChart.tsx +++ b/src/client/components/TimeEventChart.tsx @@ -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; - 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 ( + + + + + + + + + + + + + + formatDateWithUnit(text, props.unit)} + /> + + } /> + + } /> + + + + ); - - return ; }); TimeEventChart.displayName = 'TimeEventChart'; diff --git a/src/client/components/telemetry/TelemetryOverview.tsx b/src/client/components/telemetry/TelemetryOverview.tsx index 1d54e2a..a766dfb 100644 --- a/src/client/components/telemetry/TelemetryOverview.tsx +++ b/src/client/components/telemetry/TelemetryOverview.tsx @@ -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, + })); }, } ); diff --git a/src/client/components/website/WebsiteOverview.tsx b/src/client/components/website/WebsiteOverview.tsx index feeb325..a11495d 100644 --- a/src/client/components/website/WebsiteOverview.tsx +++ b/src/client/components/website/WebsiteOverview.tsx @@ -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, + })); }, } ); diff --git a/src/client/hooks/useStrokeDasharray.ts b/src/client/hooks/useStrokeDasharray.ts new file mode 100644 index 0000000..17a228f --- /dev/null +++ b/src/client/hooks/useStrokeDasharray.ts @@ -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([]); + + 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]; +} diff --git a/src/server/tests/seeds/website.ts b/src/server/tests/seeds/website.ts index 5e3126e..65bbf66 100644 --- a/src/server/tests/seeds/website.ts +++ b/src/server/tests/seeds/website.ts @@ -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) => {