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 " ;
54import { 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