|
1 | | -/* eslint-disable @typescript-eslint/no-explicit-any */ |
2 | 1 | "use client"; |
3 | 2 |
|
4 | | -import React, { useMemo } from "react"; |
5 | | -import { AreaChart as CoreAreaChart, Area, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; |
| 3 | +import React, { useMemo, useState } from "react"; |
| 4 | +import { Area, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis, Line, ComposedChart, CartesianGrid } from "recharts"; |
6 | 5 | // plane imports |
7 | | -import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants"; |
| 6 | +import { AXIS_LABEL_CLASSNAME } from "@plane/constants"; |
8 | 7 | import { TAreaChartProps } from "@plane/types"; |
9 | 8 | // local components |
10 | | -import { CustomXAxisTick, CustomYAxisTick } from "../tick"; |
11 | | -import { CustomTooltip } from "../tooltip"; |
| 9 | +import { getLegendProps } from "../components/legend"; |
| 10 | +import { CustomXAxisTick, CustomYAxisTick } from "../components/tick"; |
| 11 | +import { CustomTooltip } from "../components/tooltip"; |
12 | 12 |
|
13 | 13 | export const AreaChart = React.memo(<K extends string, T extends string>(props: TAreaChartProps<K, T>) => { |
14 | 14 | const { |
15 | 15 | data, |
16 | 16 | areas, |
17 | 17 | xAxis, |
18 | 18 | yAxis, |
19 | | - className = "w-full h-96", |
| 19 | + className, |
| 20 | + legend, |
| 21 | + margin, |
20 | 22 | tickCount = { |
21 | 23 | x: undefined, |
22 | 24 | y: 10, |
23 | 25 | }, |
24 | 26 | showTooltip = true, |
| 27 | + comparisonLine, |
25 | 28 | } = props; |
| 29 | + // states |
| 30 | + const [activeArea, setActiveArea] = useState<string | null>(null); |
| 31 | + const [activeLegend, setActiveLegend] = useState<string | null>(null); |
26 | 32 | // derived values |
27 | 33 | const itemKeys = useMemo(() => areas.map((area) => area.key), [areas]); |
28 | | - const itemDotClassNames = useMemo( |
29 | | - () => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.dotClassName }), {}), |
| 34 | + const itemLabels: Record<string, string> = useMemo( |
| 35 | + () => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.label }), {}), |
30 | 36 | [areas] |
31 | 37 | ); |
| 38 | + const itemDotColors = useMemo(() => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.fill }), {}), [areas]); |
32 | 39 |
|
33 | 40 | const renderAreas = useMemo( |
34 | 41 | () => |
35 | 42 | areas.map((area) => ( |
36 | 43 | <Area |
37 | 44 | key={area.key} |
38 | | - type="monotone" |
| 45 | + type={area.smoothCurves ? "monotone" : "linear"} |
39 | 46 | dataKey={area.key} |
40 | 47 | stackId={area.stackId} |
41 | | - className={area.className} |
42 | | - stroke="inherit" |
43 | | - fill="inherit" |
| 48 | + fill={area.fill} |
| 49 | + opacity={!!activeLegend && activeLegend !== area.key ? 0.1 : 1} |
| 50 | + fillOpacity={area.fillOpacity} |
| 51 | + strokeOpacity={area.strokeOpacity} |
| 52 | + stroke={area.strokeColor} |
| 53 | + strokeWidth={2} |
| 54 | + style={area.style} |
| 55 | + dot={ |
| 56 | + area.showDot |
| 57 | + ? { |
| 58 | + fill: area.fill, |
| 59 | + fillOpacity: 1, |
| 60 | + } |
| 61 | + : false |
| 62 | + } |
| 63 | + activeDot={{ |
| 64 | + stroke: area.fill, |
| 65 | + }} |
| 66 | + onMouseEnter={() => setActiveArea(area.key)} |
| 67 | + onMouseLeave={() => setActiveArea(null)} |
| 68 | + className="[&_path]:transition-opacity [&_path]:duration-200" |
44 | 69 | /> |
45 | 70 | )), |
46 | | - [areas] |
| 71 | + [activeLegend, areas] |
47 | 72 | ); |
48 | 73 |
|
| 74 | + // create comparison line data for straight line from origin to last point |
| 75 | + const comparisonLineData = useMemo(() => { |
| 76 | + if (!data || data.length === 0) return []; |
| 77 | + // get the last data point |
| 78 | + const lastPoint = data[data.length - 1]; |
| 79 | + // for the y-value in the last point, use its yAxis key value |
| 80 | + const lastYValue = lastPoint[yAxis.key] || 0; |
| 81 | + // create data for a straight line that has points at each x-axis position |
| 82 | + return data.map((item, index) => { |
| 83 | + // calculate the y value for this point on the straight line |
| 84 | + // using linear interpolation between (0,0) and (last_x, last_y) |
| 85 | + const ratio = index / (data.length - 1); |
| 86 | + const interpolatedValue = ratio * lastYValue; |
| 87 | + |
| 88 | + return { |
| 89 | + [xAxis.key]: item[xAxis.key], |
| 90 | + comparisonLine: interpolatedValue, |
| 91 | + }; |
| 92 | + }); |
| 93 | + }, [data, xAxis.key]); |
| 94 | + |
49 | 95 | return ( |
50 | 96 | <div className={className}> |
51 | 97 | <ResponsiveContainer width="100%" height="100%"> |
52 | | - <CoreAreaChart |
53 | | - width={500} |
54 | | - height={300} |
| 98 | + <ComposedChart |
55 | 99 | data={data} |
56 | 100 | margin={{ |
57 | | - top: 5, |
58 | | - right: 30, |
59 | | - left: 20, |
60 | | - bottom: 5, |
| 101 | + top: margin?.top === undefined ? 5 : margin.top, |
| 102 | + right: margin?.right === undefined ? 30 : margin.right, |
| 103 | + bottom: margin?.bottom === undefined ? 5 : margin.bottom, |
| 104 | + left: margin?.left === undefined ? 20 : margin.left, |
61 | 105 | }} |
62 | | - reverseStackOrder |
63 | 106 | > |
| 107 | + <CartesianGrid stroke="rgba(var(--color-border-100), 0.8)" vertical={false} /> |
64 | 108 | <XAxis |
65 | 109 | dataKey={xAxis.key} |
66 | 110 | tick={(props) => <CustomXAxisTick {...props} />} |
67 | | - tickLine={{ |
68 | | - stroke: "currentColor", |
69 | | - className: AXIS_LINE_CLASSNAME, |
70 | | - }} |
71 | | - axisLine={{ |
72 | | - stroke: "currentColor", |
73 | | - className: AXIS_LINE_CLASSNAME, |
74 | | - }} |
75 | | - label={{ |
76 | | - value: xAxis.label, |
77 | | - dy: 28, |
78 | | - className: LABEL_CLASSNAME, |
79 | | - }} |
| 111 | + tickLine={false} |
| 112 | + axisLine={false} |
| 113 | + label={ |
| 114 | + xAxis.label && { |
| 115 | + value: xAxis.label, |
| 116 | + dy: 28, |
| 117 | + className: AXIS_LABEL_CLASSNAME, |
| 118 | + } |
| 119 | + } |
80 | 120 | tickCount={tickCount.x} |
81 | 121 | /> |
82 | 122 | <YAxis |
83 | 123 | domain={yAxis.domain} |
84 | | - tickLine={{ |
85 | | - stroke: "currentColor", |
86 | | - className: AXIS_LINE_CLASSNAME, |
87 | | - }} |
88 | | - axisLine={{ |
89 | | - stroke: "currentColor", |
90 | | - className: AXIS_LINE_CLASSNAME, |
91 | | - }} |
92 | | - label={{ |
93 | | - value: yAxis.label, |
94 | | - angle: -90, |
95 | | - position: "bottom", |
96 | | - offset: -24, |
97 | | - dx: -16, |
98 | | - className: LABEL_CLASSNAME, |
99 | | - }} |
| 124 | + tickLine={false} |
| 125 | + axisLine={false} |
| 126 | + label={ |
| 127 | + yAxis.label && { |
| 128 | + value: yAxis.label, |
| 129 | + angle: -90, |
| 130 | + position: "bottom", |
| 131 | + offset: -24, |
| 132 | + dx: -16, |
| 133 | + className: AXIS_LABEL_CLASSNAME, |
| 134 | + } |
| 135 | + } |
100 | 136 | tick={(props) => <CustomYAxisTick {...props} />} |
101 | 137 | tickCount={tickCount.y} |
102 | 138 | allowDecimals={!!yAxis.allowDecimals} |
103 | 139 | /> |
| 140 | + {legend && ( |
| 141 | + // @ts-expect-error recharts types are not up to date |
| 142 | + <Legend |
| 143 | + formatter={(value) => itemLabels[value]} |
| 144 | + onMouseEnter={(payload) => setActiveLegend(payload.value)} |
| 145 | + onMouseLeave={() => setActiveLegend(null)} |
| 146 | + {...getLegendProps(legend)} |
| 147 | + /> |
| 148 | + )} |
104 | 149 | {showTooltip && ( |
105 | 150 | <Tooltip |
106 | | - cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }} |
| 151 | + cursor={{ |
| 152 | + stroke: "rgba(var(--color-text-300))", |
| 153 | + strokeDasharray: "4 4", |
| 154 | + }} |
| 155 | + wrapperStyle={{ |
| 156 | + pointerEvents: "auto", |
| 157 | + }} |
107 | 158 | content={({ active, label, payload }) => ( |
108 | 159 | <CustomTooltip |
109 | 160 | active={active} |
| 161 | + activeKey={activeArea} |
110 | 162 | label={label} |
111 | 163 | payload={payload} |
112 | 164 | itemKeys={itemKeys} |
113 | | - itemDotClassNames={itemDotClassNames} |
| 165 | + itemLabels={itemLabels} |
| 166 | + itemDotColors={itemDotColors} |
114 | 167 | /> |
115 | 168 | )} |
116 | 169 | /> |
117 | 170 | )} |
118 | 171 | {renderAreas} |
119 | | - </CoreAreaChart> |
| 172 | + {comparisonLine && ( |
| 173 | + <Line |
| 174 | + data={comparisonLineData} |
| 175 | + type="linear" |
| 176 | + dataKey="comparisonLine" |
| 177 | + stroke={comparisonLine.strokeColor} |
| 178 | + fill={comparisonLine.strokeColor} |
| 179 | + strokeWidth={2} |
| 180 | + strokeDasharray={comparisonLine.dashedLine ? "4 4" : "none"} |
| 181 | + activeDot={false} |
| 182 | + legendType="none" |
| 183 | + name="Comparison line" |
| 184 | + /> |
| 185 | + )} |
| 186 | + </ComposedChart> |
120 | 187 | </ResponsiveContainer> |
121 | 188 | </div> |
122 | 189 | ); |
|
0 commit comments