[WEB-3697] chore: chart components (#6835)

This commit is contained in:
Anmol Singh Bhatia
2025-03-27 17:46:43 +05:30
committed by GitHub
parent 869c755065
commit 471fefce8b
14 changed files with 688 additions and 237 deletions

View File

@@ -1,2 +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";
export const AXIS_LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide";

View File

@@ -1,14 +1,14 @@
/* 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";
import React, { useMemo, useState } from "react";
import { Area, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis, Line, ComposedChart, CartesianGrid } from "recharts";
// plane imports
import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants";
import { AXIS_LABEL_CLASSNAME } from "@plane/constants";
import { TAreaChartProps } from "@plane/types";
// local components
import { CustomXAxisTick, CustomYAxisTick } from "../tick";
import { CustomTooltip } from "../tooltip";
import { getLegendProps } from "../components/legend";
import { CustomXAxisTick, CustomYAxisTick } from "../components/tick";
import { CustomTooltip } from "../components/tooltip";
export const AreaChart = React.memo(<K extends string, T extends string>(props: TAreaChartProps<K, T>) => {
const {
@@ -16,107 +16,174 @@ export const AreaChart = React.memo(<K extends string, T extends string>(props:
areas,
xAxis,
yAxis,
className = "w-full h-96",
className,
legend,
margin,
tickCount = {
x: undefined,
y: 10,
},
showTooltip = true,
comparisonLine,
} = props;
// states
const [activeArea, setActiveArea] = useState<string | null>(null);
const [activeLegend, setActiveLegend] = useState<string | null>(null);
// derived values
const itemKeys = useMemo(() => areas.map((area) => area.key), [areas]);
const itemDotClassNames = useMemo(
() => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.dotClassName }), {}),
const itemLabels: Record<string, string> = useMemo(
() => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.label }), {}),
[areas]
);
const itemDotColors = useMemo(() => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.fill }), {}), [areas]);
const renderAreas = useMemo(
() =>
areas.map((area) => (
<Area
key={area.key}
type="monotone"
type={area.smoothCurves ? "monotone" : "linear"}
dataKey={area.key}
stackId={area.stackId}
className={area.className}
stroke="inherit"
fill="inherit"
fill={area.fill}
opacity={!!activeLegend && activeLegend !== area.key ? 0.1 : 1}
fillOpacity={area.fillOpacity}
strokeOpacity={area.strokeOpacity}
stroke={area.strokeColor}
strokeWidth={2}
style={area.style}
dot={
area.showDot
? {
fill: area.fill,
fillOpacity: 1,
}
: false
}
activeDot={{
stroke: area.fill,
}}
onMouseEnter={() => setActiveArea(area.key)}
onMouseLeave={() => setActiveArea(null)}
className="[&_path]:transition-opacity [&_path]:duration-200"
/>
)),
[areas]
[activeLegend, areas]
);
// create comparison line data for straight line from origin to last point
const comparisonLineData = useMemo(() => {
if (!data || data.length === 0) return [];
// get the last data point
const lastPoint = data[data.length - 1];
// for the y-value in the last point, use its yAxis key value
const lastYValue = lastPoint[yAxis.key] || 0;
// create data for a straight line that has points at each x-axis position
return data.map((item, index) => {
// calculate the y value for this point on the straight line
// using linear interpolation between (0,0) and (last_x, last_y)
const ratio = index / (data.length - 1);
const interpolatedValue = ratio * lastYValue;
return {
[xAxis.key]: item[xAxis.key],
comparisonLine: interpolatedValue,
};
});
}, [data, xAxis.key]);
return (
<div className={className}>
<ResponsiveContainer width="100%" height="100%">
<CoreAreaChart
width={500}
height={300}
<ComposedChart
data={data}
margin={{
top: 5,
right: 30,
left: 20,
bottom: 5,
top: margin?.top === undefined ? 5 : margin.top,
right: margin?.right === undefined ? 30 : margin.right,
bottom: margin?.bottom === undefined ? 5 : margin.bottom,
left: margin?.left === undefined ? 20 : margin.left,
}}
reverseStackOrder
>
<CartesianGrid stroke="rgba(var(--color-border-100), 0.8)" vertical={false} />
<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,
}}
tickLine={false}
axisLine={false}
label={
xAxis.label && {
value: xAxis.label,
dy: 28,
className: AXIS_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,
}}
tickLine={false}
axisLine={false}
label={
yAxis.label && {
value: yAxis.label,
angle: -90,
position: "bottom",
offset: -24,
dx: -16,
className: AXIS_LABEL_CLASSNAME,
}
}
tick={(props) => <CustomYAxisTick {...props} />}
tickCount={tickCount.y}
allowDecimals={!!yAxis.allowDecimals}
/>
{legend && (
// @ts-expect-error recharts types are not up to date
<Legend
formatter={(value) => itemLabels[value]}
onMouseEnter={(payload) => setActiveLegend(payload.value)}
onMouseLeave={() => setActiveLegend(null)}
{...getLegendProps(legend)}
/>
)}
{showTooltip && (
<Tooltip
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
cursor={{
stroke: "rgba(var(--color-text-300))",
strokeDasharray: "4 4",
}}
wrapperStyle={{
pointerEvents: "auto",
}}
content={({ active, label, payload }) => (
<CustomTooltip
active={active}
activeKey={activeArea}
label={label}
payload={payload}
itemKeys={itemKeys}
itemDotClassNames={itemDotClassNames}
itemLabels={itemLabels}
itemDotColors={itemDotColors}
/>
)}
/>
)}
{renderAreas}
</CoreAreaChart>
{comparisonLine && (
<Line
data={comparisonLineData}
type="linear"
dataKey="comparisonLine"
stroke={comparisonLine.strokeColor}
fill={comparisonLine.strokeColor}
strokeWidth={2}
strokeDasharray={comparisonLine.dashedLine ? "4 4" : "none"}
activeDot={false}
legendType="none"
name="Comparison line"
/>
)}
</ComposedChart>
</ResponsiveContainer>
</div>
);

View File

@@ -15,10 +15,25 @@ const calculatePercentage = <K extends string, T extends string>(
};
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
const BAR_TOP_BORDER_RADIUS = 4; // Border radius for each bar
const BAR_BOTTOM_BORDER_RADIUS = 4; // Border radius for each bar
export const CustomBar = React.memo((props: any) => {
const { fill, x, y, width, height, dataKey, stackKeys, payload, textClassName, showPercentage } = props;
const {
opacity,
fill,
x,
y,
width,
height,
dataKey,
stackKeys,
payload,
textClassName,
showPercentage,
showTopBorderRadius,
showBottomBorderRadius,
} = 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
@@ -34,24 +49,28 @@ export const CustomBar = React.memo((props: any) => {
// bar percentage is a number
!Number.isNaN(currentBarPercentage);
const topBorderRadius = showTopBorderRadius ? BAR_TOP_BORDER_RADIUS : 0;
const bottomBorderRadius = showBottomBorderRadius ? BAR_BOTTOM_BORDER_RADIUS : 0;
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"
M${x},${y + topBorderRadius}
Q${x},${y} ${x + topBorderRadius},${y}
L${x + width - topBorderRadius},${y}
Q${x + width},${y} ${x + width},${y + topBorderRadius}
L${x + width},${y + height - bottomBorderRadius}
Q${x + width},${y + height} ${x + width - bottomBorderRadius},${y + height}
L${x + bottomBorderRadius},${y + height}
Q${x},${y + height} ${x},${y + height - bottomBorderRadius}
Z
`}
className="transition-opacity duration-200"
fill={fill}
opacity={opacity}
/>
{showText && (
<text

View File

@@ -1,14 +1,24 @@
/* 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";
import React, { useMemo, useState } from "react";
import {
BarChart as CoreBarChart,
Bar,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
Legend,
CartesianGrid,
} from "recharts";
// plane imports
import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants";
import { AXIS_LABEL_CLASSNAME } from "@plane/constants";
import { TBarChartProps } from "@plane/types";
// local components
import { CustomXAxisTick, CustomYAxisTick } from "../tick";
import { CustomTooltip } from "../tooltip";
import { getLegendProps } from "../components/legend";
import { CustomXAxisTick, CustomYAxisTick } from "../components/tick";
import { CustomTooltip } from "../components/tooltip";
import { CustomBar } from "./bar";
export const BarChart = React.memo(<K extends string, T extends string>(props: TBarChartProps<K, T>) => {
@@ -18,19 +28,25 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
xAxis,
yAxis,
barSize = 40,
className = "w-full h-96",
className,
legend,
margin,
tickCount = {
x: undefined,
y: 10,
},
showTooltip = true,
} = props;
// states
const [activeBar, setActiveBar] = useState<string | null>(null);
const [activeLegend, setActiveLegend] = useState<string | null>(null);
// derived values
const stackKeys = useMemo(() => bars.map((bar) => bar.key), [bars]);
const stackDotClassNames = useMemo(
() => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.dotClassName }), {}),
const stackLabels: Record<string, string> = useMemo(
() => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.label }), {}),
[bars]
);
const stackDotColors = useMemo(() => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.fill }), {}), [bars]);
const renderBars = useMemo(
() =>
@@ -39,18 +55,29 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
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}
/>
)}
opacity={!!activeLegend && activeLegend !== bar.key ? 0.1 : 1}
fill={bar.fill}
shape={(shapeProps: any) => {
const showTopBorderRadius = bar.showTopBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
const showBottomBorderRadius = bar.showBottomBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
return (
<CustomBar
{...shapeProps}
stackKeys={stackKeys}
textClassName={bar.textClassName}
showPercentage={bar.showPercentage}
showTopBorderRadius={!!showTopBorderRadius}
showBottomBorderRadius={!!showBottomBorderRadius}
/>
);
}}
className="[&_path]:transition-opacity [&_path]:duration-200"
onMouseEnter={() => setActiveBar(bar.key)}
onMouseLeave={() => setActiveBar(null)}
/>
)),
[stackKeys, bars]
[activeLegend, stackKeys, bars]
);
return (
@@ -58,60 +85,71 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
<ResponsiveContainer width="100%" height="100%">
<CoreBarChart
data={data}
margin={{ top: 10, right: 10, left: 10, bottom: 40 }}
margin={{
top: margin?.top === undefined ? 5 : margin.top,
right: margin?.right === undefined ? 30 : margin.right,
bottom: margin?.bottom === undefined ? 5 : margin.bottom,
left: margin?.left === undefined ? 20 : margin.left,
}}
barSize={barSize}
className="recharts-wrapper"
>
<CartesianGrid stroke="rgba(var(--color-border-100), 0.8)" vertical={false} />
<XAxis
dataKey={xAxis.key}
tick={(props) => <CustomXAxisTick {...props} />}
tickLine={{
stroke: "currentColor",
className: AXIS_LINE_CLASSNAME,
}}
axisLine={{
stroke: "currentColor",
className: AXIS_LINE_CLASSNAME,
}}
tickLine={false}
axisLine={false}
label={{
value: xAxis.label,
dy: 28,
className: LABEL_CLASSNAME,
className: AXIS_LABEL_CLASSNAME,
}}
tickCount={tickCount.x}
/>
<YAxis
domain={yAxis.domain}
tickLine={{
stroke: "currentColor",
className: AXIS_LINE_CLASSNAME,
}}
axisLine={{
stroke: "currentColor",
className: AXIS_LINE_CLASSNAME,
}}
tickLine={false}
axisLine={false}
label={{
value: yAxis.label,
angle: -90,
position: "bottom",
offset: -24,
dx: -16,
className: LABEL_CLASSNAME,
className: AXIS_LABEL_CLASSNAME,
}}
tick={(props) => <CustomYAxisTick {...props} />}
tickCount={tickCount.y}
allowDecimals={!!yAxis.allowDecimals}
/>
{legend && (
// @ts-expect-error recharts types are not up to date
<Legend
onMouseEnter={(payload) => setActiveLegend(payload.value)}
onMouseLeave={() => setActiveLegend(null)}
formatter={(value) => stackLabels[value]}
{...getLegendProps(legend)}
/>
)}
{showTooltip && (
<Tooltip
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
cursor={{
fill: "currentColor",
className: "text-custom-background-90/80 cursor-pointer",
}}
wrapperStyle={{
pointerEvents: "auto",
}}
content={({ active, label, payload }) => (
<CustomTooltip
active={active}
label={label}
payload={payload}
activeKey={activeBar}
itemKeys={stackKeys}
itemDotClassNames={stackDotClassNames}
itemLabels={stackLabels}
itemDotColors={stackDotColors}
/>
)}
/>

View File

@@ -0,0 +1,77 @@
import React from "react";
import { LegendProps } from "recharts";
// plane imports
import { TChartLegend } from "@plane/types";
import { cn } from "@plane/utils";
export const getLegendProps = (args: TChartLegend): LegendProps => {
const { align, layout, verticalAlign } = args;
return {
layout,
align,
verticalAlign,
wrapperStyle: {
display: "flex",
overflow: "hidden",
...(layout === "vertical"
? {
top: 0,
alignItems: "center",
height: "100%",
}
: {
left: 0,
bottom: 0,
width: "100%",
justifyContent: "center",
}),
},
content: <CustomLegend {...args} />,
};
};
const CustomLegend = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<LegendProps, "payload" | "formatter" | "onClick" | "onMouseEnter" | "onMouseLeave"> &
TChartLegend
>((props, ref) => {
const { formatter, layout, onClick, onMouseEnter, onMouseLeave, payload } = props;
if (!payload?.length) return null;
return (
<div
ref={ref}
className={cn("flex items-center px-4 overflow-scroll vertical-scrollbar scrollbar-sm", {
"max-h-full flex-col items-start py-4": layout === "vertical",
})}
>
{payload.map((item, index) => (
<div
key={item.value}
className={cn("flex items-center gap-1.5 text-custom-text-300 text-sm font-medium whitespace-nowrap", {
"px-2": layout === "horizontal",
"py-2": layout === "vertical",
"pl-0 pt-0": index === 0,
"pr-0 pb-0": index === payload.length - 1,
"cursor-pointer": !!props.onClick,
})}
onClick={(e) => onClick?.(item, index, e)}
onMouseEnter={(e) => onMouseEnter?.(item, index, e)}
onMouseLeave={(e) => onMouseLeave?.(item, index, e)}
>
<div
className="flex-shrink-0 size-2 rounded-sm"
style={{
backgroundColor: item.color,
}}
/>
{/* @ts-expect-error recharts types are not up to date */}
{formatter?.(item.value, { value: item.value }, index) ?? item.payload?.name}
</div>
))}
</div>
);
});
CustomLegend.displayName = "CustomLegend";

View File

@@ -2,7 +2,7 @@
import React from "react";
// Common classnames
const AXIS_TICK_CLASSNAME = "fill-custom-text-400 text-sm capitalize";
const AXIS_TICK_CLASSNAME = "fill-custom-text-300 text-sm";
export const CustomXAxisTick = React.memo<any>(({ x, y, payload }: any) => (
<g transform={`translate(${x},${y})`}>

View File

@@ -0,0 +1,60 @@
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;
activeKey?: string | null;
label: string | undefined;
payload: Payload<ValueType, NameType>[] | undefined;
itemKeys: string[];
itemLabels: Record<string, string>;
itemDotColors: Record<string, string>;
};
export const CustomTooltip = React.memo((props: Props) => {
const { active, activeKey, label, payload, itemKeys, itemLabels, itemDotColors } = 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 max-h-[40vh] w-[12rem] overflow-y-scroll vertical-scrollbar scrollbar-sm"
spacing={ECardSpacing.SM}
>
<p className="flex-shrink-0 text-xs text-custom-text-100 font-medium border-b border-custom-border-200 pb-2 truncate">
{label}
</p>
{filteredPayload.map((item) => {
if (!item.dataKey) return null;
return (
<div
key={item?.dataKey}
className={cn("flex items-center gap-2 text-xs transition-opacity", {
"opacity-20": activeKey && item.dataKey !== activeKey,
})}
>
<div className="flex items-center gap-2 truncate">
{itemDotColors[item?.dataKey] && (
<div
className="flex-shrink-0 size-2 rounded-sm"
style={{
backgroundColor: itemDotColors[item?.dataKey],
}}
/>
)}
<span className="text-custom-text-300 truncate">{itemLabels[item?.dataKey]}:</span>
</div>
<span className="flex-shrink-0 font-medium text-custom-text-200">{item?.value}</span>
</div>
);
})}
</Card>
);
});
CustomTooltip.displayName = "CustomTooltip";

View File

@@ -1,107 +1,154 @@
/* 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";
import React, { useMemo, useState } from "react";
import {
CartesianGrid,
LineChart as CoreLineChart,
Legend,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
// plane imports
import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants";
import { AXIS_LABEL_CLASSNAME } from "@plane/constants";
import { TLineChartProps } from "@plane/types";
// local components
import { CustomXAxisTick, CustomYAxisTick } from "../tick";
import { CustomTooltip } from "../tooltip";
import { getLegendProps } from "../components/legend";
import { CustomXAxisTick, CustomYAxisTick } from "../components/tick";
import { CustomTooltip } from "../components/tooltip";
export const LineChart = React.memo(<K extends string, T extends string>(props: TLineChartProps<K, T>) => {
const {
data,
lines,
margin,
xAxis,
yAxis,
className = "w-full h-96",
className,
tickCount = {
x: undefined,
y: 10,
},
legend,
showTooltip = true,
} = props;
// states
const [activeLine, setActiveLine] = useState<string | null>(null);
const [activeLegend, setActiveLegend] = useState<string | null>(null);
// derived values
const itemKeys = useMemo(() => lines.map((line) => line.key), [lines]);
const itemDotClassNames = useMemo(
() => lines.reduce((acc, line) => ({ ...acc, [line.key]: line.dotClassName }), {}),
const itemLabels: Record<string, string> = useMemo(
() => lines.reduce((acc, line) => ({ ...acc, [line.key]: line.label }), {}),
[lines]
);
const itemDotColors = useMemo(() => lines.reduce((acc, line) => ({ ...acc, [line.key]: line.stroke }), {}), [lines]);
const renderLines = useMemo(
() =>
lines.map((line) => (
<Line key={line.key} dataKey={line.key} type="monotone" className={line.className} stroke="inherit" />
<Line
key={line.key}
dataKey={line.key}
type={line.smoothCurves ? "monotone" : "linear"}
className="[&_path]:transition-opacity [&_path]:duration-200"
opacity={!!activeLegend && activeLegend !== line.key ? 0.1 : 1}
fill={line.fill}
stroke={line.stroke}
strokeWidth={2}
strokeDasharray={line.dashedLine ? "4 4" : "none"}
dot={
line.showDot
? {
fill: line.fill,
fillOpacity: 1,
}
: false
}
activeDot={{
stroke: line.fill,
}}
onMouseEnter={() => setActiveLine(line.key)}
onMouseLeave={() => setActiveLine(null)}
/>
)),
[lines]
[activeLegend, 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,
top: margin?.top === undefined ? 5 : margin.top,
right: margin?.right === undefined ? 30 : margin.right,
bottom: margin?.bottom === undefined ? 5 : margin.bottom,
left: margin?.left === undefined ? 20 : margin.left,
}}
>
<CartesianGrid stroke="rgba(var(--color-border-100), 0.8)" vertical={false} />
<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,
}}
tickLine={false}
axisLine={false}
label={
xAxis.label && {
value: xAxis.label,
dy: 28,
className: AXIS_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,
}}
tickLine={false}
axisLine={false}
label={
yAxis.label && {
value: yAxis.label,
angle: -90,
position: "bottom",
offset: -24,
dx: -16,
className: AXIS_LABEL_CLASSNAME,
}
}
tick={(props) => <CustomYAxisTick {...props} />}
tickCount={tickCount.y}
allowDecimals={!!yAxis.allowDecimals}
/>
{legend && (
// @ts-expect-error recharts types are not up to date
<Legend
onMouseEnter={(payload) => setActiveLegend(payload.value)}
onMouseLeave={() => setActiveLegend(null)}
formatter={(value) => itemLabels[value]}
{...getLegendProps(legend)}
/>
)}
{showTooltip && (
<Tooltip
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
cursor={{
stroke: "rgba(var(--color-text-300))",
strokeDasharray: "4 4",
}}
wrapperStyle={{
pointerEvents: "auto",
}}
content={({ active, label, payload }) => (
<CustomTooltip
active={active}
activeKey={activeLine}
label={label}
payload={payload}
itemKeys={itemKeys}
itemDotClassNames={itemDotClassNames}
itemLabels={itemLabels}
itemDotColors={itemDotColors}
/>
)}
/>

View File

@@ -0,0 +1,31 @@
import React from "react";
import { Sector } from "recharts";
export const CustomActiveShape = React.memo((props: any) => {
const { cx, cy, cornerRadius, innerRadius, outerRadius, startAngle, endAngle, fill } = props;
return (
<g>
<Sector
cx={cx}
cy={cy}
innerRadius={innerRadius}
outerRadius={outerRadius}
cornerRadius={cornerRadius}
startAngle={startAngle}
endAngle={endAngle}
fill={fill}
/>
<Sector
cx={cx}
cy={cy}
startAngle={startAngle}
endAngle={endAngle}
cornerRadius={cornerRadius}
innerRadius={outerRadius + 6}
outerRadius={outerRadius + 10}
fill={fill}
/>
</g>
);
});

View File

@@ -1,45 +1,145 @@
"use client";
import React, { useMemo } from "react";
import { Cell, PieChart as CorePieChart, Pie, ResponsiveContainer, Tooltip } from "recharts";
import React, { useMemo, useState } from "react";
import { Cell, PieChart as CorePieChart, Label, Legend, Pie, ResponsiveContainer, Tooltip } from "recharts";
// plane imports
import { TPieChartProps } from "@plane/types";
// local components
import { getLegendProps } from "../components/legend";
import { CustomActiveShape } from "./active-shape";
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 {
data,
dataKey,
cells,
className,
innerRadius,
legend,
margin,
outerRadius,
showTooltip = true,
showLabel,
customLabel,
centerLabel,
cornerRadius,
paddingAngle,
tooltipLabel,
} = props;
// states
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [activeLegend, setActiveLegend] = useState<string | null>(null);
const renderCells = useMemo(
() => cells.map((cell) => <Cell key={cell.key} className={cell.className} style={cell.style} />),
[cells]
() =>
cells.map((cell, index) => (
<Cell
key={cell.key}
className="transition-opacity duration-200"
fill={cell.fill}
opacity={!!activeLegend && activeLegend !== cell.key ? 0.1 : 1}
style={{
outline: "none",
}}
onMouseEnter={() => setActiveIndex(index)}
onMouseLeave={() => setActiveIndex(null)}
/>
)),
[activeLegend, 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,
top: margin?.top === undefined ? 5 : margin.top,
right: margin?.right === undefined ? 30 : margin.right,
bottom: margin?.bottom === undefined ? 5 : margin.bottom,
left: margin?.left === undefined ? 20 : margin.left,
}}
>
<Pie data={data} dataKey={dataKey} cx="50%" cy="50%" innerRadius={innerRadius} outerRadius={outerRadius}>
<Pie
activeIndex={activeIndex === null ? undefined : activeIndex}
onMouseLeave={() => setActiveIndex(null)}
data={data}
dataKey={dataKey}
cx="50%"
cy="50%"
blendStroke
activeShape={<CustomActiveShape />}
innerRadius={innerRadius}
outerRadius={outerRadius}
cornerRadius={cornerRadius}
paddingAngle={paddingAngle}
labelLine={false}
label={
showLabel
? ({ payload, ...props }) => (
<text
className="text-sm font-medium transition-opacity duration-200"
cx={props.cx}
cy={props.cy}
x={props.x}
y={props.y}
textAnchor={props.textAnchor}
dominantBaseline={props.dominantBaseline}
fill="rgba(var(--color-text-200))"
opacity={!!activeLegend && activeLegend !== payload.key ? 0.1 : 1}
>
{customLabel?.(payload.count) ?? payload.count}
</text>
)
: undefined
}
>
{renderCells}
{centerLabel && (
<Label
value={centerLabel.text}
fill={centerLabel.fill}
position="center"
opacity={activeLegend ? 0.1 : 1}
style={centerLabel.style}
className={centerLabel.className}
/>
)}
</Pie>
{legend && (
// @ts-expect-error recharts types are not up to date
<Legend
onMouseEnter={(payload) => {
// @ts-expect-error recharts types are not up to date
const key: string | undefined = payload.payload?.key;
if (!key) return;
setActiveLegend(key);
setActiveIndex(null);
}}
onMouseLeave={() => setActiveLegend(null)}
{...getLegendProps(legend)}
/>
)}
{showTooltip && (
<Tooltip
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
cursor={{
fill: "currentColor",
className: "text-custom-background-90/80 cursor-pointer",
}}
wrapperStyle={{
pointerEvents: "auto",
}}
content={({ active, payload }) => {
if (!active || !payload || !payload.length) return null;
const cellData = cells.find((c) => c.key === payload[0].name);
const cellData = cells.find((c) => c.key === payload[0].payload.key);
if (!cellData) return null;
return <CustomPieChartTooltip dotClassName={cellData.dotClassName} label={dataKey} payload={payload} />;
const label = tooltipLabel
? typeof tooltipLabel === "function"
? tooltipLabel(payload[0]?.payload?.payload)
: tooltipLabel
: dataKey;
return <CustomPieChartTooltip dotColor={cellData.fill} label={label} payload={payload} />;
}}
/>
)}

View File

@@ -2,27 +2,36 @@ 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;
dotColor?: string;
label: string;
payload: Payload<ValueType, NameType>[];
};
export const CustomPieChartTooltip = React.memo((props: Props) => {
const { dotClassName, label, payload } = props;
const { dotColor, 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">
<Card
className="flex flex-col max-h-[40vh] w-[12rem] overflow-y-scroll vertical-scrollbar scrollbar-sm"
spacing={ECardSpacing.SM}
>
<p className="flex-shrink-0 text-xs text-custom-text-100 font-medium border-b border-custom-border-200 pb-2 truncate">
{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 className="flex items-center gap-2 truncate">
<div
className="flex-shrink-0 size-2 rounded-sm"
style={{
backgroundColor: dotColor,
}}
/>
<span className="text-custom-text-300 truncate">{item?.name}:</span>
</div>
<span className="flex-shrink-0 font-medium text-custom-text-200">{item?.value}</span>
</div>
))}
</Card>

View File

@@ -1,41 +0,0 @@
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";

View File

@@ -31,6 +31,9 @@ export const TreeMapChart = React.memo((props: TreeMapChartProps) => {
fill: "currentColor",
className: "text-custom-background-90/80 cursor-pointer",
}}
wrapperStyle={{
pointerEvents: "auto",
}}
/>
)}
</Treemap>

View File

@@ -1,3 +1,16 @@
export type TChartLegend = {
align: "left" | "center" | "right";
verticalAlign: "top" | "middle" | "bottom";
layout: "horizontal" | "vertical";
};
export type TChartMargin = {
top?: number;
right?: number;
bottom?: number;
left?: number;
};
export type TChartData<K extends string, T extends string> = {
// required key
[key in K]: string | number;
@@ -7,15 +20,19 @@ type TChartProps<K extends string, T extends string> = {
data: TChartData<K, T>[];
xAxis: {
key: keyof TChartData<K, T>;
label: string;
label?: string;
strokeColor?: string;
};
yAxis: {
key: keyof TChartData<K, T>;
label: string;
domain?: [number, number];
allowDecimals?: boolean;
domain?: [number, number];
key: keyof TChartData<K, T>;
label?: string;
strokeColor?: string;
};
className?: string;
legend?: TChartLegend;
margin?: TChartMargin;
tickCount?: {
x?: number;
y?: number;
@@ -25,11 +42,13 @@ type TChartProps<K extends string, T extends string> = {
export type TBarItem<T extends string> = {
key: T;
fillClassName: string;
label: string;
fill: string;
textClassName: string;
dotClassName?: string;
showPercentage?: boolean;
stackId: string;
showTopBorderRadius?: (barKey: string, payload: any) => boolean;
showBottomBorderRadius?: (barKey: string, payload: any) => boolean;
};
export type TBarChartProps<K extends string, T extends string> = TChartProps<K, T> & {
@@ -39,9 +58,13 @@ export type TBarChartProps<K extends string, T extends string> = TChartProps<K,
export type TLineItem<T extends string> = {
key: T;
className?: string;
label: string;
dashedLine: boolean;
fill: string;
showDot: boolean;
smoothCurves: boolean;
stroke: string;
style?: Record<string, string | number>;
dotClassName?: string;
};
export type TLineChartProps<K extends string, T extends string> = TChartProps<K, T> & {
@@ -50,31 +73,49 @@ export type TLineChartProps<K extends string, T extends string> = TChartProps<K,
export type TAreaItem<T extends string> = {
key: T;
label: string;
stackId: string;
className?: string;
fill: string;
fillOpacity: number;
showDot: boolean;
smoothCurves: boolean;
strokeColor: string;
strokeOpacity: number;
style?: Record<string, string | number>;
dotClassName?: string;
};
export type TAreaChartProps<K extends string, T extends string> = TChartProps<K, T> & {
areas: TAreaItem<T>[];
comparisonLine?: {
dashedLine: boolean;
strokeColor: string;
};
};
export type TCellItem<T extends string> = {
key: T;
className?: string;
style?: Record<string, string | number>;
dotClassName?: string;
fill: string;
};
export type TPieChartProps<K extends string, T extends string> = Pick<
TChartProps<K, T>,
"className" | "data" | "showTooltip"
"className" | "data" | "showTooltip" | "legend" | "margin"
> & {
dataKey: T;
cells: TCellItem<T>[];
innerRadius?: number;
outerRadius?: number;
innerRadius?: number | string;
outerRadius?: number | string;
cornerRadius?: number;
paddingAngle?: number;
showLabel: boolean;
customLabel?: (value: any) => string;
centerLabel?: {
className?: string;
fill: string;
style?: React.CSSProperties;
text?: string | number;
};
tooltipLabel?: string | ((payload: any) => string);
};
export type TreeMapItem = {