Skip to content

Commit 471fefc

Browse files
[WEB-3697] chore: chart components (#6835)
1 parent 869c755 commit 471fefc

File tree

14 files changed

+688
-237
lines changed

14 files changed

+688
-237
lines changed

packages/constants/src/chart.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
export const LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide";
2-
export const AXIS_LINE_CLASSNAME = "text-custom-text-400/70";
2+
export const AXIS_LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide";

packages/propel/src/charts/area-chart/root.tsx

Lines changed: 121 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,122 +1,189 @@
1-
/* eslint-disable @typescript-eslint/no-explicit-any */
21
"use client";
32

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";
65
// plane imports
7-
import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants";
6+
import { AXIS_LABEL_CLASSNAME } from "@plane/constants";
87
import { TAreaChartProps } from "@plane/types";
98
// 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";
1212

1313
export const AreaChart = React.memo(<K extends string, T extends string>(props: TAreaChartProps<K, T>) => {
1414
const {
1515
data,
1616
areas,
1717
xAxis,
1818
yAxis,
19-
className = "w-full h-96",
19+
className,
20+
legend,
21+
margin,
2022
tickCount = {
2123
x: undefined,
2224
y: 10,
2325
},
2426
showTooltip = true,
27+
comparisonLine,
2528
} = props;
29+
// states
30+
const [activeArea, setActiveArea] = useState<string | null>(null);
31+
const [activeLegend, setActiveLegend] = useState<string | null>(null);
2632
// derived values
2733
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 }), {}),
3036
[areas]
3137
);
38+
const itemDotColors = useMemo(() => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.fill }), {}), [areas]);
3239

3340
const renderAreas = useMemo(
3441
() =>
3542
areas.map((area) => (
3643
<Area
3744
key={area.key}
38-
type="monotone"
45+
type={area.smoothCurves ? "monotone" : "linear"}
3946
dataKey={area.key}
4047
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"
4469
/>
4570
)),
46-
[areas]
71+
[activeLegend, areas]
4772
);
4873

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+
4995
return (
5096
<div className={className}>
5197
<ResponsiveContainer width="100%" height="100%">
52-
<CoreAreaChart
53-
width={500}
54-
height={300}
98+
<ComposedChart
5599
data={data}
56100
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,
61105
}}
62-
reverseStackOrder
63106
>
107+
<CartesianGrid stroke="rgba(var(--color-border-100), 0.8)" vertical={false} />
64108
<XAxis
65109
dataKey={xAxis.key}
66110
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+
}
80120
tickCount={tickCount.x}
81121
/>
82122
<YAxis
83123
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+
}
100136
tick={(props) => <CustomYAxisTick {...props} />}
101137
tickCount={tickCount.y}
102138
allowDecimals={!!yAxis.allowDecimals}
103139
/>
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+
)}
104149
{showTooltip && (
105150
<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+
}}
107158
content={({ active, label, payload }) => (
108159
<CustomTooltip
109160
active={active}
161+
activeKey={activeArea}
110162
label={label}
111163
payload={payload}
112164
itemKeys={itemKeys}
113-
itemDotClassNames={itemDotClassNames}
165+
itemLabels={itemLabels}
166+
itemDotColors={itemDotColors}
114167
/>
115168
)}
116169
/>
117170
)}
118171
{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>
120187
</ResponsiveContainer>
121188
</div>
122189
);

packages/propel/src/charts/bar-chart/bar.tsx

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,25 @@ const calculatePercentage = <K extends string, T extends string>(
1515
};
1616

1717
const MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT = 14; // Minimum height needed to show text inside
18-
const BAR_BORDER_RADIUS = 2; // Border radius for each bar
18+
const BAR_TOP_BORDER_RADIUS = 4; // Border radius for each bar
19+
const BAR_BOTTOM_BORDER_RADIUS = 4; // Border radius for each bar
1920

2021
export const CustomBar = React.memo((props: any) => {
21-
const { fill, x, y, width, height, dataKey, stackKeys, payload, textClassName, showPercentage } = props;
22+
const {
23+
opacity,
24+
fill,
25+
x,
26+
y,
27+
width,
28+
height,
29+
dataKey,
30+
stackKeys,
31+
payload,
32+
textClassName,
33+
showPercentage,
34+
showTopBorderRadius,
35+
showBottomBorderRadius,
36+
} = props;
2237
// Calculate text position
2338
const TEXT_PADDING_Y = Math.min(6, Math.abs(MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT - height / 2));
2439
const textY = y + height - TEXT_PADDING_Y; // Position inside bar if tall enough
@@ -34,24 +49,28 @@ export const CustomBar = React.memo((props: any) => {
3449
// bar percentage is a number
3550
!Number.isNaN(currentBarPercentage);
3651

52+
const topBorderRadius = showTopBorderRadius ? BAR_TOP_BORDER_RADIUS : 0;
53+
const bottomBorderRadius = showBottomBorderRadius ? BAR_BOTTOM_BORDER_RADIUS : 0;
54+
3755
if (!height) return null;
56+
3857
return (
3958
<g>
4059
<path
4160
d={`
42-
M${x + BAR_BORDER_RADIUS},${y + height}
43-
L${x + BAR_BORDER_RADIUS},${y}
44-
Q${x},${y} ${x},${y + BAR_BORDER_RADIUS}
45-
L${x},${y + height - BAR_BORDER_RADIUS}
46-
Q${x},${y + height} ${x + BAR_BORDER_RADIUS},${y + height}
47-
L${x + width - BAR_BORDER_RADIUS},${y + height}
48-
Q${x + width},${y + height} ${x + width},${y + height - BAR_BORDER_RADIUS}
49-
L${x + width},${y + BAR_BORDER_RADIUS}
50-
Q${x + width},${y} ${x + width - BAR_BORDER_RADIUS},${y}
51-
L${x + BAR_BORDER_RADIUS},${y}
52-
`}
53-
className={cn("transition-colors duration-200", fill)}
54-
fill="currentColor"
61+
M${x},${y + topBorderRadius}
62+
Q${x},${y} ${x + topBorderRadius},${y}
63+
L${x + width - topBorderRadius},${y}
64+
Q${x + width},${y} ${x + width},${y + topBorderRadius}
65+
L${x + width},${y + height - bottomBorderRadius}
66+
Q${x + width},${y + height} ${x + width - bottomBorderRadius},${y + height}
67+
L${x + bottomBorderRadius},${y + height}
68+
Q${x},${y + height} ${x},${y + height - bottomBorderRadius}
69+
Z
70+
`}
71+
className="transition-opacity duration-200"
72+
fill={fill}
73+
opacity={opacity}
5574
/>
5675
{showText && (
5776
<text

0 commit comments

Comments
 (0)