Skip to content

Commit ce57c14

Browse files
authored
[WEB-3329] dev: new chart components (#6565)
* dev: new chart components * chore: separate out pie chart tooltip * chore: remove unused any types * chore: move chart components to propel package
1 parent 1eb1e82 commit ce57c14

File tree

32 files changed

+679
-409
lines changed

32 files changed

+679
-409
lines changed

packages/constants/src/chart.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
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";

packages/constants/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from "./ai";
22
export * from "./analytics";
33
export * from "./auth";
4+
export * from "./chart";
45
export * from "./endpoints";
56
export * from "./file";
67
export * from "./filter";

packages/propel/.prettierignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.next
2+
.turbo
3+
out/
4+
dist/
5+
build/

packages/propel/.prettierrc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"printWidth": 120,
3+
"tabWidth": 2,
4+
"trailingComma": "es5"
5+
}

packages/propel/package.json

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,29 @@
22
"name": "@plane/propel",
33
"version": "0.24.1",
44
"private": true,
5-
"exports": {
6-
"./globals.css": "./src/globals.css",
7-
"./components/*": "./src/*.tsx"
5+
"scripts": {
6+
"lint": "eslint src --ext .ts,.tsx",
7+
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
88
},
9-
"devDependencies": {
10-
"@plane/eslint-config": "*",
11-
"@plane/tailwind-config": "*",
12-
"@plane/typescript-config": "*",
13-
"@types/react": "18.3.1",
14-
"@types/react-dom": "18.3.0",
15-
"typescript": "^5.3.3"
9+
"exports": {
10+
"./ui/*": "./src/ui/*.tsx",
11+
"./charts/*": "./src/charts/*/index.ts"
1612
},
1713
"dependencies": {
1814
"@radix-ui/react-slot": "^1.1.1",
1915
"class-variance-authority": "^0.7.1",
20-
"clsx": "^2.1.1",
2116
"lucide-react": "^0.469.0",
2217
"react": "^18.3.1",
2318
"react-dom": "^18.3.1",
24-
"tailwind-merge": "^2.6.0",
19+
"recharts": "^2.15.1",
2520
"tailwindcss-animate": "^1.0.7"
21+
},
22+
"devDependencies": {
23+
"@plane/eslint-config": "*",
24+
"@plane/tailwind-config": "*",
25+
"@plane/typescript-config": "*",
26+
"@types/react": "18.3.1",
27+
"@types/react-dom": "18.3.0",
28+
"typescript": "^5.3.3"
2629
}
2730
}
File renamed without changes.
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
"use client";
3+
4+
import React, { useMemo } from "react";
5+
import { AreaChart as CoreAreaChart, Area, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
6+
// plane imports
7+
import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants";
8+
import { TAreaChartProps } from "@plane/types";
9+
// local components
10+
import { CustomXAxisTick, CustomYAxisTick } from "../tick";
11+
import { CustomTooltip } from "../tooltip";
12+
13+
export const AreaChart = React.memo(<K extends string, T extends string>(props: TAreaChartProps<K, T>) => {
14+
const {
15+
data,
16+
areas,
17+
xAxis,
18+
yAxis,
19+
className = "w-full h-96",
20+
tickCount = {
21+
x: undefined,
22+
y: 10,
23+
},
24+
showTooltip = true,
25+
} = props;
26+
// derived values
27+
const itemKeys = useMemo(() => areas.map((area) => area.key), [areas]);
28+
const itemDotClassNames = useMemo(
29+
() => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.dotClassName }), {}),
30+
[areas]
31+
);
32+
33+
const renderAreas = useMemo(
34+
() =>
35+
areas.map((area) => (
36+
<Area
37+
key={area.key}
38+
type="monotone"
39+
dataKey={area.key}
40+
stackId={area.stackId}
41+
className={area.className}
42+
stroke="inherit"
43+
fill="inherit"
44+
/>
45+
)),
46+
[areas]
47+
);
48+
49+
return (
50+
<div className={className}>
51+
<ResponsiveContainer width="100%" height="100%">
52+
<CoreAreaChart
53+
width={500}
54+
height={300}
55+
data={data}
56+
margin={{
57+
top: 5,
58+
right: 30,
59+
left: 20,
60+
bottom: 5,
61+
}}
62+
reverseStackOrder
63+
>
64+
<XAxis
65+
dataKey={xAxis.key}
66+
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+
}}
80+
tickCount={tickCount.x}
81+
/>
82+
<YAxis
83+
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+
}}
100+
tick={(props) => <CustomYAxisTick {...props} />}
101+
tickCount={tickCount.y}
102+
allowDecimals={!!yAxis.allowDecimals}
103+
/>
104+
{showTooltip && (
105+
<Tooltip
106+
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
107+
content={({ active, label, payload }) => (
108+
<CustomTooltip
109+
active={active}
110+
label={label}
111+
payload={payload}
112+
itemKeys={itemKeys}
113+
itemDotClassNames={itemDotClassNames}
114+
/>
115+
)}
116+
/>
117+
)}
118+
{renderAreas}
119+
</CoreAreaChart>
120+
</ResponsiveContainer>
121+
</div>
122+
);
123+
});
124+
AreaChart.displayName = "AreaChart";
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import React from "react";
3+
// plane imports
4+
import { TChartData } from "@plane/types";
5+
import { cn } from "@plane/utils";
6+
7+
// Helper to calculate percentage
8+
const calculatePercentage = <K extends string, T extends string>(
9+
data: TChartData<K, T>,
10+
stackKeys: T[],
11+
currentKey: T
12+
): number => {
13+
const total = stackKeys.reduce((sum, key) => sum + data[key], 0);
14+
return total === 0 ? 0 : Math.round((data[currentKey] / total) * 100);
15+
};
16+
17+
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
19+
20+
export const CustomBar = React.memo((props: any) => {
21+
const { fill, x, y, width, height, dataKey, stackKeys, payload, textClassName, showPercentage } = props;
22+
// Calculate text position
23+
const TEXT_PADDING_Y = Math.min(6, Math.abs(MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT - height / 2));
24+
const textY = y + height - TEXT_PADDING_Y; // Position inside bar if tall enough
25+
// derived values
26+
const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey);
27+
const showText =
28+
// from props
29+
showPercentage &&
30+
// height of the bar is greater than or equal to the minimum height required to show the text
31+
height >= MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT &&
32+
// bar percentage text has some value
33+
currentBarPercentage !== undefined &&
34+
// bar percentage is a number
35+
!Number.isNaN(currentBarPercentage);
36+
37+
if (!height) return null;
38+
return (
39+
<g>
40+
<path
41+
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"
55+
/>
56+
{showText && (
57+
<text
58+
x={x + width / 2}
59+
y={textY}
60+
textAnchor="middle"
61+
className={cn("text-xs font-medium", textClassName)}
62+
fill="currentColor"
63+
>
64+
{currentBarPercentage}%
65+
</text>
66+
)}
67+
</g>
68+
);
69+
});
70+
CustomBar.displayName = "CustomBar";
File renamed without changes.
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
"use client";
3+
4+
import React, { useMemo } from "react";
5+
import { BarChart as CoreBarChart, Bar, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
6+
// plane imports
7+
import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants";
8+
import { TBarChartProps } from "@plane/types";
9+
// local components
10+
import { CustomXAxisTick, CustomYAxisTick } from "../tick";
11+
import { CustomTooltip } from "../tooltip";
12+
import { CustomBar } from "./bar";
13+
14+
export const BarChart = React.memo(<K extends string, T extends string>(props: TBarChartProps<K, T>) => {
15+
const {
16+
data,
17+
bars,
18+
xAxis,
19+
yAxis,
20+
barSize = 40,
21+
className = "w-full h-96",
22+
tickCount = {
23+
x: undefined,
24+
y: 10,
25+
},
26+
showTooltip = true,
27+
} = props;
28+
// derived values
29+
const stackKeys = useMemo(() => bars.map((bar) => bar.key), [bars]);
30+
const stackDotClassNames = useMemo(
31+
() => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.dotClassName }), {}),
32+
[bars]
33+
);
34+
35+
const renderBars = useMemo(
36+
() =>
37+
bars.map((bar) => (
38+
<Bar
39+
key={bar.key}
40+
dataKey={bar.key}
41+
stackId={bar.stackId}
42+
fill={bar.fillClassName}
43+
shape={(shapeProps: any) => (
44+
<CustomBar
45+
{...shapeProps}
46+
stackKeys={stackKeys}
47+
textClassName={bar.textClassName}
48+
showPercentage={bar.showPercentage}
49+
/>
50+
)}
51+
/>
52+
)),
53+
[stackKeys, bars]
54+
);
55+
56+
return (
57+
<div className={className}>
58+
<ResponsiveContainer width="100%" height="100%">
59+
<CoreBarChart
60+
data={data}
61+
margin={{ top: 10, right: 10, left: 10, bottom: 40 }}
62+
barSize={barSize}
63+
className="recharts-wrapper"
64+
>
65+
<XAxis
66+
dataKey={xAxis.key}
67+
tick={(props) => <CustomXAxisTick {...props} />}
68+
tickLine={{
69+
stroke: "currentColor",
70+
className: AXIS_LINE_CLASSNAME,
71+
}}
72+
axisLine={{
73+
stroke: "currentColor",
74+
className: AXIS_LINE_CLASSNAME,
75+
}}
76+
label={{
77+
value: xAxis.label,
78+
dy: 28,
79+
className: LABEL_CLASSNAME,
80+
}}
81+
tickCount={tickCount.x}
82+
/>
83+
<YAxis
84+
domain={yAxis.domain}
85+
tickLine={{
86+
stroke: "currentColor",
87+
className: AXIS_LINE_CLASSNAME,
88+
}}
89+
axisLine={{
90+
stroke: "currentColor",
91+
className: AXIS_LINE_CLASSNAME,
92+
}}
93+
label={{
94+
value: yAxis.label,
95+
angle: -90,
96+
position: "bottom",
97+
offset: -24,
98+
dx: -16,
99+
className: LABEL_CLASSNAME,
100+
}}
101+
tick={(props) => <CustomYAxisTick {...props} />}
102+
tickCount={tickCount.y}
103+
allowDecimals={!!yAxis.allowDecimals}
104+
/>
105+
{showTooltip && (
106+
<Tooltip
107+
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
108+
content={({ active, label, payload }) => (
109+
<CustomTooltip
110+
active={active}
111+
label={label}
112+
payload={payload}
113+
itemKeys={stackKeys}
114+
itemDotClassNames={stackDotClassNames}
115+
/>
116+
)}
117+
/>
118+
)}
119+
{renderBars}
120+
</CoreBarChart>
121+
</ResponsiveContainer>
122+
</div>
123+
);
124+
});
125+
BarChart.displayName = "BarChart";

0 commit comments

Comments
 (0)