improvement: optimize Treemap chart for large datasets (#6369)

This commit is contained in:
Prateek Shourya 2025-01-10 12:54:29 +05:30 committed by GitHub
parent 87ea13c32e
commit 8a6a5a8ca7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 306 additions and 92 deletions

View file

@ -39,10 +39,10 @@ export type TreeMapItem = {
textClassName?: string; textClassName?: string;
icon?: React.ReactElement; icon?: React.ReactElement;
} & ( } & (
| { | {
fillColor: string; fillColor: string;
} }
| { | {
fillClassName: string; fillClassName: string;
} }
); );
@ -51,4 +51,23 @@ export type TreeMapChartProps = {
data: TreeMapItem[]; data: TreeMapItem[];
className?: string; className?: string;
isAnimationActive?: boolean; isAnimationActive?: boolean;
showTooltip?: boolean;
};
export type TTopSectionConfig = {
showIcon: boolean;
showName: boolean;
nameTruncated: boolean;
};
export type TBottomSectionConfig = {
show: boolean;
showValue: boolean;
showLabel: boolean;
labelTruncated: boolean;
};
export type TContentVisibility = {
top: TTopSectionConfig;
bottom: TBottomSectionConfig;
}; };

View file

@ -1,30 +1,152 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ import React, { useMemo } from "react";
import React from "react";
// plane imports // plane imports
import { Tooltip } from "@plane/ui"; import { TBottomSectionConfig, TContentVisibility, TTopSectionConfig } from "@plane/types";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
// constants
const AVG_WIDTH_RATIO = 0.7;
const isTruncateRequired = (text: string, maxWidth: number, fontSize: number) => { const LAYOUT = {
// Approximate width per character (this is an estimation) PADDING: 2,
const avgCharWidth = fontSize * AVG_WIDTH_RATIO; RADIUS: 6,
const maxChars = Math.floor(maxWidth / avgCharWidth); TEXT: {
PADDING_LEFT: 8,
return text.length > maxChars; 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 truncateText = (text: string, maxWidth: number, fontSize: number) => { const calculateContentWidth = (text: string | number, fontSize: number): number => String(text).length * fontSize * 0.7;
// Approximate width per character (this is an estimation)
const avgCharWidth = fontSize * AVG_WIDTH_RATIO; const calculateTopSectionConfig = (effectiveWidth: number, name: string, hasIcon: boolean): TTopSectionConfig => {
const maxChars = Math.floor(maxWidth / avgCharWidth); const iconWidth = hasIcon ? LAYOUT.ICON.SIZE + LAYOUT.ICON.GAP : 0;
if (text.length > maxChars) { const nameWidth = calculateContentWidth(name, LAYOUT.TEXT.FONT_SIZES.SM);
return text.slice(0, maxChars - 2) + "..."; 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,
};
} }
return text;
// 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,
};
}; };
export const CustomTreeMapContent = ({ 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, x,
y, y,
width, width,
@ -36,54 +158,56 @@ export const CustomTreeMapContent = ({
fillClassName, fillClassName,
textClassName, textClassName,
icon, icon,
}: any) => { }) => {
const RADIUS = 10; const dimensions = useMemo(() => {
const PADDING = 5; const pX = x + LAYOUT.PADDING;
// Apply padding to dimensions const pY = y + LAYOUT.PADDING;
const pX = x + PADDING; const pWidth = Math.max(0, width - LAYOUT.PADDING * 2);
const pY = y + PADDING; const pHeight = Math.max(0, height - LAYOUT.PADDING * 2);
const pWidth = width - PADDING * 2; return { pX, pY, pWidth, pHeight };
const pHeight = height - PADDING * 2; }, [x, y, width, height]);
// Text padding from the left edge
const TEXT_PADDING_LEFT = 12;
const TEXT_PADDING_RIGHT = 12;
// Icon size and spacing
const ICON_SIZE = 16;
const ICON_TEXT_GAP = 6;
// Available width for the text
const iconSpace = icon ? ICON_SIZE + ICON_TEXT_GAP : 0;
const availableWidth = pWidth - TEXT_PADDING_LEFT - TEXT_PADDING_RIGHT - iconSpace;
// Truncate text based on available width
// 12.6px for text-sm
const isTextTruncated = typeof name === "string" ? isTruncateRequired(name, availableWidth, 12.6) : name;
const truncatedName = typeof name === "string" ? truncateText(name, availableWidth, 12.6) : name;
if (!name) return; // To remove the total count const visibility = useMemo(
return ( () => calculateVisibility(width, height, !!icon, name, value, label),
<g> [width, height, icon, name, value, label]
<path );
d={`
M${pX + RADIUS},${pY} if (!name || width <= 0 || height <= 0) return null;
L${pX + pWidth - RADIUS},${pY}
Q${pX + pWidth},${pY} ${pX + pWidth},${pY + RADIUS} const renderContent = () => {
L${pX + pWidth},${pY + pHeight - RADIUS} const { pX, pY, pWidth, pHeight } = dimensions;
Q${pX + pWidth},${pY + pHeight} ${pX + pWidth - RADIUS},${pY + pHeight} const { top, bottom } = visibility;
L${pX + RADIUS},${pY + pHeight}
Q${pX},${pY + pHeight} ${pX},${pY + pHeight - RADIUS} const availableTextWidth = pWidth - LAYOUT.TEXT.PADDING_LEFT - LAYOUT.TEXT.PADDING_RIGHT;
L${pX},${pY + RADIUS} const iconSpace = top.showIcon ? LAYOUT.ICON.SIZE + LAYOUT.ICON.GAP : 0;
Q${pX},${pY} ${pX + RADIUS},${pY}
`} return (
className={cn("transition-colors duration-200 hover:opacity-90 cursor-pointer", fillClassName)} <g>
fill={fillColor ?? "currentColor"} {/* Background shape */}
/> <path
<Tooltip tooltipContent={name} className="outline-none" disabled={!isTextTruncated}> 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 cursor-pointer", fillClassName)}
fill={fillColor ?? "currentColor"}
/>
{/* Top section */}
<g> <g>
{icon && ( {top.showIcon && icon && (
<foreignObject <foreignObject
x={pX + TEXT_PADDING_LEFT} x={pX + LAYOUT.TEXT.PADDING_LEFT}
y={pY + TEXT_PADDING_LEFT} y={pY + LAYOUT.TEXT.PADDING_LEFT}
width={ICON_SIZE} width={LAYOUT.ICON.SIZE}
height={ICON_SIZE} height={LAYOUT.ICON.SIZE}
className={textClassName || "text-custom-text-300"} className={textClassName || "text-custom-text-300"}
> >
{React.cloneElement(icon, { {React.cloneElement(icon, {
@ -92,30 +216,61 @@ export const CustomTreeMapContent = ({
})} })}
</foreignObject> </foreignObject>
)} )}
<text {top.showName && (
x={pX + TEXT_PADDING_LEFT + (icon ? ICON_SIZE + ICON_TEXT_GAP : 0)} <text
y={pY + TEXT_PADDING_LEFT * 2} x={pX + LAYOUT.TEXT.PADDING_LEFT + iconSpace}
textAnchor="start" y={pY + LAYOUT.TEXT.VERTICAL_OFFSET}
className={cn( textAnchor="start"
"text-sm font-light truncate max-w-[90%] tracking-wider", className={cn(
textClassName || "text-custom-text-300" "text-sm font-extralight tracking-wider select-none",
)} textClassName || "text-custom-text-300"
fill="currentColor" )}
> fill="currentColor"
{truncatedName} >
</text> {top.nameTruncated ? truncateText(name, availableTextWidth, LAYOUT.TEXT.FONT_SIZES.SM, iconSpace) : name}
</text>
)}
</g> </g>
</Tooltip>
<text {/* Bottom section */}
x={pX + TEXT_PADDING_LEFT} {bottom.show && (
y={pY + pHeight - TEXT_PADDING_LEFT} <g>
textAnchor="start" {bottom.showValue && value !== undefined && (
className={cn("text-xs font-light tracking-wider", textClassName || "text-custom-text-300")} <text
fill="currentColor" x={pX + LAYOUT.TEXT.PADDING_LEFT}
> y={pY + pHeight - LAYOUT.TEXT.PADDING_LEFT}
{value?.toLocaleString()} textAnchor="start"
{label && ` ${label}`} className={cn(
</text> "text-xs font-extralight tracking-wider select-none",
textClassName || "text-custom-text-400"
)}
fill="currentColor"
>
{value.toLocaleString()}
{bottom.showLabel && label && (
<tspan dx={4}>
{bottom.labelTruncated
? truncateText(
label,
availableTextWidth - calculateContentWidth(value, LAYOUT.TEXT.FONT_SIZES.XS) - 4,
LAYOUT.TEXT.FONT_SIZES.XS
)
: 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" className="cursor-pointer" />
{renderContent()}
</g> </g>
); );
}; };

View file

@ -1,13 +1,14 @@
import React from "react"; import React from "react";
import { Treemap, ResponsiveContainer } from "recharts"; import { Treemap, ResponsiveContainer, Tooltip } from "recharts";
// plane imports // plane imports
import { TreeMapChartProps } from "@plane/types"; import { TreeMapChartProps } from "@plane/types";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
// local imports // local imports
import { CustomTreeMapContent } from "./map-content"; import { CustomTreeMapContent } from "./map-content";
import { TreeMapTooltip } from "./tooltip";
export const TreeMapChart = React.memo((props: TreeMapChartProps) => { export const TreeMapChart = React.memo((props: TreeMapChartProps) => {
const { data, className = "w-full h-96", isAnimationActive = false } = props; const { data, className = "w-full h-96", isAnimationActive = false, showTooltip = true } = props;
return ( return (
<div className={cn(className)}> <div className={cn(className)}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
@ -22,7 +23,17 @@ export const TreeMapChart = React.memo((props: TreeMapChartProps) => {
isUpdateAnimationActive={isAnimationActive} isUpdateAnimationActive={isAnimationActive}
animationBegin={100} animationBegin={100}
animationDuration={500} 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> </ResponsiveContainer>
</div> </div>
); );

View file

@ -0,0 +1,29 @@
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";