[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:
parent
b8043f92b1
commit
b5538565c7
3 changed files with 159 additions and 63 deletions
|
|
@ -1,10 +1,38 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import React from "react";
|
import React from "react";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { TChartData } from "@plane/types";
|
import { TBarChartShapeVariant, TBarItem, TChartData } from "@plane/types";
|
||||||
import { cn } from "@plane/utils";
|
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>(
|
const calculatePercentage = <K extends string, T extends string>(
|
||||||
data: TChartData<K, T>,
|
data: TChartData<K, T>,
|
||||||
stackKeys: 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);
|
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 getBarPath = (x: number, y: number, width: number, height: number, topRadius: number, bottomRadius: number) => `
|
||||||
const BAR_TOP_BORDER_RADIUS = 4; // Border radius for each bar
|
M${x},${y + topRadius}
|
||||||
const BAR_BOTTOM_BORDER_RADIUS = 4; // Border radius for each bar
|
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 {
|
const {
|
||||||
opacity,
|
opacity,
|
||||||
fill,
|
fill,
|
||||||
|
|
@ -34,56 +87,104 @@ export const CustomBar = React.memo((props: any) => {
|
||||||
showTopBorderRadius,
|
showTopBorderRadius,
|
||||||
showBottomBorderRadius,
|
showBottomBorderRadius,
|
||||||
} = props;
|
} = props;
|
||||||
// Calculate text position
|
|
||||||
const TEXT_PADDING_Y = Math.min(6, Math.abs(MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT - height / 2));
|
if (!height) return null;
|
||||||
const textY = y + height - TEXT_PADDING_Y; // Position inside bar if tall enough
|
|
||||||
// derived values
|
|
||||||
const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey);
|
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 =
|
const showText =
|
||||||
// from props
|
|
||||||
showPercentage &&
|
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 &&
|
height >= MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT &&
|
||||||
// bar percentage text has some value
|
|
||||||
currentBarPercentage !== undefined &&
|
currentBarPercentage !== undefined &&
|
||||||
// bar percentage is a number
|
|
||||||
!Number.isNaN(currentBarPercentage);
|
!Number.isNaN(currentBarPercentage);
|
||||||
|
|
||||||
const topBorderRadius = showTopBorderRadius ? BAR_TOP_BORDER_RADIUS : 0;
|
const topBorderRadius = showTopBorderRadius ? BAR_TOP_BORDER_RADIUS : 0;
|
||||||
const bottomBorderRadius = showBottomBorderRadius ? BAR_BOTTOM_BORDER_RADIUS : 0;
|
const bottomBorderRadius = showBottomBorderRadius ? BAR_BOTTOM_BORDER_RADIUS : 0;
|
||||||
|
|
||||||
if (!height) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g>
|
<g>
|
||||||
<path
|
<path
|
||||||
d={`
|
d={getBarPath(x, y, width, height, topBorderRadius, bottomBorderRadius)}
|
||||||
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"
|
className="transition-opacity duration-200"
|
||||||
fill={fill}
|
fill={typeof fill === "function" ? fill(payload) : fill}
|
||||||
opacity={opacity}
|
opacity={opacity}
|
||||||
/>
|
/>
|
||||||
{showText && (
|
{showText && (
|
||||||
<text
|
<PercentageText x={x + width / 2} y={textY} percentage={currentBarPercentage} className={textClassName} />
|
||||||
x={x + width / 2}
|
|
||||||
y={textY}
|
|
||||||
textAnchor="middle"
|
|
||||||
className={cn("text-xs font-medium", textClassName)}
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
{currentBarPercentage}%
|
|
||||||
</text>
|
|
||||||
)}
|
)}
|
||||||
</g>
|
</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";
|
CustomBar.displayName = "CustomBar";
|
||||||
|
CustomBarLollipop.displayName = "CustomBarLollipop";
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import { TBarChartProps } from "@plane/types";
|
||||||
import { getLegendProps } from "../components/legend";
|
import { getLegendProps } from "../components/legend";
|
||||||
import { CustomXAxisTick, CustomYAxisTick } from "../components/tick";
|
import { CustomXAxisTick, CustomYAxisTick } from "../components/tick";
|
||||||
import { CustomTooltip } from "../components/tooltip";
|
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>) => {
|
export const BarChart = React.memo(<K extends string, T extends string>(props: TBarChartProps<K, T>) => {
|
||||||
const {
|
const {
|
||||||
|
|
@ -36,6 +36,7 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
|
||||||
y: 10,
|
y: 10,
|
||||||
},
|
},
|
||||||
showTooltip = true,
|
showTooltip = true,
|
||||||
|
customTooltipContent,
|
||||||
} = props;
|
} = props;
|
||||||
// states
|
// states
|
||||||
const [activeBar, setActiveBar] = useState<string | null>(null);
|
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}
|
stackId={bar.stackId}
|
||||||
opacity={!!activeLegend && activeLegend !== bar.key ? 0.1 : 1}
|
opacity={!!activeLegend && activeLegend !== bar.key ? 0.1 : 1}
|
||||||
shape={(shapeProps: any) => {
|
shape={(shapeProps: any) => {
|
||||||
const showTopBorderRadius = bar.showTopBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
|
const shapeVariant = barShapeVariants[bar.shapeVariant ?? "bar"];
|
||||||
const showBottomBorderRadius = bar.showBottomBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
|
return shapeVariant(shapeProps, bar, stackKeys);
|
||||||
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
className="[&_path]:transition-opacity [&_path]:duration-200"
|
className="[&_path]:transition-opacity [&_path]:duration-200"
|
||||||
onMouseEnter={() => setActiveBar(bar.key)}
|
onMouseEnter={() => setActiveBar(bar.key)}
|
||||||
|
|
@ -150,7 +139,9 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
|
||||||
wrapperStyle={{
|
wrapperStyle={{
|
||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
}}
|
}}
|
||||||
content={({ active, label, payload }) => (
|
content={({ active, label, payload }) => {
|
||||||
|
if (customTooltipContent) return customTooltipContent({ active, label, payload });
|
||||||
|
return (
|
||||||
<CustomTooltip
|
<CustomTooltip
|
||||||
active={active}
|
active={active}
|
||||||
label={label}
|
label={label}
|
||||||
|
|
@ -160,7 +151,8 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
|
||||||
itemLabels={stackLabels}
|
itemLabels={stackLabels}
|
||||||
itemDotColors={stackDotColors}
|
itemDotColors={stackDotColors}
|
||||||
/>
|
/>
|
||||||
)}
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{renderBars}
|
{renderBars}
|
||||||
|
|
|
||||||
3
packages/types/src/charts/index.d.ts
vendored
3
packages/types/src/charts/index.d.ts
vendored
|
|
@ -53,6 +53,8 @@ type TChartProps<K extends string, T extends string> = {
|
||||||
// Bar Chart
|
// Bar Chart
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
export type TBarChartShapeVariant = "bar" | "lollipop" | "lollipop-dotted";
|
||||||
|
|
||||||
export type TBarItem<T extends string> = {
|
export type TBarItem<T extends string> = {
|
||||||
key: T;
|
key: T;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -62,6 +64,7 @@ export type TBarItem<T extends string> = {
|
||||||
stackId: string;
|
stackId: string;
|
||||||
showTopBorderRadius?: (barKey: string, payload: any) => boolean;
|
showTopBorderRadius?: (barKey: string, payload: any) => boolean;
|
||||||
showBottomBorderRadius?: (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> & {
|
export type TBarChartProps<K extends string, T extends string> = TChartProps<K, T> & {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue