From a8ba463cb5fd73668f07bbd80d037b85232bad2d Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Thu, 9 Jan 2025 21:37:22 +0530 Subject: [PATCH] improvement: optimize Treemap chart for large datasets --- packages/types/src/charts.d.ts | 23 +- .../core/charts/tree-map/map-content.tsx | 329 +++++++++++++----- .../components/core/charts/tree-map/root.tsx | 17 +- .../core/charts/tree-map/tooltip.tsx | 29 ++ 4 files changed, 306 insertions(+), 92 deletions(-) create mode 100644 web/core/components/core/charts/tree-map/tooltip.tsx diff --git a/packages/types/src/charts.d.ts b/packages/types/src/charts.d.ts index 64eaff04343..04234d6e725 100644 --- a/packages/types/src/charts.d.ts +++ b/packages/types/src/charts.d.ts @@ -39,10 +39,10 @@ export type TreeMapItem = { textClassName?: string; icon?: React.ReactElement; } & ( - | { + | { fillColor: string; } - | { + | { fillClassName: string; } ); @@ -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; }; diff --git a/web/core/components/core/charts/tree-map/map-content.tsx b/web/core/components/core/charts/tree-map/map-content.tsx index dc178f9baa8..e2f2959f74f 100644 --- a/web/core/components/core/charts/tree-map/map-content.tsx +++ b/web/core/components/core/charts/tree-map/map-content.tsx @@ -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 = ({ x, y, width, @@ -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 ( - - - +}) => { + 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 ( + + {/* Background shape */} + + + {/* Top section */} - {icon && ( + {top.showIcon && icon && ( {React.cloneElement(icon, { @@ -92,30 +216,61 @@ export const CustomTreeMapContent = ({ })} )} - - {truncatedName} - + {top.showName && ( + + {top.nameTruncated ? truncateText(name, availableTextWidth, LAYOUT.TEXT.FONT_SIZES.SM, iconSpace) : name} + + )} - - - {value?.toLocaleString()} - {label && ` ${label}`} - + + {/* Bottom section */} + {bottom.show && ( + + {bottom.showValue && value !== undefined && ( + + {value.toLocaleString()} + {bottom.showLabel && label && ( + + {bottom.labelTruncated + ? truncateText( + label, + availableTextWidth - calculateContentWidth(value, LAYOUT.TEXT.FONT_SIZES.XS) - 4, + LAYOUT.TEXT.FONT_SIZES.XS + ) + : label} + + )} + {!bottom.showLabel && label && ...} + + )} + + )} + + ); + }; + + return ( + + + {renderContent()} ); }; diff --git a/web/core/components/core/charts/tree-map/root.tsx b/web/core/components/core/charts/tree-map/root.tsx index 5e0ecf5e6c3..47ea21d7275 100644 --- a/web/core/components/core/charts/tree-map/root.tsx +++ b/web/core/components/core/charts/tree-map/root.tsx @@ -1,13 +1,14 @@ import React from "react"; -import { Treemap, ResponsiveContainer } from "recharts"; +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 } = props; + const { data, className = "w-full h-96", isAnimationActive = false, showTooltip = true } = props; return (
@@ -22,7 +23,17 @@ export const TreeMapChart = React.memo((props: TreeMapChartProps) => { isUpdateAnimationActive={isAnimationActive} animationBegin={100} animationDuration={500} - /> + > + {showTooltip && ( + } + cursor={{ + fill: "currentColor", + className: "text-custom-background-90/80 cursor-pointer", + }} + /> + )} +
); diff --git a/web/core/components/core/charts/tree-map/tooltip.tsx b/web/core/components/core/charts/tree-map/tooltip.tsx new file mode 100644 index 00000000000..55c6e687ead --- /dev/null +++ b/web/core/components/core/charts/tree-map/tooltip.tsx @@ -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 ( + +
+ {data?.icon} +

{data?.name}

+
+ + {data?.value.toLocaleString()} + {data.label && ` ${data.label}`} + +
+ ); +}); + +TreeMapTooltip.displayName = "TreeMapTooltip";