Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 138 additions & 37 deletions packages/propel/src/charts/bar-chart/bar.tsx
Original file line number Diff line number Diff line change
@@ -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[],
Expand All @@ -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
`;

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>
);

export const CustomBar = React.memo((props: any) => {
// Base Components
const CustomBar = React.memo((props: TBarProps) => {
const {
opacity,
fill,
Expand All @@ -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";
44 changes: 18 additions & 26 deletions packages/propel/src/charts/bar-chart/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
Expand Down Expand Up @@ -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)}
Expand Down Expand Up @@ -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}
Expand Down
3 changes: 3 additions & 0 deletions packages/types/src/charts/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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> & {
Expand Down