[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
This commit is contained in:
parent
1eb1e82fe4
commit
ce57c1423c
32 changed files with 679 additions and 409 deletions
2
packages/constants/src/chart.ts
Normal file
2
packages/constants/src/chart.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export const LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide";
|
||||||
|
export const AXIS_LINE_CLASSNAME = "text-custom-text-400/70";
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export * from "./ai";
|
export * from "./ai";
|
||||||
export * from "./analytics";
|
export * from "./analytics";
|
||||||
export * from "./auth";
|
export * from "./auth";
|
||||||
|
export * from "./chart";
|
||||||
export * from "./endpoints";
|
export * from "./endpoints";
|
||||||
export * from "./file";
|
export * from "./file";
|
||||||
export * from "./filter";
|
export * from "./filter";
|
||||||
|
|
|
||||||
5
packages/propel/.prettierignore
Normal file
5
packages/propel/.prettierignore
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
.next
|
||||||
|
.turbo
|
||||||
|
out/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
5
packages/propel/.prettierrc
Normal file
5
packages/propel/.prettierrc
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"printWidth": 120,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5"
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,22 @@
|
||||||
"name": "@plane/propel",
|
"name": "@plane/propel",
|
||||||
"version": "0.24.1",
|
"version": "0.24.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"lint": "eslint src --ext .ts,.tsx",
|
||||||
|
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
|
||||||
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
"./globals.css": "./src/globals.css",
|
"./ui/*": "./src/ui/*.tsx",
|
||||||
"./components/*": "./src/*.tsx"
|
"./charts/*": "./src/charts/*/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"lucide-react": "^0.469.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"recharts": "^2.15.1",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@plane/eslint-config": "*",
|
"@plane/eslint-config": "*",
|
||||||
|
|
@ -13,15 +26,5 @@
|
||||||
"@types/react": "18.3.1",
|
"@types/react": "18.3.1",
|
||||||
"@types/react-dom": "18.3.0",
|
"@types/react-dom": "18.3.0",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"lucide-react": "^0.469.0",
|
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-dom": "^18.3.1",
|
|
||||||
"tailwind-merge": "^2.6.0",
|
|
||||||
"tailwindcss-animate": "^1.0.7"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
124
packages/propel/src/charts/area-chart/root.tsx
Normal file
124
packages/propel/src/charts/area-chart/root.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { AreaChart as CoreAreaChart, Area, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
|
// plane imports
|
||||||
|
import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants";
|
||||||
|
import { TAreaChartProps } from "@plane/types";
|
||||||
|
// local components
|
||||||
|
import { CustomXAxisTick, CustomYAxisTick } from "../tick";
|
||||||
|
import { CustomTooltip } from "../tooltip";
|
||||||
|
|
||||||
|
export const AreaChart = React.memo(<K extends string, T extends string>(props: TAreaChartProps<K, T>) => {
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
areas,
|
||||||
|
xAxis,
|
||||||
|
yAxis,
|
||||||
|
className = "w-full h-96",
|
||||||
|
tickCount = {
|
||||||
|
x: undefined,
|
||||||
|
y: 10,
|
||||||
|
},
|
||||||
|
showTooltip = true,
|
||||||
|
} = props;
|
||||||
|
// derived values
|
||||||
|
const itemKeys = useMemo(() => areas.map((area) => area.key), [areas]);
|
||||||
|
const itemDotClassNames = useMemo(
|
||||||
|
() => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.dotClassName }), {}),
|
||||||
|
[areas]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderAreas = useMemo(
|
||||||
|
() =>
|
||||||
|
areas.map((area) => (
|
||||||
|
<Area
|
||||||
|
key={area.key}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={area.key}
|
||||||
|
stackId={area.stackId}
|
||||||
|
className={area.className}
|
||||||
|
stroke="inherit"
|
||||||
|
fill="inherit"
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
[areas]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<CoreAreaChart
|
||||||
|
width={500}
|
||||||
|
height={300}
|
||||||
|
data={data}
|
||||||
|
margin={{
|
||||||
|
top: 5,
|
||||||
|
right: 30,
|
||||||
|
left: 20,
|
||||||
|
bottom: 5,
|
||||||
|
}}
|
||||||
|
reverseStackOrder
|
||||||
|
>
|
||||||
|
<XAxis
|
||||||
|
dataKey={xAxis.key}
|
||||||
|
tick={(props) => <CustomXAxisTick {...props} />}
|
||||||
|
tickLine={{
|
||||||
|
stroke: "currentColor",
|
||||||
|
className: AXIS_LINE_CLASSNAME,
|
||||||
|
}}
|
||||||
|
axisLine={{
|
||||||
|
stroke: "currentColor",
|
||||||
|
className: AXIS_LINE_CLASSNAME,
|
||||||
|
}}
|
||||||
|
label={{
|
||||||
|
value: xAxis.label,
|
||||||
|
dy: 28,
|
||||||
|
className: LABEL_CLASSNAME,
|
||||||
|
}}
|
||||||
|
tickCount={tickCount.x}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
domain={yAxis.domain}
|
||||||
|
tickLine={{
|
||||||
|
stroke: "currentColor",
|
||||||
|
className: AXIS_LINE_CLASSNAME,
|
||||||
|
}}
|
||||||
|
axisLine={{
|
||||||
|
stroke: "currentColor",
|
||||||
|
className: AXIS_LINE_CLASSNAME,
|
||||||
|
}}
|
||||||
|
label={{
|
||||||
|
value: yAxis.label,
|
||||||
|
angle: -90,
|
||||||
|
position: "bottom",
|
||||||
|
offset: -24,
|
||||||
|
dx: -16,
|
||||||
|
className: LABEL_CLASSNAME,
|
||||||
|
}}
|
||||||
|
tick={(props) => <CustomYAxisTick {...props} />}
|
||||||
|
tickCount={tickCount.y}
|
||||||
|
allowDecimals={!!yAxis.allowDecimals}
|
||||||
|
/>
|
||||||
|
{showTooltip && (
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
|
||||||
|
content={({ active, label, payload }) => (
|
||||||
|
<CustomTooltip
|
||||||
|
active={active}
|
||||||
|
label={label}
|
||||||
|
payload={payload}
|
||||||
|
itemKeys={itemKeys}
|
||||||
|
itemDotClassNames={itemDotClassNames}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{renderAreas}
|
||||||
|
</CoreAreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
AreaChart.displayName = "AreaChart";
|
||||||
70
packages/propel/src/charts/bar-chart/bar.tsx
Normal file
70
packages/propel/src/charts/bar-chart/bar.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import React from "react";
|
||||||
|
// plane imports
|
||||||
|
import { TChartData } from "@plane/types";
|
||||||
|
import { cn } from "@plane/utils";
|
||||||
|
|
||||||
|
// Helper to calculate percentage
|
||||||
|
const calculatePercentage = <K extends string, T extends string>(
|
||||||
|
data: TChartData<K, T>,
|
||||||
|
stackKeys: T[],
|
||||||
|
currentKey: T
|
||||||
|
): number => {
|
||||||
|
const total = stackKeys.reduce((sum, key) => sum + data[key], 0);
|
||||||
|
return total === 0 ? 0 : Math.round((data[currentKey] / total) * 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT = 14; // Minimum height needed to show text inside
|
||||||
|
const BAR_BORDER_RADIUS = 2; // Border radius for each bar
|
||||||
|
|
||||||
|
export const CustomBar = React.memo((props: any) => {
|
||||||
|
const { fill, x, y, width, height, dataKey, stackKeys, payload, textClassName, showPercentage } = props;
|
||||||
|
// Calculate text position
|
||||||
|
const TEXT_PADDING_Y = Math.min(6, Math.abs(MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT - height / 2));
|
||||||
|
const textY = y + height - TEXT_PADDING_Y; // Position inside bar if tall enough
|
||||||
|
// derived values
|
||||||
|
const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey);
|
||||||
|
const showText =
|
||||||
|
// from props
|
||||||
|
showPercentage &&
|
||||||
|
// height of the bar is greater than or equal to the minimum height required to show the text
|
||||||
|
height >= MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT &&
|
||||||
|
// bar percentage text has some value
|
||||||
|
currentBarPercentage !== undefined &&
|
||||||
|
// bar percentage is a number
|
||||||
|
!Number.isNaN(currentBarPercentage);
|
||||||
|
|
||||||
|
if (!height) return null;
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
d={`
|
||||||
|
M${x + BAR_BORDER_RADIUS},${y + height}
|
||||||
|
L${x + BAR_BORDER_RADIUS},${y}
|
||||||
|
Q${x},${y} ${x},${y + BAR_BORDER_RADIUS}
|
||||||
|
L${x},${y + height - BAR_BORDER_RADIUS}
|
||||||
|
Q${x},${y + height} ${x + BAR_BORDER_RADIUS},${y + height}
|
||||||
|
L${x + width - BAR_BORDER_RADIUS},${y + height}
|
||||||
|
Q${x + width},${y + height} ${x + width},${y + height - BAR_BORDER_RADIUS}
|
||||||
|
L${x + width},${y + BAR_BORDER_RADIUS}
|
||||||
|
Q${x + width},${y} ${x + width - BAR_BORDER_RADIUS},${y}
|
||||||
|
L${x + BAR_BORDER_RADIUS},${y}
|
||||||
|
`}
|
||||||
|
className={cn("transition-colors duration-200", fill)}
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
{showText && (
|
||||||
|
<text
|
||||||
|
x={x + width / 2}
|
||||||
|
y={textY}
|
||||||
|
textAnchor="middle"
|
||||||
|
className={cn("text-xs font-medium", textClassName)}
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
{currentBarPercentage}%
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
CustomBar.displayName = "CustomBar";
|
||||||
125
packages/propel/src/charts/bar-chart/root.tsx
Normal file
125
packages/propel/src/charts/bar-chart/root.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { BarChart as CoreBarChart, Bar, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
|
// plane imports
|
||||||
|
import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants";
|
||||||
|
import { TBarChartProps } from "@plane/types";
|
||||||
|
// local components
|
||||||
|
import { CustomXAxisTick, CustomYAxisTick } from "../tick";
|
||||||
|
import { CustomTooltip } from "../tooltip";
|
||||||
|
import { CustomBar } from "./bar";
|
||||||
|
|
||||||
|
export const BarChart = React.memo(<K extends string, T extends string>(props: TBarChartProps<K, T>) => {
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
bars,
|
||||||
|
xAxis,
|
||||||
|
yAxis,
|
||||||
|
barSize = 40,
|
||||||
|
className = "w-full h-96",
|
||||||
|
tickCount = {
|
||||||
|
x: undefined,
|
||||||
|
y: 10,
|
||||||
|
},
|
||||||
|
showTooltip = true,
|
||||||
|
} = props;
|
||||||
|
// derived values
|
||||||
|
const stackKeys = useMemo(() => bars.map((bar) => bar.key), [bars]);
|
||||||
|
const stackDotClassNames = useMemo(
|
||||||
|
() => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.dotClassName }), {}),
|
||||||
|
[bars]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBars = useMemo(
|
||||||
|
() =>
|
||||||
|
bars.map((bar) => (
|
||||||
|
<Bar
|
||||||
|
key={bar.key}
|
||||||
|
dataKey={bar.key}
|
||||||
|
stackId={bar.stackId}
|
||||||
|
fill={bar.fillClassName}
|
||||||
|
shape={(shapeProps: any) => (
|
||||||
|
<CustomBar
|
||||||
|
{...shapeProps}
|
||||||
|
stackKeys={stackKeys}
|
||||||
|
textClassName={bar.textClassName}
|
||||||
|
showPercentage={bar.showPercentage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
[stackKeys, bars]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<CoreBarChart
|
||||||
|
data={data}
|
||||||
|
margin={{ top: 10, right: 10, left: 10, bottom: 40 }}
|
||||||
|
barSize={barSize}
|
||||||
|
className="recharts-wrapper"
|
||||||
|
>
|
||||||
|
<XAxis
|
||||||
|
dataKey={xAxis.key}
|
||||||
|
tick={(props) => <CustomXAxisTick {...props} />}
|
||||||
|
tickLine={{
|
||||||
|
stroke: "currentColor",
|
||||||
|
className: AXIS_LINE_CLASSNAME,
|
||||||
|
}}
|
||||||
|
axisLine={{
|
||||||
|
stroke: "currentColor",
|
||||||
|
className: AXIS_LINE_CLASSNAME,
|
||||||
|
}}
|
||||||
|
label={{
|
||||||
|
value: xAxis.label,
|
||||||
|
dy: 28,
|
||||||
|
className: LABEL_CLASSNAME,
|
||||||
|
}}
|
||||||
|
tickCount={tickCount.x}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
domain={yAxis.domain}
|
||||||
|
tickLine={{
|
||||||
|
stroke: "currentColor",
|
||||||
|
className: AXIS_LINE_CLASSNAME,
|
||||||
|
}}
|
||||||
|
axisLine={{
|
||||||
|
stroke: "currentColor",
|
||||||
|
className: AXIS_LINE_CLASSNAME,
|
||||||
|
}}
|
||||||
|
label={{
|
||||||
|
value: yAxis.label,
|
||||||
|
angle: -90,
|
||||||
|
position: "bottom",
|
||||||
|
offset: -24,
|
||||||
|
dx: -16,
|
||||||
|
className: LABEL_CLASSNAME,
|
||||||
|
}}
|
||||||
|
tick={(props) => <CustomYAxisTick {...props} />}
|
||||||
|
tickCount={tickCount.y}
|
||||||
|
allowDecimals={!!yAxis.allowDecimals}
|
||||||
|
/>
|
||||||
|
{showTooltip && (
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
|
||||||
|
content={({ active, label, payload }) => (
|
||||||
|
<CustomTooltip
|
||||||
|
active={active}
|
||||||
|
label={label}
|
||||||
|
payload={payload}
|
||||||
|
itemKeys={stackKeys}
|
||||||
|
itemDotClassNames={stackDotClassNames}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{renderBars}
|
||||||
|
</CoreBarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
BarChart.displayName = "BarChart";
|
||||||
1
packages/propel/src/charts/line-chart/index.ts
Normal file
1
packages/propel/src/charts/line-chart/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./root";
|
||||||
115
packages/propel/src/charts/line-chart/root.tsx
Normal file
115
packages/propel/src/charts/line-chart/root.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { LineChart as CoreLineChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
|
// plane imports
|
||||||
|
import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants";
|
||||||
|
import { TLineChartProps } from "@plane/types";
|
||||||
|
// local components
|
||||||
|
import { CustomXAxisTick, CustomYAxisTick } from "../tick";
|
||||||
|
import { CustomTooltip } from "../tooltip";
|
||||||
|
|
||||||
|
export const LineChart = React.memo(<K extends string, T extends string>(props: TLineChartProps<K, T>) => {
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
lines,
|
||||||
|
xAxis,
|
||||||
|
yAxis,
|
||||||
|
className = "w-full h-96",
|
||||||
|
tickCount = {
|
||||||
|
x: undefined,
|
||||||
|
y: 10,
|
||||||
|
},
|
||||||
|
showTooltip = true,
|
||||||
|
} = props;
|
||||||
|
// derived values
|
||||||
|
const itemKeys = useMemo(() => lines.map((line) => line.key), [lines]);
|
||||||
|
const itemDotClassNames = useMemo(
|
||||||
|
() => lines.reduce((acc, line) => ({ ...acc, [line.key]: line.dotClassName }), {}),
|
||||||
|
[lines]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderLines = useMemo(
|
||||||
|
() =>
|
||||||
|
lines.map((line) => (
|
||||||
|
<Line key={line.key} dataKey={line.key} type="monotone" className={line.className} stroke="inherit" />
|
||||||
|
)),
|
||||||
|
[lines]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<CoreLineChart
|
||||||
|
width={500}
|
||||||
|
height={300}
|
||||||
|
data={data}
|
||||||
|
margin={{
|
||||||
|
top: 5,
|
||||||
|
right: 30,
|
||||||
|
left: 20,
|
||||||
|
bottom: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XAxis
|
||||||
|
dataKey={xAxis.key}
|
||||||
|
tick={(props) => <CustomXAxisTick {...props} />}
|
||||||
|
tickLine={{
|
||||||
|
stroke: "currentColor",
|
||||||
|
className: AXIS_LINE_CLASSNAME,
|
||||||
|
}}
|
||||||
|
axisLine={{
|
||||||
|
stroke: "currentColor",
|
||||||
|
className: AXIS_LINE_CLASSNAME,
|
||||||
|
}}
|
||||||
|
label={{
|
||||||
|
value: xAxis.label,
|
||||||
|
dy: 28,
|
||||||
|
className: LABEL_CLASSNAME,
|
||||||
|
}}
|
||||||
|
tickCount={tickCount.x}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
domain={yAxis.domain}
|
||||||
|
tickLine={{
|
||||||
|
stroke: "currentColor",
|
||||||
|
className: AXIS_LINE_CLASSNAME,
|
||||||
|
}}
|
||||||
|
axisLine={{
|
||||||
|
stroke: "currentColor",
|
||||||
|
className: AXIS_LINE_CLASSNAME,
|
||||||
|
}}
|
||||||
|
label={{
|
||||||
|
value: yAxis.label,
|
||||||
|
angle: -90,
|
||||||
|
position: "bottom",
|
||||||
|
offset: -24,
|
||||||
|
dx: -16,
|
||||||
|
className: LABEL_CLASSNAME,
|
||||||
|
}}
|
||||||
|
tick={(props) => <CustomYAxisTick {...props} />}
|
||||||
|
tickCount={tickCount.y}
|
||||||
|
allowDecimals={!!yAxis.allowDecimals}
|
||||||
|
/>
|
||||||
|
{showTooltip && (
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
|
||||||
|
content={({ active, label, payload }) => (
|
||||||
|
<CustomTooltip
|
||||||
|
active={active}
|
||||||
|
label={label}
|
||||||
|
payload={payload}
|
||||||
|
itemKeys={itemKeys}
|
||||||
|
itemDotClassNames={itemDotClassNames}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{renderLines}
|
||||||
|
</CoreLineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
LineChart.displayName = "LineChart";
|
||||||
1
packages/propel/src/charts/pie-chart/index.ts
Normal file
1
packages/propel/src/charts/pie-chart/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./root";
|
||||||
51
packages/propel/src/charts/pie-chart/root.tsx
Normal file
51
packages/propel/src/charts/pie-chart/root.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { Cell, PieChart as CorePieChart, Pie, ResponsiveContainer, Tooltip } from "recharts";
|
||||||
|
// plane imports
|
||||||
|
import { TPieChartProps } from "@plane/types";
|
||||||
|
// local components
|
||||||
|
import { CustomPieChartTooltip } from "./tooltip";
|
||||||
|
|
||||||
|
export const PieChart = React.memo(<K extends string, T extends string>(props: TPieChartProps<K, T>) => {
|
||||||
|
const { data, dataKey, cells, className = "w-full h-96", innerRadius, outerRadius, showTooltip = true } = props;
|
||||||
|
|
||||||
|
const renderCells = useMemo(
|
||||||
|
() => cells.map((cell) => <Cell key={cell.key} className={cell.className} style={cell.style} />),
|
||||||
|
[cells]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<CorePieChart
|
||||||
|
width={500}
|
||||||
|
height={300}
|
||||||
|
data={data}
|
||||||
|
margin={{
|
||||||
|
top: 5,
|
||||||
|
right: 30,
|
||||||
|
left: 20,
|
||||||
|
bottom: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pie data={data} dataKey={dataKey} cx="50%" cy="50%" innerRadius={innerRadius} outerRadius={outerRadius}>
|
||||||
|
{renderCells}
|
||||||
|
</Pie>
|
||||||
|
{showTooltip && (
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (!active || !payload || !payload.length) return null;
|
||||||
|
const cellData = cells.find((c) => c.key === payload[0].name);
|
||||||
|
if (!cellData) return null;
|
||||||
|
return <CustomPieChartTooltip dotClassName={cellData.dotClassName} label={dataKey} payload={payload} />;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CorePieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
PieChart.displayName = "PieChart";
|
||||||
31
packages/propel/src/charts/pie-chart/tooltip.tsx
Normal file
31
packages/propel/src/charts/pie-chart/tooltip.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import React from "react";
|
||||||
|
import { NameType, Payload, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
||||||
|
// plane imports
|
||||||
|
import { Card, ECardSpacing } from "@plane/ui";
|
||||||
|
import { cn } from "@plane/utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
dotClassName?: string;
|
||||||
|
label: string;
|
||||||
|
payload: Payload<ValueType, NameType>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomPieChartTooltip = React.memo((props: Props) => {
|
||||||
|
const { dotClassName, label, payload } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="flex flex-col" spacing={ECardSpacing.SM}>
|
||||||
|
<p className="text-xs text-custom-text-100 font-medium border-b border-custom-border-200 pb-2 capitalize">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
{payload?.map((item) => (
|
||||||
|
<div key={item?.dataKey} className="flex items-center gap-2 text-xs capitalize">
|
||||||
|
<div className={cn("size-2 rounded-full", dotClassName)} />
|
||||||
|
<span className="text-custom-text-300">{item?.name}:</span>
|
||||||
|
<span className="font-medium text-custom-text-200">{item?.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
CustomPieChartTooltip.displayName = "CustomPieChartTooltip";
|
||||||
41
packages/propel/src/charts/tooltip.tsx
Normal file
41
packages/propel/src/charts/tooltip.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import React from "react";
|
||||||
|
import { NameType, Payload, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
||||||
|
// plane imports
|
||||||
|
import { Card, ECardSpacing } from "@plane/ui";
|
||||||
|
import { cn } from "@plane/utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
active: boolean | undefined;
|
||||||
|
label: string | undefined;
|
||||||
|
payload: Payload<ValueType, NameType>[] | undefined;
|
||||||
|
itemKeys: string[];
|
||||||
|
itemDotClassNames: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomTooltip = React.memo((props: Props) => {
|
||||||
|
const { active, label, payload, itemKeys, itemDotClassNames } = props;
|
||||||
|
// derived values
|
||||||
|
const filteredPayload = payload?.filter((item) => item.dataKey && itemKeys.includes(`${item.dataKey}`));
|
||||||
|
|
||||||
|
if (!active || !filteredPayload || !filteredPayload.length) return null;
|
||||||
|
return (
|
||||||
|
<Card className="flex flex-col" spacing={ECardSpacing.SM}>
|
||||||
|
<p className="text-xs text-custom-text-100 font-medium border-b border-custom-border-200 pb-2 capitalize">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
{filteredPayload.map((item) => {
|
||||||
|
if (!item.dataKey) return null;
|
||||||
|
return (
|
||||||
|
<div key={item?.dataKey} className="flex items-center gap-2 text-xs capitalize">
|
||||||
|
{itemDotClassNames[item?.dataKey] && (
|
||||||
|
<div className={cn("size-2 rounded-full", itemDotClassNames[item?.dataKey])} />
|
||||||
|
)}
|
||||||
|
<span className="text-custom-text-300">{item?.name}:</span>
|
||||||
|
<span className="font-medium text-custom-text-200">{item?.value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
CustomTooltip.displayName = "CustomTooltip";
|
||||||
1
packages/propel/src/charts/tree-map/index.ts
Normal file
1
packages/propel/src/charts/tree-map/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./root";
|
||||||
|
|
@ -2,53 +2,6 @@
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
|
||||||
:root {
|
|
||||||
--background: 0 0% 100%;
|
|
||||||
--foreground: 222.2 47.4% 11.2%;
|
|
||||||
--muted: 210 40% 96.1%;
|
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
|
||||||
--popover: 0 0% 100%;
|
|
||||||
--popover-foreground: 222.2 47.4% 11.2%;
|
|
||||||
--border: 214.3 31.8% 91.4%;
|
|
||||||
--input: 214.3 31.8% 91.4%;
|
|
||||||
--card: 0 0% 100%;
|
|
||||||
--card-foreground: 222.2 47.4% 11.2%;
|
|
||||||
--primary: 222.2 47.4% 11.2%;
|
|
||||||
--primary-foreground: 210 40% 98%;
|
|
||||||
--secondary: 210 40% 96.1%;
|
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
|
||||||
--accent: 210 40% 96.1%;
|
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
|
||||||
--destructive: 0 100% 50%;
|
|
||||||
--destructive-foreground: 210 40% 98%;
|
|
||||||
--ring: 215 20.2% 65.1%;
|
|
||||||
--radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--background: 224 71% 4%;
|
|
||||||
--foreground: 213 31% 91%;
|
|
||||||
--muted: 223 47% 11%;
|
|
||||||
--muted-foreground: 215.4 16.3% 56.9%;
|
|
||||||
--accent: 216 34% 17%;
|
|
||||||
--accent-foreground: 210 40% 98%;
|
|
||||||
--popover: 224 71% 4%;
|
|
||||||
--popover-foreground: 215 20.2% 65.1%;
|
|
||||||
--border: 216 34% 17%;
|
|
||||||
--input: 216 34% 17%;
|
|
||||||
--card: 224 71% 4%;
|
|
||||||
--card-foreground: 213 31% 91%;
|
|
||||||
--primary: 210 40% 98%;
|
|
||||||
--primary-foreground: 222.2 47.4% 1.2%;
|
|
||||||
--secondary: 222.2 47.4% 11.2%;
|
|
||||||
--secondary-foreground: 210 40% 98%;
|
|
||||||
--destructive: 0 63% 31%;
|
|
||||||
--destructive-foreground: 210 40% 98%;
|
|
||||||
--ring: 216 34% 17%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
|
|
|
||||||
0
packages/propel/src/index.ts
Normal file
0
packages/propel/src/index.ts
Normal file
|
|
@ -1,57 +0,0 @@
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
import * as React from "react";
|
|
||||||
|
|
||||||
import { cn } from "@plane/utils";
|
|
||||||
|
|
||||||
const buttonVariants = cva(
|
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default:
|
|
||||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
|
||||||
destructive:
|
|
||||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
|
||||||
outline:
|
|
||||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
|
||||||
secondary:
|
|
||||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: "h-9 px-4 py-2",
|
|
||||||
sm: "h-8 rounded-md px-3 text-xs",
|
|
||||||
lg: "h-10 rounded-md px-8",
|
|
||||||
icon: "h-9 w-9",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface ButtonProps
|
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
||||||
VariantProps<typeof buttonVariants> {
|
|
||||||
asChild?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
|
||||||
const Comp = asChild ? Slot : "button";
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
Button.displayName = "Button";
|
|
||||||
|
|
||||||
export { Button, buttonVariants };
|
|
||||||
|
|
@ -19,6 +19,7 @@ module.exports = {
|
||||||
"./app/**/*.tsx",
|
"./app/**/*.tsx",
|
||||||
"./ui/**/*.tsx",
|
"./ui/**/*.tsx",
|
||||||
"../packages/ui/src/**/*.{js,ts,jsx,tsx}",
|
"../packages/ui/src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
"../packages/propel/src/**/*.{js,ts,jsx,tsx}",
|
||||||
"../packages/editor/src/**/*.{js,ts,jsx,tsx}",
|
"../packages/editor/src/**/*.{js,ts,jsx,tsx}",
|
||||||
"!../packages/ui/**/*.stories{js,ts,jsx,tsx}",
|
"!../packages/ui/**/*.stories{js,ts,jsx,tsx}",
|
||||||
],
|
],
|
||||||
|
|
|
||||||
77
packages/types/src/charts.d.ts
vendored
77
packages/types/src/charts.d.ts
vendored
|
|
@ -1,29 +1,20 @@
|
||||||
export type TStackItem<T extends string> = {
|
export type TChartData<K extends string, T extends string> = {
|
||||||
key: T;
|
// required key
|
||||||
fillClassName: string;
|
|
||||||
textClassName: string;
|
|
||||||
dotClassName?: string;
|
|
||||||
showPercentage?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TStackChartData<K extends string, T extends string> = {
|
|
||||||
[key in K]: string | number;
|
[key in K]: string | number;
|
||||||
} & Record<T, any>;
|
} & Record<T, any>;
|
||||||
|
|
||||||
export type TStackedBarChartProps<K extends string, T extends string> = {
|
type TChartProps<K extends string, T extends string> = {
|
||||||
data: TStackChartData<K, T>[];
|
data: TChartData<K, T>[];
|
||||||
stacks: TStackItem<T>[];
|
|
||||||
xAxis: {
|
xAxis: {
|
||||||
key: keyof TStackChartData<K, T>;
|
key: keyof TChartData<K, T>;
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
yAxis: {
|
yAxis: {
|
||||||
key: keyof TStackChartData<K, T>;
|
key: keyof TChartData<K, T>;
|
||||||
label: string;
|
label: string;
|
||||||
domain?: [number, number];
|
domain?: [number, number];
|
||||||
allowDecimals?: boolean;
|
allowDecimals?: boolean;
|
||||||
};
|
};
|
||||||
barSize?: number;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
tickCount?: {
|
tickCount?: {
|
||||||
x?: number;
|
x?: number;
|
||||||
|
|
@ -32,6 +23,60 @@ export type TStackedBarChartProps<K extends string, T extends string> = {
|
||||||
showTooltip?: boolean;
|
showTooltip?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TBarItem<T extends string> = {
|
||||||
|
key: T;
|
||||||
|
fillClassName: string;
|
||||||
|
textClassName: string;
|
||||||
|
dotClassName?: string;
|
||||||
|
showPercentage?: boolean;
|
||||||
|
stackId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TBarChartProps<K extends string, T extends string> = TChartProps<K, T> & {
|
||||||
|
bars: TBarItem<T>[];
|
||||||
|
barSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TLineItem<T extends string> = {
|
||||||
|
key: T;
|
||||||
|
className?: string;
|
||||||
|
style?: Record<string, string | number>;
|
||||||
|
dotClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TLineChartProps<K extends string, T extends string> = TChartProps<K, T> & {
|
||||||
|
lines: TLineItem<T>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TAreaItem<T extends string> = {
|
||||||
|
key: T;
|
||||||
|
stackId: string;
|
||||||
|
className?: string;
|
||||||
|
style?: Record<string, string | number>;
|
||||||
|
dotClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TAreaChartProps<K extends string, T extends string> = TChartProps<K, T> & {
|
||||||
|
areas: TAreaItem<T>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCellItem<T extends string> = {
|
||||||
|
key: T;
|
||||||
|
className?: string;
|
||||||
|
style?: Record<string, string | number>;
|
||||||
|
dotClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TPieChartProps<K extends string, T extends string> = Pick<
|
||||||
|
TChartProps<K, T>,
|
||||||
|
"className" | "data" | "showTooltip"
|
||||||
|
> & {
|
||||||
|
dataKey: T;
|
||||||
|
cells: TCellItem<T>[];
|
||||||
|
innerRadius?: number;
|
||||||
|
outerRadius?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type TreeMapItem = {
|
export type TreeMapItem = {
|
||||||
name: string;
|
name: string;
|
||||||
value: number;
|
value: number;
|
||||||
|
|
@ -45,7 +90,7 @@ export type TreeMapItem = {
|
||||||
| {
|
| {
|
||||||
fillClassName: string;
|
fillClassName: string;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export type TreeMapChartProps = {
|
export type TreeMapChartProps = {
|
||||||
data: TreeMapItem[];
|
data: TreeMapItem[];
|
||||||
|
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
import React from "react";
|
|
||||||
// plane imports
|
|
||||||
import { TStackChartData } from "@plane/types";
|
|
||||||
import { cn } from "@plane/utils";
|
|
||||||
|
|
||||||
// Helper to calculate percentage
|
|
||||||
const calculatePercentage = <K extends string, T extends string>(
|
|
||||||
data: TStackChartData<K, T>,
|
|
||||||
stackKeys: T[],
|
|
||||||
currentKey: T
|
|
||||||
): number => {
|
|
||||||
const total = stackKeys.reduce((sum, key) => sum + data[key], 0);
|
|
||||||
return total === 0 ? 0 : Math.round((data[currentKey] / total) * 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CustomStackBar = React.memo<any>((props: any) => {
|
|
||||||
const { fill, x, y, width, height, dataKey, stackKeys, payload, textClassName, showPercentage } = props;
|
|
||||||
// Calculate text position
|
|
||||||
const MIN_BAR_HEIGHT_FOR_INTERNAL = 14; // Minimum height needed to show text inside
|
|
||||||
const TEXT_PADDING = Math.min(6, Math.abs(MIN_BAR_HEIGHT_FOR_INTERNAL - height / 2));
|
|
||||||
const textY = y + height - TEXT_PADDING; // Position inside bar if tall enough
|
|
||||||
// derived values
|
|
||||||
const RADIUS = 2;
|
|
||||||
const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey);
|
|
||||||
|
|
||||||
if (!height) return null;
|
|
||||||
return (
|
|
||||||
<g>
|
|
||||||
<path
|
|
||||||
d={`
|
|
||||||
M${x + RADIUS},${y + height}
|
|
||||||
L${x + RADIUS},${y}
|
|
||||||
Q${x},${y} ${x},${y + RADIUS}
|
|
||||||
L${x},${y + height - RADIUS}
|
|
||||||
Q${x},${y + height} ${x + RADIUS},${y + height}
|
|
||||||
L${x + width - RADIUS},${y + height}
|
|
||||||
Q${x + width},${y + height} ${x + width},${y + height - RADIUS}
|
|
||||||
L${x + width},${y + RADIUS}
|
|
||||||
Q${x + width},${y} ${x + width - RADIUS},${y}
|
|
||||||
L${x + RADIUS},${y}
|
|
||||||
`}
|
|
||||||
className={cn("transition-colors duration-200", fill)}
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
{showPercentage &&
|
|
||||||
height >= MIN_BAR_HEIGHT_FOR_INTERNAL &&
|
|
||||||
currentBarPercentage !== undefined &&
|
|
||||||
!Number.isNaN(currentBarPercentage) && (
|
|
||||||
<text
|
|
||||||
x={x + width / 2}
|
|
||||||
y={textY}
|
|
||||||
textAnchor="middle"
|
|
||||||
className={cn("text-xs font-medium", textClassName)}
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
{currentBarPercentage}%
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
CustomStackBar.displayName = "CustomStackBar";
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip } from "recharts";
|
|
||||||
// plane imports
|
|
||||||
import { TStackedBarChartProps } from "@plane/types";
|
|
||||||
import { cn } from "@plane/utils";
|
|
||||||
// local components
|
|
||||||
import { CustomStackBar } from "./bar";
|
|
||||||
import { CustomXAxisTick, CustomYAxisTick } from "./tick";
|
|
||||||
import { CustomTooltip } from "./tooltip";
|
|
||||||
|
|
||||||
// Common classnames
|
|
||||||
const LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide";
|
|
||||||
const AXIS_LINE_CLASSNAME = "text-custom-text-400/70";
|
|
||||||
|
|
||||||
export const StackedBarChart = React.memo(
|
|
||||||
<K extends string, T extends string>({
|
|
||||||
data,
|
|
||||||
stacks,
|
|
||||||
xAxis,
|
|
||||||
yAxis,
|
|
||||||
barSize = 40,
|
|
||||||
className = "w-full h-96",
|
|
||||||
tickCount = {
|
|
||||||
x: undefined,
|
|
||||||
y: 10,
|
|
||||||
},
|
|
||||||
showTooltip = true,
|
|
||||||
}: TStackedBarChartProps<K, T>) => {
|
|
||||||
// derived values
|
|
||||||
const stackKeys = React.useMemo(() => stacks.map((stack) => stack.key), [stacks]);
|
|
||||||
const stackDotClassNames = React.useMemo(
|
|
||||||
() => stacks.reduce((acc, stack) => ({ ...acc, [stack.key]: stack.dotClassName }), {}),
|
|
||||||
[stacks]
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderBars = React.useMemo(
|
|
||||||
() =>
|
|
||||||
stacks.map((stack) => (
|
|
||||||
<Bar
|
|
||||||
key={stack.key}
|
|
||||||
dataKey={stack.key}
|
|
||||||
stackId="a"
|
|
||||||
fill={stack.fillClassName}
|
|
||||||
shape={(props: any) => (
|
|
||||||
<CustomStackBar
|
|
||||||
{...props}
|
|
||||||
stackKeys={stackKeys}
|
|
||||||
textClassName={stack.textClassName}
|
|
||||||
showPercentage={stack.showPercentage}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)),
|
|
||||||
[stackKeys, stacks]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn(className)}>
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart
|
|
||||||
data={data}
|
|
||||||
margin={{ top: 10, right: 10, left: 10, bottom: 40 }}
|
|
||||||
barSize={barSize}
|
|
||||||
className="recharts-wrapper"
|
|
||||||
>
|
|
||||||
<XAxis
|
|
||||||
dataKey={xAxis.key}
|
|
||||||
tick={(props) => <CustomXAxisTick {...props} />}
|
|
||||||
tickLine={{
|
|
||||||
stroke: "currentColor",
|
|
||||||
className: AXIS_LINE_CLASSNAME,
|
|
||||||
}}
|
|
||||||
axisLine={{
|
|
||||||
stroke: "currentColor",
|
|
||||||
className: AXIS_LINE_CLASSNAME,
|
|
||||||
}}
|
|
||||||
label={{
|
|
||||||
value: xAxis.label,
|
|
||||||
dy: 28,
|
|
||||||
className: LABEL_CLASSNAME,
|
|
||||||
}}
|
|
||||||
tickCount={tickCount.x}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
domain={yAxis.domain}
|
|
||||||
tickLine={{
|
|
||||||
stroke: "currentColor",
|
|
||||||
className: AXIS_LINE_CLASSNAME,
|
|
||||||
}}
|
|
||||||
axisLine={{
|
|
||||||
stroke: "currentColor",
|
|
||||||
className: AXIS_LINE_CLASSNAME,
|
|
||||||
}}
|
|
||||||
label={{
|
|
||||||
value: yAxis.label,
|
|
||||||
angle: -90,
|
|
||||||
position: "bottom",
|
|
||||||
offset: -24,
|
|
||||||
dx: -16,
|
|
||||||
className: LABEL_CLASSNAME,
|
|
||||||
}}
|
|
||||||
tick={(props) => <CustomYAxisTick {...props} />}
|
|
||||||
tickCount={tickCount.y}
|
|
||||||
allowDecimals={yAxis.allowDecimals ?? false}
|
|
||||||
/>
|
|
||||||
{showTooltip && (
|
|
||||||
<Tooltip
|
|
||||||
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
|
|
||||||
content={({ active, label, payload }) => (
|
|
||||||
<CustomTooltip
|
|
||||||
active={active}
|
|
||||||
label={label}
|
|
||||||
payload={payload}
|
|
||||||
stackKeys={stackKeys}
|
|
||||||
stackDotClassNames={stackDotClassNames}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{renderBars}
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
StackedBarChart.displayName = "StackedBarChart";
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
import React from "react";
|
|
||||||
// plane imports
|
|
||||||
import { Card, ECardSpacing } from "@plane/ui";
|
|
||||||
import { cn } from "@plane/utils";
|
|
||||||
|
|
||||||
type TStackedBarChartProps = {
|
|
||||||
active: boolean | undefined;
|
|
||||||
label: string | undefined;
|
|
||||||
payload: any[] | undefined;
|
|
||||||
stackKeys: string[];
|
|
||||||
stackDotClassNames: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CustomTooltip = React.memo(
|
|
||||||
({ active, label, payload, stackKeys, stackDotClassNames }: TStackedBarChartProps) => {
|
|
||||||
// derived values
|
|
||||||
const filteredPayload = payload?.filter((item: any) => item.dataKey && stackKeys.includes(item.dataKey));
|
|
||||||
|
|
||||||
if (!active || !filteredPayload || !filteredPayload.length) return null;
|
|
||||||
return (
|
|
||||||
<Card className="flex flex-col" spacing={ECardSpacing.SM}>
|
|
||||||
<p className="text-xs text-custom-text-100 font-medium border-b border-custom-border-200 pb-2 capitalize">
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
{filteredPayload.map((item: any) => (
|
|
||||||
<div key={item?.dataKey} className="flex items-center gap-2 text-xs capitalize">
|
|
||||||
{stackDotClassNames[item?.dataKey] && (
|
|
||||||
<div className={cn("size-2 rounded-full", stackDotClassNames[item?.dataKey])} />
|
|
||||||
)}
|
|
||||||
<span className="text-custom-text-300">{item?.name}:</span>
|
|
||||||
<span className="font-medium text-custom-text-200">{item?.value}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
CustomTooltip.displayName = "CustomTooltip";
|
|
||||||
|
|
@ -18,7 +18,7 @@ const nextConfig = {
|
||||||
images: {
|
images: {
|
||||||
unoptimized: true,
|
unoptimized: true,
|
||||||
},
|
},
|
||||||
transpilePackages: ["@plane/i18n"],
|
transpilePackages: ["@plane/i18n", "@plane/propel"],
|
||||||
async redirects() {
|
async redirects() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@
|
||||||
"@plane/editor": "*",
|
"@plane/editor": "*",
|
||||||
"@plane/hooks": "*",
|
"@plane/hooks": "*",
|
||||||
"@plane/i18n": "*",
|
"@plane/i18n": "*",
|
||||||
|
"@plane/propel": "*",
|
||||||
"@plane/types": "*",
|
"@plane/types": "*",
|
||||||
"@plane/ui": "*",
|
"@plane/ui": "*",
|
||||||
"@plane/utils": "*",
|
"@plane/utils": "*",
|
||||||
|
|
|
||||||
71
yarn.lock
71
yarn.lock
|
|
@ -9165,21 +9165,6 @@ lru-cache@^5.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
yallist "^3.0.2"
|
yallist "^3.0.2"
|
||||||
|
|
||||||
lucide-react@^0.356.0:
|
|
||||||
version "0.356.0"
|
|
||||||
resolved "https://registry.npmjs.org/lucide-react/-/lucide-react-0.356.0.tgz"
|
|
||||||
integrity sha512-MDInjLrmZToccH2UxEshntujBlFwtOofGB22FN/eg39FfGVYV1TT1eMIv2j4rdaTJBpYjUuX7fEo9pwYkNFgwA==
|
|
||||||
|
|
||||||
lucide-react@^0.378.0:
|
|
||||||
version "0.378.0"
|
|
||||||
resolved "https://registry.npmjs.org/lucide-react/-/lucide-react-0.378.0.tgz"
|
|
||||||
integrity sha512-u6EPU8juLUk9ytRcyapkWI18epAv3RU+6+TC23ivjR0e+glWKBobFeSgRwOIJihzktILQuy6E0E80P2jVTDR5g==
|
|
||||||
|
|
||||||
lucide-react@^0.379.0:
|
|
||||||
version "0.379.0"
|
|
||||||
resolved "https://registry.npmjs.org/lucide-react/-/lucide-react-0.379.0.tgz"
|
|
||||||
integrity sha512-KcdeVPqmhRldldAAgptb8FjIunM2x2Zy26ZBh1RsEUcdLIvsEmbcw7KpzFYUy5BbpGeWhPu9Z9J5YXfStiXwhg==
|
|
||||||
|
|
||||||
lucide-react@^0.469.0:
|
lucide-react@^0.469.0:
|
||||||
version "0.469.0"
|
version "0.469.0"
|
||||||
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.469.0.tgz#f16936ca6521482fef754a7eabb310e6c68e1482"
|
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.469.0.tgz#f16936ca6521482fef754a7eabb310e6c68e1482"
|
||||||
|
|
@ -11170,6 +11155,15 @@ react-smooth@^4.0.0:
|
||||||
prop-types "^15.8.1"
|
prop-types "^15.8.1"
|
||||||
react-transition-group "^4.4.5"
|
react-transition-group "^4.4.5"
|
||||||
|
|
||||||
|
react-smooth@^4.0.4:
|
||||||
|
version "4.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-smooth/-/react-smooth-4.0.4.tgz#a5875f8bb61963ca61b819cedc569dc2453894b4"
|
||||||
|
integrity sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==
|
||||||
|
dependencies:
|
||||||
|
fast-equals "^5.0.1"
|
||||||
|
prop-types "^15.8.1"
|
||||||
|
react-transition-group "^4.4.5"
|
||||||
|
|
||||||
react-style-singleton@^2.2.1:
|
react-style-singleton@^2.2.1:
|
||||||
version "2.2.1"
|
version "2.2.1"
|
||||||
resolved "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz"
|
resolved "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz"
|
||||||
|
|
@ -11274,6 +11268,20 @@ recharts@^2.12.7:
|
||||||
tiny-invariant "^1.3.1"
|
tiny-invariant "^1.3.1"
|
||||||
victory-vendor "^36.6.8"
|
victory-vendor "^36.6.8"
|
||||||
|
|
||||||
|
recharts@^2.15.1:
|
||||||
|
version "2.15.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/recharts/-/recharts-2.15.1.tgz#0941adf0402528d54f6d81997eb15840c893aa3c"
|
||||||
|
integrity sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==
|
||||||
|
dependencies:
|
||||||
|
clsx "^2.0.0"
|
||||||
|
eventemitter3 "^4.0.1"
|
||||||
|
lodash "^4.17.21"
|
||||||
|
react-is "^18.3.1"
|
||||||
|
react-smooth "^4.0.4"
|
||||||
|
recharts-scale "^0.4.4"
|
||||||
|
tiny-invariant "^1.3.1"
|
||||||
|
victory-vendor "^36.6.8"
|
||||||
|
|
||||||
redent@^3.0.0:
|
redent@^3.0.0:
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz"
|
resolved "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz"
|
||||||
|
|
@ -12028,16 +12036,7 @@ streamx@^2.15.0, streamx@^2.20.0:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
bare-events "^2.2.0"
|
bare-events "^2.2.0"
|
||||||
|
|
||||||
"string-width-cjs@npm:string-width@^4.2.0":
|
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||||
version "4.2.3"
|
|
||||||
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
|
|
||||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
|
||||||
dependencies:
|
|
||||||
emoji-regex "^8.0.0"
|
|
||||||
is-fullwidth-code-point "^3.0.0"
|
|
||||||
strip-ansi "^6.0.1"
|
|
||||||
|
|
||||||
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
|
||||||
version "4.2.3"
|
version "4.2.3"
|
||||||
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
|
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
|
||||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||||
|
|
@ -12149,14 +12148,7 @@ string_decoder@^1.1.1, string_decoder@^1.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer "~5.2.0"
|
safe-buffer "~5.2.0"
|
||||||
|
|
||||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||||
version "6.0.1"
|
|
||||||
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
|
||||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
|
||||||
dependencies:
|
|
||||||
ansi-regex "^5.0.1"
|
|
||||||
|
|
||||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
|
||||||
version "6.0.1"
|
version "6.0.1"
|
||||||
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
||||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||||
|
|
@ -12297,7 +12289,7 @@ tabbable@^6.0.0:
|
||||||
resolved "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz"
|
resolved "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz"
|
||||||
integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==
|
integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==
|
||||||
|
|
||||||
tailwind-merge@^2.0.0, tailwind-merge@^2.5.5, tailwind-merge@^2.6.0:
|
tailwind-merge@^2.0.0, tailwind-merge@^2.5.5:
|
||||||
version "2.6.0"
|
version "2.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.6.0.tgz#ac5fb7e227910c038d458f396b7400d93a3142d5"
|
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.6.0.tgz#ac5fb7e227910c038d458f396b7400d93a3142d5"
|
||||||
integrity sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==
|
integrity sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==
|
||||||
|
|
@ -13449,16 +13441,7 @@ word-wrap@^1.2.5:
|
||||||
resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz"
|
resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz"
|
||||||
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
|
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
|
||||||
|
|
||||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
|
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
|
||||||
version "7.0.0"
|
|
||||||
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
|
|
||||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
|
||||||
dependencies:
|
|
||||||
ansi-styles "^4.0.0"
|
|
||||||
string-width "^4.1.0"
|
|
||||||
strip-ansi "^6.0.0"
|
|
||||||
|
|
||||||
wrap-ansi@^7.0.0:
|
|
||||||
version "7.0.0"
|
version "7.0.0"
|
||||||
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
|
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
|
||||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue