@@ -7,10 +7,6 @@ import { Color } from 'three';
77import type { NodeRendererProps } from '../../types' ;
88import { animationConfig } from '../../utils' ;
99
10- // Layout constants
11- const CHAR_WIDTH_ESTIMATE = 0.15 ;
12- const ICON_TEXT_GAP = 0.15 ;
13-
1410export type BadgePosition =
1511 | 'top-right'
1612 | 'top-left'
@@ -20,7 +16,7 @@ export type BadgePosition =
2016
2117export type IconPosition = 'start' | 'end' ;
2218
23- export interface BadgeProps extends NodeRendererProps {
19+ export interface BadgeProps extends Omit < NodeRendererProps , 'opacity' > {
2420 /**
2521 * The text to display in the badge.
2622 */
@@ -31,6 +27,12 @@ export interface BadgeProps extends NodeRendererProps {
3127 */
3228 backgroundColor ?: string ;
3329
30+ /**
31+ * Opacity of the badge background and stroke (0-1).
32+ * Default: 1
33+ */
34+ opacity ?: number ;
35+
3436 /**
3537 * Text color of the badge.
3638 */
@@ -84,6 +86,30 @@ export interface BadgeProps extends NodeRendererProps {
8486 * the text remains centered and only the icon moves to the specified position.
8587 */
8688 iconPosition ?: IconPosition | [ number , number ] ;
89+
90+ /**
91+ * Font size for the badge text.
92+ */
93+ fontSize ?: number ;
94+
95+ /**
96+ * Font weight for the badge text (100-900).
97+ * Values outside this range will be clamped to the nearest valid value.
98+ * Common values: 400 (normal), 700 (bold), 900 (extra bold).
99+ */
100+ fontWeight ?: number ;
101+
102+ /**
103+ * Character width estimate for calculating text width.
104+ * Default: 0.2
105+ */
106+ charWidthEstimate ?: number ;
107+
108+ /**
109+ * Gap between icon and text.
110+ * Default: 0.01
111+ */
112+ iconTextGap ?: number ;
87113}
88114
89115export const Badge : FC < BadgeProps > = ( {
@@ -98,10 +124,14 @@ export const Badge: FC<BadgeProps> = ({
98124 radius = 0.12 ,
99125 badgeSize = 1.5 ,
100126 position = 'top-right' ,
101- padding = 0.3 ,
127+ padding = 0.15 ,
102128 icon,
103129 iconSize = 0.35 ,
104- iconPosition = 'start'
130+ iconPosition = 'start' ,
131+ fontSize = 0.3 ,
132+ fontWeight,
133+ charWidthEstimate = 0.2 ,
134+ iconTextGap = 0.01
105135} ) => {
106136 const normalizedBgColor = useMemo (
107137 ( ) => new Color ( backgroundColor ) ,
@@ -115,6 +145,13 @@ export const Badge: FC<BadgeProps> = ({
115145 // Guard for radius
116146 const normalizedRadius = Math . min ( radius , 0.2 ) ;
117147
148+ // Normalize fontWeight to valid CSS range (100-900)
149+ const normalizedFontWeight = useMemo ( ( ) => {
150+ if ( fontWeight === undefined ) return undefined ;
151+ // Clamp numeric values to 100-900 range
152+ return Math . max ( 100 , Math . min ( 900 , fontWeight ) ) ;
153+ } , [ fontWeight ] ) ;
154+
118155 // Calculate position based on preset or custom coordinates
119156 const badgePosition = useMemo ( ( ) : [ number , number , number ] => {
120157 if ( Array . isArray ( position ) ) {
@@ -138,37 +175,58 @@ export const Badge: FC<BadgeProps> = ({
138175 }
139176 } , [ position , size ] ) ;
140177
178+ // Shared text size calculations (used by both badgeDimensions and contentLayout)
179+ const textSizeCalculations = useMemo ( ( ) => {
180+ const fontSizeScale = fontSize / 0.3 ;
181+ const fontWeightMultiplier =
182+ normalizedFontWeight && normalizedFontWeight >= 700 ? 1.1 : 1 ;
183+ const adjustedCharWidth =
184+ charWidthEstimate * fontSizeScale * fontWeightMultiplier ;
185+ const estimatedTextWidth = label . length * adjustedCharWidth ;
186+
187+ return {
188+ fontSizeScale,
189+ fontWeightMultiplier,
190+ adjustedCharWidth,
191+ estimatedTextWidth
192+ } ;
193+ } , [ fontSize , normalizedFontWeight , charWidthEstimate , label . length ] ) ;
194+
141195 // Calculate dynamic badge dimensions based on text length and icon
142196 const badgeDimensions = useMemo ( ( ) => {
143197 const baseWidth = 0.5 ;
144198 const baseHeight = 0.5 ;
145199 const minWidth = baseWidth ;
146200 const minHeight = baseHeight ;
147201
148- // Estimate text width based on character count
149- const charCount = label . length ;
150- let estimatedWidth = Math . max (
151- minWidth ,
152- Math . min ( charCount * CHAR_WIDTH_ESTIMATE + padding , 2.0 + padding )
153- ) ; // Add padding to width
202+ const { fontSizeScale, estimatedTextWidth } = textSizeCalculations ;
154203
155- // Add icon width if icon is present
204+ // Calculate content width (text + icon + gap, no padding yet)
205+ let contentWidth = estimatedTextWidth ;
156206 if ( icon ) {
157- estimatedWidth += iconSize ;
207+ contentWidth += iconSize + iconTextGap ;
158208 }
159209
210+ // Add padding to total width (padding on both left and right sides)
211+ const estimatedWidth = Math . max ( minWidth , contentWidth + padding * 2 ) ;
212+
213+ // Scale height based on fontSize
214+ const charCount = label . length ;
160215 const estimatedHeight = Math . max (
161216 minHeight ,
162- Math . min ( charCount * 0.05 + padding * 0.5 , 0.8 + padding * 0.5 )
163- ) ; // Add padding to height
217+ Math . min (
218+ charCount * 0.05 * fontSizeScale + padding * 0.5 ,
219+ 0.8 * fontSizeScale + padding * 0.5
220+ )
221+ ) ;
164222
165223 return {
166224 width : estimatedWidth ,
167225 height : estimatedHeight
168226 } ;
169- } , [ label , padding , icon , iconSize ] ) ;
227+ } , [ textSizeCalculations , label . length , padding , icon , iconSize , iconTextGap ] ) ;
170228
171- const { scale, badgeOpacity } = useSpring ( {
229+ const { scale } = useSpring ( {
172230 from : {
173231 scale : [ 0.00001 , 0.00001 , 0.00001 ] ,
174232 badgeOpacity : 0
@@ -204,26 +262,26 @@ export const Badge: FC<BadgeProps> = ({
204262 } ;
205263 }
206264
207- const estimatedTextWidth = label . length * CHAR_WIDTH_ESTIMATE ;
208- const totalContentWidth = iconSize + estimatedTextWidth ;
265+ const { estimatedTextWidth } = textSizeCalculations ;
266+ const totalContentWidth = iconSize + iconTextGap + estimatedTextWidth ;
209267 const startX = - totalContentWidth / 2 ;
210268
211269 if ( iconPosition === 'start' ) {
212270 return {
213- iconX : startX + iconSize - 0.5 / 2 ,
271+ iconX : startX + iconSize / 2 ,
214272 iconY : 0 ,
215- textX : startX + iconSize + estimatedTextWidth / 2 ,
273+ textX : startX + iconSize + iconTextGap + estimatedTextWidth / 2 ,
216274 textY : 0
217275 } ;
218276 } else {
219277 return {
220278 textX : startX + estimatedTextWidth / 2 ,
221279 textY : 0 ,
222- iconX : startX + estimatedTextWidth + ICON_TEXT_GAP + iconSize / 2 ,
280+ iconX : startX + estimatedTextWidth + iconTextGap + iconSize / 2 ,
223281 iconY : 0
224282 } ;
225283 }
226- } , [ icon , iconSize , iconPosition , label . length ] ) ;
284+ } , [ textSizeCalculations , icon , iconSize , iconPosition , iconTextGap ] ) ;
227285
228286 return (
229287 < Billboard position = { badgePosition } >
@@ -241,6 +299,7 @@ export const Badge: FC<BadgeProps> = ({
241299 smoothness = { 8 }
242300 material-color = { normalizedStrokeColor }
243301 material-transparent = { true }
302+ material-opacity = { opacity }
244303 />
245304 </ a . mesh >
246305 ) }
@@ -252,6 +311,7 @@ export const Badge: FC<BadgeProps> = ({
252311 smoothness = { 8 }
253312 material-color = { normalizedBgColor }
254313 material-transparent = { true }
314+ material-opacity = { opacity }
255315 />
256316 </ a . mesh >
257317 { /* Icon */ }
@@ -268,11 +328,11 @@ export const Badge: FC<BadgeProps> = ({
268328 { /* Text */ }
269329 < Text
270330 position = { [ contentLayout . textX , contentLayout . textY , 1.1 ] }
271- fontSize = { 0.3 }
331+ fontSize = { fontSize }
332+ fontWeight = { normalizedFontWeight }
272333 color = { normalizedTextColor }
273334 anchorX = "center"
274335 anchorY = "middle"
275- maxWidth = { badgeDimensions . width - 0.2 }
276336 textAlign = "center"
277337 material-depthTest = { false }
278338 material-depthWrite = { false }
0 commit comments