improvement: optimize Treemap chart for large datasets (#6369)
This commit is contained in:
parent
87ea13c32e
commit
8a6a5a8ca7
4 changed files with 306 additions and 92 deletions
23
packages/types/src/charts.d.ts
vendored
23
packages/types/src/charts.d.ts
vendored
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
29
web/core/components/core/charts/tree-map/tooltip.tsx
Normal file
29
web/core/components/core/charts/tree-map/tooltip.tsx
Normal 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";
|
||||||
Loading…
Add table
Add a link
Reference in a new issue