Skip to content

Commit 8a6a5a8

Browse files
improvement: optimize Treemap chart for large datasets (#6369)
1 parent 87ea13c commit 8a6a5a8

File tree

4 files changed

+306
-92
lines changed

4 files changed

+306
-92
lines changed

packages/types/src/charts.d.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,10 @@ export type TreeMapItem = {
3939
textClassName?: string;
4040
icon?: React.ReactElement;
4141
} & (
42-
| {
42+
| {
4343
fillColor: string;
4444
}
45-
| {
45+
| {
4646
fillClassName: string;
4747
}
4848
);
@@ -51,4 +51,23 @@ export type TreeMapChartProps = {
5151
data: TreeMapItem[];
5252
className?: string;
5353
isAnimationActive?: boolean;
54+
showTooltip?: boolean;
55+
};
56+
57+
export type TTopSectionConfig = {
58+
showIcon: boolean;
59+
showName: boolean;
60+
nameTruncated: boolean;
61+
};
62+
63+
export type TBottomSectionConfig = {
64+
show: boolean;
65+
showValue: boolean;
66+
showLabel: boolean;
67+
labelTruncated: boolean;
68+
};
69+
70+
export type TContentVisibility = {
71+
top: TTopSectionConfig;
72+
bottom: TBottomSectionConfig;
5473
};
Lines changed: 242 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,152 @@
1-
/* eslint-disable @typescript-eslint/no-explicit-any */
2-
import React from "react";
1+
import React, { useMemo } from "react";
32
// plane imports
4-
import { Tooltip } from "@plane/ui";
3+
import { TBottomSectionConfig, TContentVisibility, TTopSectionConfig } from "@plane/types";
54
import { cn } from "@plane/utils";
6-
// constants
7-
const AVG_WIDTH_RATIO = 0.7;
85

9-
const isTruncateRequired = (text: string, maxWidth: number, fontSize: number) => {
10-
// Approximate width per character (this is an estimation)
11-
const avgCharWidth = fontSize * AVG_WIDTH_RATIO;
12-
const maxChars = Math.floor(maxWidth / avgCharWidth);
6+
const LAYOUT = {
7+
PADDING: 2,
8+
RADIUS: 6,
9+
TEXT: {
10+
PADDING_LEFT: 8,
11+
PADDING_RIGHT: 8,
12+
VERTICAL_OFFSET: 20,
13+
ELLIPSIS_OFFSET: -4,
14+
FONT_SIZES: {
15+
SM: 12.6,
16+
XS: 10.8,
17+
},
18+
},
19+
ICON: {
20+
SIZE: 16,
21+
GAP: 6,
22+
},
23+
MIN_DIMENSIONS: {
24+
HEIGHT_FOR_BOTH: 42,
25+
HEIGHT_FOR_TOP: 35,
26+
HEIGHT_FOR_DOTS: 20,
27+
WIDTH_FOR_ICON: 30,
28+
WIDTH_FOR_DOTS: 15,
29+
},
30+
};
31+
32+
const calculateContentWidth = (text: string | number, fontSize: number): number => String(text).length * fontSize * 0.7;
33+
34+
const calculateTopSectionConfig = (effectiveWidth: number, name: string, hasIcon: boolean): TTopSectionConfig => {
35+
const iconWidth = hasIcon ? LAYOUT.ICON.SIZE + LAYOUT.ICON.GAP : 0;
36+
const nameWidth = calculateContentWidth(name, LAYOUT.TEXT.FONT_SIZES.SM);
37+
const totalPadding = LAYOUT.TEXT.PADDING_LEFT + LAYOUT.TEXT.PADDING_RIGHT;
38+
39+
// First check if we can show icon
40+
const canShowIcon = hasIcon && effectiveWidth >= LAYOUT.MIN_DIMENSIONS.WIDTH_FOR_ICON;
41+
42+
// If we can't even show icon, check if we can show dots
43+
if (!canShowIcon) {
44+
return {
45+
showIcon: false,
46+
showName: effectiveWidth >= LAYOUT.MIN_DIMENSIONS.WIDTH_FOR_DOTS,
47+
nameTruncated: true,
48+
};
49+
}
50+
51+
// We can show icon, now check if we have space for name
52+
const availableWidthForName = effectiveWidth - (canShowIcon ? iconWidth : 0) - totalPadding;
53+
const canShowFullName = availableWidthForName >= nameWidth;
54+
55+
return {
56+
showIcon: canShowIcon,
57+
showName: availableWidthForName > 0,
58+
nameTruncated: !canShowFullName,
59+
};
60+
};
61+
62+
const calculateBottomSectionConfig = (
63+
effectiveWidth: number,
64+
effectiveHeight: number,
65+
value: number | undefined,
66+
label: string | undefined
67+
): TBottomSectionConfig => {
68+
// If height is not enough for bottom section
69+
if (effectiveHeight < LAYOUT.MIN_DIMENSIONS.HEIGHT_FOR_BOTH) {
70+
return {
71+
show: false,
72+
showValue: false,
73+
showLabel: false,
74+
labelTruncated: false,
75+
};
76+
}
77+
78+
// Calculate widths
79+
const totalPadding = LAYOUT.TEXT.PADDING_LEFT + LAYOUT.TEXT.PADDING_RIGHT;
80+
const valueWidth = value ? calculateContentWidth(value, LAYOUT.TEXT.FONT_SIZES.XS) : 0;
81+
const labelWidth = label ? calculateContentWidth(label, LAYOUT.TEXT.FONT_SIZES.XS) + 4 : 0; // 4px for spacing
82+
const availableWidth = effectiveWidth - totalPadding;
83+
84+
// If we can't even show value
85+
if (availableWidth < Math.max(valueWidth, LAYOUT.MIN_DIMENSIONS.WIDTH_FOR_DOTS)) {
86+
return {
87+
show: true,
88+
showValue: false,
89+
showLabel: false,
90+
labelTruncated: false,
91+
};
92+
}
93+
94+
// If we can show value but not full label
95+
const canShowFullLabel = availableWidth >= valueWidth + labelWidth;
1396

14-
return text.length > maxChars;
97+
return {
98+
show: true,
99+
showValue: true,
100+
showLabel: true,
101+
labelTruncated: !canShowFullLabel,
102+
};
15103
};
16104

17-
const truncateText = (text: string, maxWidth: number, fontSize: number) => {
18-
// Approximate width per character (this is an estimation)
19-
const avgCharWidth = fontSize * AVG_WIDTH_RATIO;
20-
const maxChars = Math.floor(maxWidth / avgCharWidth);
21-
if (text.length > maxChars) {
22-
return text.slice(0, maxChars - 2) + "...";
105+
const calculateVisibility = (
106+
width: number,
107+
height: number,
108+
hasIcon: boolean,
109+
name: string,
110+
value: number | undefined,
111+
label: string | undefined
112+
): TContentVisibility => {
113+
const effectiveWidth = width - LAYOUT.PADDING * 2;
114+
const effectiveHeight = height - LAYOUT.PADDING * 2;
115+
116+
// If extremely small, show only dots
117+
if (
118+
effectiveHeight < LAYOUT.MIN_DIMENSIONS.HEIGHT_FOR_DOTS ||
119+
effectiveWidth < LAYOUT.MIN_DIMENSIONS.WIDTH_FOR_DOTS
120+
) {
121+
return {
122+
top: { showIcon: false, showName: false, nameTruncated: false },
123+
bottom: { show: false, showValue: false, showLabel: false, labelTruncated: false },
124+
};
23125
}
24-
return text;
126+
127+
const topSection = calculateTopSectionConfig(effectiveWidth, name, hasIcon);
128+
const bottomSection = calculateBottomSectionConfig(effectiveWidth, effectiveHeight, value, label);
129+
130+
return {
131+
top: topSection,
132+
bottom: bottomSection,
133+
};
25134
};
26135

27-
export const CustomTreeMapContent = ({
136+
const truncateText = (text: string | number, maxWidth: number, fontSize: number, reservedWidth: number = 0): string => {
137+
const availableWidth = maxWidth - reservedWidth;
138+
if (availableWidth <= 0) return "";
139+
140+
const avgCharWidth = fontSize * 0.7;
141+
const maxChars = Math.floor(availableWidth / avgCharWidth);
142+
const stringText = String(text);
143+
144+
if (maxChars <= 3) return "";
145+
if (stringText.length <= maxChars) return stringText;
146+
return `${stringText.slice(0, maxChars - 3)}...`;
147+
};
148+
149+
export const CustomTreeMapContent: React.FC<any> = ({
28150
x,
29151
y,
30152
width,
@@ -36,54 +158,56 @@ export const CustomTreeMapContent = ({
36158
fillClassName,
37159
textClassName,
38160
icon,
39-
}: any) => {
40-
const RADIUS = 10;
41-
const PADDING = 5;
42-
// Apply padding to dimensions
43-
const pX = x + PADDING;
44-
const pY = y + PADDING;
45-
const pWidth = width - PADDING * 2;
46-
const pHeight = height - PADDING * 2;
47-
// Text padding from the left edge
48-
const TEXT_PADDING_LEFT = 12;
49-
const TEXT_PADDING_RIGHT = 12;
50-
// Icon size and spacing
51-
const ICON_SIZE = 16;
52-
const ICON_TEXT_GAP = 6;
53-
// Available width for the text
54-
const iconSpace = icon ? ICON_SIZE + ICON_TEXT_GAP : 0;
55-
const availableWidth = pWidth - TEXT_PADDING_LEFT - TEXT_PADDING_RIGHT - iconSpace;
56-
// Truncate text based on available width
57-
// 12.6px for text-sm
58-
const isTextTruncated = typeof name === "string" ? isTruncateRequired(name, availableWidth, 12.6) : name;
59-
const truncatedName = typeof name === "string" ? truncateText(name, availableWidth, 12.6) : name;
60-
61-
if (!name) return; // To remove the total count
62-
return (
63-
<g>
64-
<path
65-
d={`
66-
M${pX + RADIUS},${pY}
67-
L${pX + pWidth - RADIUS},${pY}
68-
Q${pX + pWidth},${pY} ${pX + pWidth},${pY + RADIUS}
69-
L${pX + pWidth},${pY + pHeight - RADIUS}
70-
Q${pX + pWidth},${pY + pHeight} ${pX + pWidth - RADIUS},${pY + pHeight}
71-
L${pX + RADIUS},${pY + pHeight}
72-
Q${pX},${pY + pHeight} ${pX},${pY + pHeight - RADIUS}
73-
L${pX},${pY + RADIUS}
74-
Q${pX},${pY} ${pX + RADIUS},${pY}
75-
`}
76-
className={cn("transition-colors duration-200 hover:opacity-90 cursor-pointer", fillClassName)}
77-
fill={fillColor ?? "currentColor"}
78-
/>
79-
<Tooltip tooltipContent={name} className="outline-none" disabled={!isTextTruncated}>
161+
}) => {
162+
const dimensions = useMemo(() => {
163+
const pX = x + LAYOUT.PADDING;
164+
const pY = y + LAYOUT.PADDING;
165+
const pWidth = Math.max(0, width - LAYOUT.PADDING * 2);
166+
const pHeight = Math.max(0, height - LAYOUT.PADDING * 2);
167+
return { pX, pY, pWidth, pHeight };
168+
}, [x, y, width, height]);
169+
170+
const visibility = useMemo(
171+
() => calculateVisibility(width, height, !!icon, name, value, label),
172+
[width, height, icon, name, value, label]
173+
);
174+
175+
if (!name || width <= 0 || height <= 0) return null;
176+
177+
const renderContent = () => {
178+
const { pX, pY, pWidth, pHeight } = dimensions;
179+
const { top, bottom } = visibility;
180+
181+
const availableTextWidth = pWidth - LAYOUT.TEXT.PADDING_LEFT - LAYOUT.TEXT.PADDING_RIGHT;
182+
const iconSpace = top.showIcon ? LAYOUT.ICON.SIZE + LAYOUT.ICON.GAP : 0;
183+
184+
return (
185+
<g>
186+
{/* Background shape */}
187+
<path
188+
d={`
189+
M${pX + LAYOUT.RADIUS},${pY}
190+
L${pX + pWidth - LAYOUT.RADIUS},${pY}
191+
Q${pX + pWidth},${pY} ${pX + pWidth},${pY + LAYOUT.RADIUS}
192+
L${pX + pWidth},${pY + pHeight - LAYOUT.RADIUS}
193+
Q${pX + pWidth},${pY + pHeight} ${pX + pWidth - LAYOUT.RADIUS},${pY + pHeight}
194+
L${pX + LAYOUT.RADIUS},${pY + pHeight}
195+
Q${pX},${pY + pHeight} ${pX},${pY + pHeight - LAYOUT.RADIUS}
196+
L${pX},${pY + LAYOUT.RADIUS}
197+
Q${pX},${pY} ${pX + LAYOUT.RADIUS},${pY}
198+
`}
199+
className={cn("transition-colors duration-200 hover:opacity-90 cursor-pointer", fillClassName)}
200+
fill={fillColor ?? "currentColor"}
201+
/>
202+
203+
{/* Top section */}
80204
<g>
81-
{icon && (
205+
{top.showIcon && icon && (
82206
<foreignObject
83-
x={pX + TEXT_PADDING_LEFT}
84-
y={pY + TEXT_PADDING_LEFT}
85-
width={ICON_SIZE}
86-
height={ICON_SIZE}
207+
x={pX + LAYOUT.TEXT.PADDING_LEFT}
208+
y={pY + LAYOUT.TEXT.PADDING_LEFT}
209+
width={LAYOUT.ICON.SIZE}
210+
height={LAYOUT.ICON.SIZE}
87211
className={textClassName || "text-custom-text-300"}
88212
>
89213
{React.cloneElement(icon, {
@@ -92,30 +216,61 @@ export const CustomTreeMapContent = ({
92216
})}
93217
</foreignObject>
94218
)}
95-
<text
96-
x={pX + TEXT_PADDING_LEFT + (icon ? ICON_SIZE + ICON_TEXT_GAP : 0)}
97-
y={pY + TEXT_PADDING_LEFT * 2}
98-
textAnchor="start"
99-
className={cn(
100-
"text-sm font-light truncate max-w-[90%] tracking-wider",
101-
textClassName || "text-custom-text-300"
102-
)}
103-
fill="currentColor"
104-
>
105-
{truncatedName}
106-
</text>
219+
{top.showName && (
220+
<text
221+
x={pX + LAYOUT.TEXT.PADDING_LEFT + iconSpace}
222+
y={pY + LAYOUT.TEXT.VERTICAL_OFFSET}
223+
textAnchor="start"
224+
className={cn(
225+
"text-sm font-extralight tracking-wider select-none",
226+
textClassName || "text-custom-text-300"
227+
)}
228+
fill="currentColor"
229+
>
230+
{top.nameTruncated ? truncateText(name, availableTextWidth, LAYOUT.TEXT.FONT_SIZES.SM, iconSpace) : name}
231+
</text>
232+
)}
107233
</g>
108-
</Tooltip>
109-
<text
110-
x={pX + TEXT_PADDING_LEFT}
111-
y={pY + pHeight - TEXT_PADDING_LEFT}
112-
textAnchor="start"
113-
className={cn("text-xs font-light tracking-wider", textClassName || "text-custom-text-300")}
114-
fill="currentColor"
115-
>
116-
{value?.toLocaleString()}
117-
{label && ` ${label}`}
118-
</text>
234+
235+
{/* Bottom section */}
236+
{bottom.show && (
237+
<g>
238+
{bottom.showValue && value !== undefined && (
239+
<text
240+
x={pX + LAYOUT.TEXT.PADDING_LEFT}
241+
y={pY + pHeight - LAYOUT.TEXT.PADDING_LEFT}
242+
textAnchor="start"
243+
className={cn(
244+
"text-xs font-extralight tracking-wider select-none",
245+
textClassName || "text-custom-text-400"
246+
)}
247+
fill="currentColor"
248+
>
249+
{value.toLocaleString()}
250+
{bottom.showLabel && label && (
251+
<tspan dx={4}>
252+
{bottom.labelTruncated
253+
? truncateText(
254+
label,
255+
availableTextWidth - calculateContentWidth(value, LAYOUT.TEXT.FONT_SIZES.XS) - 4,
256+
LAYOUT.TEXT.FONT_SIZES.XS
257+
)
258+
: label}
259+
</tspan>
260+
)}
261+
{!bottom.showLabel && label && <tspan dx={4}>...</tspan>}
262+
</text>
263+
)}
264+
</g>
265+
)}
266+
</g>
267+
);
268+
};
269+
270+
return (
271+
<g>
272+
<rect x={x} y={y} width={width} height={height} fill="transparent" className="cursor-pointer" />
273+
{renderContent()}
119274
</g>
120275
);
121276
};

0 commit comments

Comments
 (0)