mirror of
https://github.com/makeplane/plane.git
synced 2026-02-07 06:29:54 -06:00
[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:
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
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
|
||||
// ============================================================
|
||||
|
||||
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> & {
|
||||
|
||||
Reference in New Issue
Block a user