[WEB-3329] dev: new chart components (#6565)

* dev: new chart components

* chore: separate out pie chart tooltip

* chore: remove unused any types

* chore: move chart components to propel package
This commit is contained in:
Aaryan Khandelwal 2025-02-10 16:01:06 +05:30 committed by GitHub
parent 1eb1e82fe4
commit ce57c1423c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 679 additions and 409 deletions

View file

@ -1,63 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from "react";
// plane imports
import { TStackChartData } from "@plane/types";
import { cn } from "@plane/utils";
// Helper to calculate percentage
const calculatePercentage = <K extends string, T extends string>(
data: TStackChartData<K, T>,
stackKeys: T[],
currentKey: T
): number => {
const total = stackKeys.reduce((sum, key) => sum + data[key], 0);
return total === 0 ? 0 : Math.round((data[currentKey] / total) * 100);
};
export const CustomStackBar = React.memo<any>((props: any) => {
const { fill, x, y, width, height, dataKey, stackKeys, payload, textClassName, showPercentage } = props;
// Calculate text position
const MIN_BAR_HEIGHT_FOR_INTERNAL = 14; // Minimum height needed to show text inside
const TEXT_PADDING = Math.min(6, Math.abs(MIN_BAR_HEIGHT_FOR_INTERNAL - height / 2));
const textY = y + height - TEXT_PADDING; // Position inside bar if tall enough
// derived values
const RADIUS = 2;
const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey);
if (!height) return null;
return (
<g>
<path
d={`
M${x + RADIUS},${y + height}
L${x + RADIUS},${y}
Q${x},${y} ${x},${y + RADIUS}
L${x},${y + height - RADIUS}
Q${x},${y + height} ${x + RADIUS},${y + height}
L${x + width - RADIUS},${y + height}
Q${x + width},${y + height} ${x + width},${y + height - RADIUS}
L${x + width},${y + RADIUS}
Q${x + width},${y} ${x + width - RADIUS},${y}
L${x + RADIUS},${y}
`}
className={cn("transition-colors duration-200", fill)}
fill="currentColor"
/>
{showPercentage &&
height >= MIN_BAR_HEIGHT_FOR_INTERNAL &&
currentBarPercentage !== undefined &&
!Number.isNaN(currentBarPercentage) && (
<text
x={x + width / 2}
y={textY}
textAnchor="middle"
className={cn("text-xs font-medium", textClassName)}
fill="currentColor"
>
{currentBarPercentage}%
</text>
)}
</g>
);
});
CustomStackBar.displayName = "CustomStackBar";

View file

@ -1 +0,0 @@
export * from "./root";

View file

@ -1,130 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import React from "react";
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip } from "recharts";
// plane imports
import { TStackedBarChartProps } from "@plane/types";
import { cn } from "@plane/utils";
// local components
import { CustomStackBar } from "./bar";
import { CustomXAxisTick, CustomYAxisTick } from "./tick";
import { CustomTooltip } from "./tooltip";
// Common classnames
const LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide";
const AXIS_LINE_CLASSNAME = "text-custom-text-400/70";
export const StackedBarChart = React.memo(
<K extends string, T extends string>({
data,
stacks,
xAxis,
yAxis,
barSize = 40,
className = "w-full h-96",
tickCount = {
x: undefined,
y: 10,
},
showTooltip = true,
}: TStackedBarChartProps<K, T>) => {
// derived values
const stackKeys = React.useMemo(() => stacks.map((stack) => stack.key), [stacks]);
const stackDotClassNames = React.useMemo(
() => stacks.reduce((acc, stack) => ({ ...acc, [stack.key]: stack.dotClassName }), {}),
[stacks]
);
const renderBars = React.useMemo(
() =>
stacks.map((stack) => (
<Bar
key={stack.key}
dataKey={stack.key}
stackId="a"
fill={stack.fillClassName}
shape={(props: any) => (
<CustomStackBar
{...props}
stackKeys={stackKeys}
textClassName={stack.textClassName}
showPercentage={stack.showPercentage}
/>
)}
/>
)),
[stackKeys, stacks]
);
return (
<div className={cn(className)}>
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
margin={{ top: 10, right: 10, left: 10, bottom: 40 }}
barSize={barSize}
className="recharts-wrapper"
>
<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,
}}
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,
}}
tick={(props) => <CustomYAxisTick {...props} />}
tickCount={tickCount.y}
allowDecimals={yAxis.allowDecimals ?? false}
/>
{showTooltip && (
<Tooltip
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
content={({ active, label, payload }) => (
<CustomTooltip
active={active}
label={label}
payload={payload}
stackKeys={stackKeys}
stackDotClassNames={stackDotClassNames}
/>
)}
/>
)}
{renderBars}
</BarChart>
</ResponsiveContainer>
</div>
);
}
);
StackedBarChart.displayName = "StackedBarChart";

View file

@ -1,23 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from "react";
// Common classnames
const AXIS_TICK_CLASSNAME = "fill-custom-text-400 text-sm capitalize";
export const CustomXAxisTick = React.memo<any>(({ x, y, payload }: any) => (
<g transform={`translate(${x},${y})`}>
<text y={0} dy={16} textAnchor="middle" className={AXIS_TICK_CLASSNAME}>
{payload.value}
</text>
</g>
));
CustomXAxisTick.displayName = "CustomXAxisTick";
export const CustomYAxisTick = React.memo<any>(({ x, y, payload }: any) => (
<g transform={`translate(${x},${y})`}>
<text dx={-10} textAnchor="middle" className={AXIS_TICK_CLASSNAME}>
{payload.value}
</text>
</g>
));
CustomYAxisTick.displayName = "CustomYAxisTick";

View file

@ -1,39 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from "react";
// plane imports
import { Card, ECardSpacing } from "@plane/ui";
import { cn } from "@plane/utils";
type TStackedBarChartProps = {
active: boolean | undefined;
label: string | undefined;
payload: any[] | undefined;
stackKeys: string[];
stackDotClassNames: Record<string, string>;
};
export const CustomTooltip = React.memo(
({ active, label, payload, stackKeys, stackDotClassNames }: TStackedBarChartProps) => {
// derived values
const filteredPayload = payload?.filter((item: any) => item.dataKey && stackKeys.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: any) => (
<div key={item?.dataKey} className="flex items-center gap-2 text-xs capitalize">
{stackDotClassNames[item?.dataKey] && (
<div className={cn("size-2 rounded-full", stackDotClassNames[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

@ -1 +0,0 @@
export * from "./root";

View file

@ -1,276 +0,0 @@
import React, { useMemo } from "react";
// plane imports
import { TBottomSectionConfig, TContentVisibility, TTopSectionConfig } from "@plane/types";
import { cn } from "@plane/utils";
const LAYOUT = {
PADDING: 2,
RADIUS: 6,
TEXT: {
PADDING_LEFT: 8,
PADDING_RIGHT: 8,
VERTICAL_OFFSET: 20,
ELLIPSIS_OFFSET: -4,
FONT_SIZES: {
SM: 12.6,
XS: 10.8,
},
},
ICON: {
SIZE: 16,
GAP: 6,
},
MIN_DIMENSIONS: {
HEIGHT_FOR_BOTH: 42,
HEIGHT_FOR_TOP: 35,
HEIGHT_FOR_DOTS: 20,
WIDTH_FOR_ICON: 30,
WIDTH_FOR_DOTS: 15,
},
};
const calculateContentWidth = (text: string | number, fontSize: number): number => String(text).length * fontSize * 0.7;
const calculateTopSectionConfig = (effectiveWidth: number, name: string, hasIcon: boolean): TTopSectionConfig => {
const iconWidth = hasIcon ? LAYOUT.ICON.SIZE + LAYOUT.ICON.GAP : 0;
const nameWidth = calculateContentWidth(name, LAYOUT.TEXT.FONT_SIZES.SM);
const totalPadding = LAYOUT.TEXT.PADDING_LEFT + LAYOUT.TEXT.PADDING_RIGHT;
// First check if we can show icon
const canShowIcon = hasIcon && effectiveWidth >= LAYOUT.MIN_DIMENSIONS.WIDTH_FOR_ICON;
// If we can't even show icon, check if we can show dots
if (!canShowIcon) {
return {
showIcon: false,
showName: effectiveWidth >= LAYOUT.MIN_DIMENSIONS.WIDTH_FOR_DOTS,
nameTruncated: true,
};
}
// We can show icon, now check if we have space for name
const availableWidthForName = effectiveWidth - (canShowIcon ? iconWidth : 0) - totalPadding;
const canShowFullName = availableWidthForName >= nameWidth;
return {
showIcon: canShowIcon,
showName: availableWidthForName > 0,
nameTruncated: !canShowFullName,
};
};
const calculateBottomSectionConfig = (
effectiveWidth: number,
effectiveHeight: number,
value: number | undefined,
label: string | undefined
): TBottomSectionConfig => {
// If height is not enough for bottom section
if (effectiveHeight < LAYOUT.MIN_DIMENSIONS.HEIGHT_FOR_BOTH) {
return {
show: false,
showValue: false,
showLabel: false,
labelTruncated: false,
};
}
// Calculate widths
const totalPadding = LAYOUT.TEXT.PADDING_LEFT + LAYOUT.TEXT.PADDING_RIGHT;
const valueWidth = value ? calculateContentWidth(value, LAYOUT.TEXT.FONT_SIZES.XS) : 0;
const labelWidth = label ? calculateContentWidth(label, LAYOUT.TEXT.FONT_SIZES.XS) + 4 : 0; // 4px for spacing
const availableWidth = effectiveWidth - totalPadding;
// If we can't even show value
if (availableWidth < Math.max(valueWidth, LAYOUT.MIN_DIMENSIONS.WIDTH_FOR_DOTS)) {
return {
show: true,
showValue: false,
showLabel: false,
labelTruncated: false,
};
}
// If we can show value but not full label
const canShowFullLabel = availableWidth >= valueWidth + labelWidth;
return {
show: true,
showValue: true,
showLabel: true,
labelTruncated: !canShowFullLabel,
};
};
const calculateVisibility = (
width: number,
height: number,
hasIcon: boolean,
name: string,
value: number | undefined,
label: string | undefined
): TContentVisibility => {
const effectiveWidth = width - LAYOUT.PADDING * 2;
const effectiveHeight = height - LAYOUT.PADDING * 2;
// If extremely small, show only dots
if (
effectiveHeight < LAYOUT.MIN_DIMENSIONS.HEIGHT_FOR_DOTS ||
effectiveWidth < LAYOUT.MIN_DIMENSIONS.WIDTH_FOR_DOTS
) {
return {
top: { showIcon: false, showName: false, nameTruncated: false },
bottom: { show: false, showValue: false, showLabel: false, labelTruncated: false },
};
}
const topSection = calculateTopSectionConfig(effectiveWidth, name, hasIcon);
const bottomSection = calculateBottomSectionConfig(effectiveWidth, effectiveHeight, value, label);
return {
top: topSection,
bottom: bottomSection,
};
};
const truncateText = (text: string | number, maxWidth: number, fontSize: number, reservedWidth: number = 0): string => {
const availableWidth = maxWidth - reservedWidth;
if (availableWidth <= 0) return "";
const avgCharWidth = fontSize * 0.7;
const maxChars = Math.floor(availableWidth / avgCharWidth);
const stringText = String(text);
if (maxChars <= 3) return "";
if (stringText.length <= maxChars) return stringText;
return `${stringText.slice(0, maxChars - 3)}...`;
};
export const CustomTreeMapContent: React.FC<any> = ({
x,
y,
width,
height,
name,
value,
label,
fillColor,
fillClassName,
textClassName,
icon,
}) => {
const dimensions = useMemo(() => {
const pX = x + LAYOUT.PADDING;
const pY = y + LAYOUT.PADDING;
const pWidth = Math.max(0, width - LAYOUT.PADDING * 2);
const pHeight = Math.max(0, height - LAYOUT.PADDING * 2);
return { pX, pY, pWidth, pHeight };
}, [x, y, width, height]);
const visibility = useMemo(
() => calculateVisibility(width, height, !!icon, name, value, label),
[width, height, icon, name, value, label]
);
if (!name || width <= 0 || height <= 0) return null;
const renderContent = () => {
const { pX, pY, pWidth, pHeight } = dimensions;
const { top, bottom } = visibility;
const availableTextWidth = pWidth - LAYOUT.TEXT.PADDING_LEFT - LAYOUT.TEXT.PADDING_RIGHT;
const iconSpace = top.showIcon ? LAYOUT.ICON.SIZE + LAYOUT.ICON.GAP : 0;
return (
<g>
{/* Background shape */}
<path
d={`
M${pX + LAYOUT.RADIUS},${pY}
L${pX + pWidth - LAYOUT.RADIUS},${pY}
Q${pX + pWidth},${pY} ${pX + pWidth},${pY + LAYOUT.RADIUS}
L${pX + pWidth},${pY + pHeight - LAYOUT.RADIUS}
Q${pX + pWidth},${pY + pHeight} ${pX + pWidth - LAYOUT.RADIUS},${pY + pHeight}
L${pX + LAYOUT.RADIUS},${pY + pHeight}
Q${pX},${pY + pHeight} ${pX},${pY + pHeight - LAYOUT.RADIUS}
L${pX},${pY + LAYOUT.RADIUS}
Q${pX},${pY} ${pX + LAYOUT.RADIUS},${pY}
`}
className={cn("transition-colors duration-200 hover:opacity-90", fillClassName)}
fill={fillColor ?? "currentColor"}
/>
{/* Top section */}
<g>
{top.showIcon && icon && (
<foreignObject
x={pX + LAYOUT.TEXT.PADDING_LEFT}
y={pY + LAYOUT.TEXT.PADDING_LEFT}
width={LAYOUT.ICON.SIZE}
height={LAYOUT.ICON.SIZE}
className={textClassName || "text-custom-text-300"}
>
{React.cloneElement(icon, {
className: cn("size-4", icon?.props?.className),
"aria-hidden": true,
})}
</foreignObject>
)}
{top.showName && (
<text
x={pX + LAYOUT.TEXT.PADDING_LEFT + iconSpace}
y={pY + LAYOUT.TEXT.VERTICAL_OFFSET}
textAnchor="start"
className={cn(
"text-sm font-extralight tracking-wider select-none",
textClassName || "text-custom-text-300"
)}
fill="currentColor"
>
{top.nameTruncated ? truncateText(name, availableTextWidth, LAYOUT.TEXT.FONT_SIZES.SM, iconSpace) : name}
</text>
)}
</g>
{/* Bottom section */}
{bottom.show && (
<g>
{bottom.showValue && value !== undefined && (
<text
x={pX + LAYOUT.TEXT.PADDING_LEFT}
y={pY + pHeight - LAYOUT.TEXT.PADDING_LEFT}
textAnchor="start"
className={cn(
"text-sm font-extralight tracking-wider select-none",
textClassName || "text-custom-text-300"
)}
fill="currentColor"
>
{value.toLocaleString()}
{bottom.showLabel && label && (
<tspan dx={4}>
{bottom.labelTruncated
? truncateText(
label,
availableTextWidth - calculateContentWidth(value, LAYOUT.TEXT.FONT_SIZES.SM) - 4,
LAYOUT.TEXT.FONT_SIZES.SM
)
: label}
</tspan>
)}
{!bottom.showLabel && label && <tspan dx={4}>...</tspan>}
</text>
)}
</g>
)}
</g>
);
};
return (
<g>
<rect x={x} y={y} width={width} height={height} fill="transparent" />
{renderContent()}
</g>
);
};

View file

@ -1,41 +0,0 @@
import React from "react";
import { Treemap, ResponsiveContainer, Tooltip } from "recharts";
// plane imports
import { TreeMapChartProps } from "@plane/types";
import { cn } from "@plane/utils";
// local imports
import { CustomTreeMapContent } from "./map-content";
import { TreeMapTooltip } from "./tooltip";
export const TreeMapChart = React.memo((props: TreeMapChartProps) => {
const { data, className = "w-full h-96", isAnimationActive = false, showTooltip = true } = props;
return (
<div className={cn(className)}>
<ResponsiveContainer width="100%" height="100%">
<Treemap
data={data}
nameKey="name"
dataKey="value"
stroke="currentColor"
className="text-custom-background-100 bg-custom-background-100"
content={<CustomTreeMapContent />}
animationEasing="ease-out"
isUpdateAnimationActive={isAnimationActive}
animationBegin={100}
animationDuration={500}
>
{showTooltip && (
<Tooltip
content={({ active, payload }) => <TreeMapTooltip active={active} payload={payload} />}
cursor={{
fill: "currentColor",
className: "text-custom-background-90/80 cursor-pointer",
}}
/>
)}
</Treemap>
</ResponsiveContainer>
</div>
);
});
TreeMapChart.displayName = "TreeMapChart";

View file

@ -1,29 +0,0 @@
import React from "react";
// plane imports
import { Card, ECardSpacing } from "@plane/ui";
interface TreeMapTooltipProps {
active: boolean | undefined;
payload: any[] | undefined;
}
export const TreeMapTooltip = React.memo(({ active, payload }: TreeMapTooltipProps) => {
if (!active || !payload || !payload[0]?.payload) return null;
const data = payload[0].payload;
return (
<Card className="flex flex-col space-y-1.5" spacing={ECardSpacing.SM}>
<div className="flex items-center gap-2 border-b border-custom-border-200 pb-2.5">
{data?.icon}
<p className="text-xs text-custom-text-100 font-medium capitalize">{data?.name}</p>
</div>
<span className="text-xs font-medium text-custom-text-200">
{data?.value.toLocaleString()}
{data.label && ` ${data.label}`}
</span>
</Card>
);
});
TreeMapTooltip.displayName = "TreeMapTooltip";