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
23 changes: 21 additions & 2 deletions packages/types/src/charts.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ export type TreeMapItem = {
textClassName?: string;
icon?: React.ReactElement;
} & (
| {
| {
fillColor: string;
}
| {
| {
fillClassName: string;
}
);
Expand All @@ -51,4 +51,23 @@ export type TreeMapChartProps = {
data: TreeMapItem[];
className?: string;
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;
};
329 changes: 242 additions & 87 deletions web/core/components/core/charts/tree-map/map-content.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,152 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from "react";
import React, { useMemo } from "react";
// plane imports
import { Tooltip } from "@plane/ui";
import { TBottomSectionConfig, TContentVisibility, TTopSectionConfig } from "@plane/types";
import { cn } from "@plane/utils";
// constants
const AVG_WIDTH_RATIO = 0.7;

const isTruncateRequired = (text: string, maxWidth: number, fontSize: number) => {
// Approximate width per character (this is an estimation)
const avgCharWidth = fontSize * AVG_WIDTH_RATIO;
const maxChars = Math.floor(maxWidth / avgCharWidth);
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 text.length > maxChars;
return {
show: true,
showValue: true,
showLabel: true,
labelTruncated: !canShowFullLabel,
};
};

const truncateText = (text: string, maxWidth: number, fontSize: number) => {
// Approximate width per character (this is an estimation)
const avgCharWidth = fontSize * AVG_WIDTH_RATIO;
const maxChars = Math.floor(maxWidth / avgCharWidth);
if (text.length > maxChars) {
return text.slice(0, maxChars - 2) + "...";
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 },
};
}
return text;

const topSection = calculateTopSectionConfig(effectiveWidth, name, hasIcon);
const bottomSection = calculateBottomSectionConfig(effectiveWidth, effectiveHeight, value, label);

return {
top: topSection,
bottom: bottomSection,
};
};

export const CustomTreeMapContent = ({
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> = ({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Define explicit prop types for CustomTreeMapContent instead of using any

Using any defeats TypeScript's type safety. Define a specific interface for the props of CustomTreeMapContent to enhance code robustness and maintainability.

Apply this diff to define explicit prop types:

-export const CustomTreeMapContent: React.FC<any> = ({
+interface CustomTreeMapContentProps {
+  x: number;
+  y: number;
+  width: number;
+  height: number;
+  name: string;
+  value: number;
+  label: string;
+  fillColor: string;
+  fillClassName: string;
+  textClassName: string;
+  icon: React.ReactNode;
+}
+
+export const CustomTreeMapContent: React.FC<CustomTreeMapContentProps> = ({
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const CustomTreeMapContent: React.FC<any> = ({
interface CustomTreeMapContentProps {
x: number;
y: number;
width: number;
height: number;
name: string;
value: number;
label: string;
fillColor: string;
fillClassName: string;
textClassName: string;
icon: React.ReactNode;
}
export const CustomTreeMapContent: React.FC<CustomTreeMapContentProps> = ({

x,
y,
width,
Expand All @@ -36,54 +158,56 @@ export const CustomTreeMapContent = ({
fillClassName,
textClassName,
icon,
}: any) => {
const RADIUS = 10;
const PADDING = 5;
// Apply padding to dimensions
const pX = x + PADDING;
const pY = y + PADDING;
const pWidth = width - PADDING * 2;
const pHeight = height - PADDING * 2;
// 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
return (
<g>
<path
d={`
M${pX + RADIUS},${pY}
L${pX + pWidth - RADIUS},${pY}
Q${pX + pWidth},${pY} ${pX + pWidth},${pY + RADIUS}
L${pX + pWidth},${pY + pHeight - RADIUS}
Q${pX + pWidth},${pY + pHeight} ${pX + pWidth - RADIUS},${pY + pHeight}
L${pX + RADIUS},${pY + pHeight}
Q${pX},${pY + pHeight} ${pX},${pY + pHeight - RADIUS}
L${pX},${pY + RADIUS}
Q${pX},${pY} ${pX + RADIUS},${pY}
`}
className={cn("transition-colors duration-200 hover:opacity-90 cursor-pointer", fillClassName)}
fill={fillColor ?? "currentColor"}
/>
<Tooltip tooltipContent={name} className="outline-none" disabled={!isTextTruncated}>
}) => {
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 cursor-pointer", fillClassName)}
fill={fillColor ?? "currentColor"}
/>

{/* Top section */}
<g>
{icon && (
{top.showIcon && icon && (
<foreignObject
x={pX + TEXT_PADDING_LEFT}
y={pY + TEXT_PADDING_LEFT}
width={ICON_SIZE}
height={ICON_SIZE}
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, {
Expand All @@ -92,30 +216,61 @@ export const CustomTreeMapContent = ({
})}
</foreignObject>
)}
<text
x={pX + TEXT_PADDING_LEFT + (icon ? ICON_SIZE + ICON_TEXT_GAP : 0)}
y={pY + TEXT_PADDING_LEFT * 2}
textAnchor="start"
className={cn(
"text-sm font-light truncate max-w-[90%] tracking-wider",
textClassName || "text-custom-text-300"
)}
fill="currentColor"
>
{truncatedName}
</text>
{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>
</Tooltip>
<text
x={pX + TEXT_PADDING_LEFT}
y={pY + pHeight - TEXT_PADDING_LEFT}
textAnchor="start"
className={cn("text-xs font-light tracking-wider", textClassName || "text-custom-text-300")}
fill="currentColor"
>
{value?.toLocaleString()}
{label && ` ${label}`}
</text>

{/* 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-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>
);
};
Loading
Loading