[WEB-4371] feat: bar chart component with lollipop shape variant (#7268)

* feat: enhance bar chart component with shape variants and custom tooltip

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

removed the unknown props

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

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

removed console log

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* refactor: replace inline percentage text with PercentageText component in bar chart

* Added new variant - lollipop-dotted

* added some comments

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
JayashTripathy
2025-06-25 23:43:10 +05:30
committed by GitHub
parent b8043f92b1
commit b5538565c7
3 changed files with 159 additions and 63 deletions

View File

@@ -1,10 +1,38 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from "react";
// plane imports
import { TChartData } from "@plane/types";
import { TBarChartShapeVariant, TBarItem, TChartData } from "@plane/types";
import { cn } from "@plane/utils";
// Helper to calculate percentage
// Constants
const MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT = 14; // Minimum height required to show text inside bar
const BAR_TOP_BORDER_RADIUS = 4; // Border radius for the top of bars
const BAR_BOTTOM_BORDER_RADIUS = 4; // Border radius for the bottom of bars
const DEFAULT_LOLLIPOP_LINE_WIDTH = 2; // Width of lollipop stick
const DEFAULT_LOLLIPOP_CIRCLE_RADIUS = 8; // Radius of lollipop circle
// Types
interface TShapeProps {
x: number;
y: number;
width: number;
height: number;
dataKey: string;
payload: any;
opacity?: number;
}
interface TBarProps extends TShapeProps {
fill: string | ((payload: any) => string);
stackKeys: string[];
textClassName?: string;
showPercentage?: boolean;
showTopBorderRadius?: boolean;
showBottomBorderRadius?: boolean;
dotted?: boolean;
}
// Helper Functions
const calculatePercentage = <K extends string, T extends string>(
data: TChartData<K, T>,
stackKeys: T[],
@@ -14,11 +42,36 @@ const calculatePercentage = <K extends string, T extends string>(
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_TOP_BORDER_RADIUS = 4; // Border radius for each bar
const BAR_BOTTOM_BORDER_RADIUS = 4; // Border radius for each bar
const getBarPath = (x: number, y: number, width: number, height: number, topRadius: number, bottomRadius: number) => `
M${x},${y + topRadius}
Q${x},${y} ${x + topRadius},${y}
L${x + width - topRadius},${y}
Q${x + width},${y} ${x + width},${y + topRadius}
L${x + width},${y + height - bottomRadius}
Q${x + width},${y + height} ${x + width - bottomRadius},${y + height}
L${x + bottomRadius},${y + height}
Q${x},${y + height} ${x},${y + height - bottomRadius}
Z
`;
export const CustomBar = React.memo((props: any) => {
const PercentageText = ({
x,
y,
percentage,
className,
}: {
x: number;
y: number;
percentage: number;
className?: string;
}) => (
<text x={x} y={y} textAnchor="middle" className={cn("text-xs font-medium", className)} fill="currentColor">
{percentage}%
</text>
);
// Base Components
const CustomBar = React.memo((props: TBarProps) => {
const {
opacity,
fill,
@@ -34,56 +87,104 @@ export const CustomBar = React.memo((props: any) => {
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
// derived values
if (!height) return null;
const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey);
const TEXT_PADDING_Y = Math.min(6, Math.abs(MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT - height / 2));
const textY = y + height - TEXT_PADDING_Y;
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);
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},${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
`}
d={getBarPath(x, y, width, height, topBorderRadius, bottomBorderRadius)}
className="transition-opacity duration-200"
fill={fill}
fill={typeof fill === "function" ? fill(payload) : fill}
opacity={opacity}
/>
{showText && (
<text
x={x + width / 2}
y={textY}
textAnchor="middle"
className={cn("text-xs font-medium", textClassName)}
fill="currentColor"
>
{currentBarPercentage}%
</text>
<PercentageText x={x + width / 2} y={textY} percentage={currentBarPercentage} className={textClassName} />
)}
</g>
);
});
const CustomBarLollipop = React.memo((props: TBarProps) => {
const { fill, x, y, width, height, dataKey, stackKeys, payload, textClassName, showPercentage, dotted } = props;
const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey);
return (
<g>
<line
x1={x + width / 2}
y1={y + height}
x2={x + width / 2}
y2={y}
stroke={typeof fill === "function" ? fill(payload) : fill}
strokeWidth={DEFAULT_LOLLIPOP_LINE_WIDTH}
strokeLinecap="round"
strokeDasharray={dotted ? "4 4" : "0"}
/>
<circle
cx={x + width / 2}
cy={y}
r={DEFAULT_LOLLIPOP_CIRCLE_RADIUS}
fill={typeof fill === "function" ? fill(payload) : fill}
stroke="none"
/>
{showPercentage && (
<PercentageText x={x + width / 2} y={y} percentage={currentBarPercentage} className={textClassName} />
)}
</g>
);
});
// Shape Variants
/**
* Factory function to create shape variants with consistent props
* @param Component - The base component to render
* @param factoryProps - Additional props to pass to the component
* @returns A function that creates the shape with proper props
*/
const createShapeVariant =
(Component: React.ComponentType<TBarProps>, factoryProps?: Partial<TBarProps>) =>
(shapeProps: TShapeProps, bar: TBarItem<string>, stackKeys: string[]): JSX.Element => {
const showTopBorderRadius = bar.showTopBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
const showBottomBorderRadius = bar.showBottomBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
return (
<Component
{...shapeProps}
fill={typeof bar.fill === "function" ? bar.fill(shapeProps.payload) : bar.fill}
stackKeys={stackKeys}
textClassName={bar.textClassName}
showPercentage={bar.showPercentage}
showTopBorderRadius={!!showTopBorderRadius}
showBottomBorderRadius={!!showBottomBorderRadius}
{...factoryProps}
/>
);
};
export const barShapeVariants: Record<
TBarChartShapeVariant,
(props: TShapeProps, bar: TBarItem<string>, stackKeys: string[]) => JSX.Element
> = {
bar: createShapeVariant(CustomBar), // Standard bar with rounded corners
lollipop: createShapeVariant(CustomBarLollipop), // Line with circle at top
"lollipop-dotted": createShapeVariant(CustomBarLollipop, { dotted: true }), // Dotted line lollipop variant
};
// Display names
CustomBar.displayName = "CustomBar";
CustomBarLollipop.displayName = "CustomBarLollipop";

View File

@@ -19,7 +19,7 @@ import { TBarChartProps } from "@plane/types";
import { getLegendProps } from "../components/legend";
import { CustomXAxisTick, CustomYAxisTick } from "../components/tick";
import { CustomTooltip } from "../components/tooltip";
import { CustomBar } from "./bar";
import { barShapeVariants } from "./bar";
export const BarChart = React.memo(<K extends string, T extends string>(props: TBarChartProps<K, T>) => {
const {
@@ -36,6 +36,7 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
y: 10,
},
showTooltip = true,
customTooltipContent,
} = props;
// states
const [activeBar, setActiveBar] = useState<string | null>(null);
@@ -66,20 +67,8 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
stackId={bar.stackId}
opacity={!!activeLegend && activeLegend !== bar.key ? 0.1 : 1}
shape={(shapeProps: any) => {
const showTopBorderRadius = bar.showTopBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
const showBottomBorderRadius = bar.showBottomBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
return (
<CustomBar
{...shapeProps}
fill={typeof bar.fill === "function" ? bar.fill(shapeProps.payload) : bar.fill}
stackKeys={stackKeys}
textClassName={bar.textClassName}
showPercentage={bar.showPercentage}
showTopBorderRadius={!!showTopBorderRadius}
showBottomBorderRadius={!!showBottomBorderRadius}
/>
);
const shapeVariant = barShapeVariants[bar.shapeVariant ?? "bar"];
return shapeVariant(shapeProps, bar, stackKeys);
}}
className="[&_path]:transition-opacity [&_path]:duration-200"
onMouseEnter={() => setActiveBar(bar.key)}
@@ -150,17 +139,20 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
wrapperStyle={{
pointerEvents: "auto",
}}
content={({ active, label, payload }) => (
<CustomTooltip
active={active}
label={label}
payload={payload}
activeKey={activeBar}
itemKeys={stackKeys}
itemLabels={stackLabels}
itemDotColors={stackDotColors}
/>
)}
content={({ active, label, payload }) => {
if (customTooltipContent) return customTooltipContent({ active, label, payload });
return (
<CustomTooltip
active={active}
label={label}
payload={payload}
activeKey={activeBar}
itemKeys={stackKeys}
itemLabels={stackLabels}
itemDotColors={stackDotColors}
/>
);
}}
/>
)}
{renderBars}

View File

@@ -53,6 +53,8 @@ type TChartProps<K extends string, T extends string> = {
// Bar Chart
// ============================================================
export type TBarChartShapeVariant = "bar" | "lollipop" | "lollipop-dotted";
export type TBarItem<T extends string> = {
key: T;
label: string;
@@ -62,6 +64,7 @@ export type TBarItem<T extends string> = {
stackId: string;
showTopBorderRadius?: (barKey: string, payload: any) => boolean;
showBottomBorderRadius?: (barKey: string, payload: any) => boolean;
shapeVariant?: TBarChartShapeVariant;
};
export type TBarChartProps<K extends string, T extends string> = TChartProps<K, T> & {