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
2 changes: 1 addition & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npx lint-staged
npx lint-staged
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -146,4 +146,4 @@
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}
}
105 changes: 96 additions & 9 deletions src/symbols/nodes/Badge.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import { a, useSpring } from '@react-spring/three';
import { Billboard, RoundedBox, Text } from '@react-three/drei';
import { Billboard, Image, RoundedBox, Text } from '@react-three/drei';
import type { FC } from 'react';
import React, { useMemo } from 'react';
import { Color } from 'three';

import type { NodeRendererProps } from '../../types';
import { animationConfig } from '../../utils';

// Layout constants
const CHAR_WIDTH_ESTIMATE = 0.15;
const ICON_TEXT_GAP = 0.15;

export type BadgePosition =
| 'top-right'
| 'top-left'
| 'bottom-right'
| 'bottom-left'
| 'center'
| 'custom';
| 'center';

export type IconPosition = 'start' | 'end';

export interface BadgeProps extends NodeRendererProps {
/**
Expand Down Expand Up @@ -60,6 +65,25 @@ export interface BadgeProps extends NodeRendererProps {
* Padding around the badge text.
*/
padding?: number;

/**
* SVG icon path or URL to display in the badge.
*/
icon?: string;

/**
* Size of the icon in the badge.
*/
iconSize?: number;

/**
* Position of the icon relative to the text or custom coordinates [x, y].
* - 'start': Icon appears before the text (left side)
* - 'end': Icon appears after the text (right side)
* - [x, y]: Custom coordinates within the badge. When using custom coordinates,
* the text remains centered and only the icon moves to the specified position.
*/
iconPosition?: IconPosition | [number, number];
}

export const Badge: FC<BadgeProps> = ({
Expand All @@ -74,7 +98,10 @@ export const Badge: FC<BadgeProps> = ({
radius = 0.12,
badgeSize = 1.5,
position = 'top-right',
padding = 0.3
padding = 0.3,
icon,
iconSize = 0.35,
iconPosition = 'start'
}) => {
const normalizedBgColor = useMemo(
() => new Color(backgroundColor),
Expand Down Expand Up @@ -111,7 +138,7 @@ export const Badge: FC<BadgeProps> = ({
}
}, [position, size]);

// Calculate dynamic badge dimensions based on text length
// Calculate dynamic badge dimensions based on text length and icon
const badgeDimensions = useMemo(() => {
const baseWidth = 0.5;
const baseHeight = 0.5;
Expand All @@ -120,10 +147,16 @@ export const Badge: FC<BadgeProps> = ({

// Estimate text width based on character count
const charCount = label.length;
const estimatedWidth = Math.max(
let estimatedWidth = Math.max(
minWidth,
Math.min(charCount * 0.15 + padding, 2.0 + padding)
Math.min(charCount * CHAR_WIDTH_ESTIMATE + padding, 2.0 + padding)
); // Add padding to width

// Add icon width if icon is present
if (icon) {
estimatedWidth += iconSize;
}

const estimatedHeight = Math.max(
minHeight,
Math.min(charCount * 0.05 + padding * 0.5, 0.8 + padding * 0.5)
Expand All @@ -133,7 +166,7 @@ export const Badge: FC<BadgeProps> = ({
width: estimatedWidth,
height: estimatedHeight
};
}, [label, padding]);
}, [label, padding, icon, iconSize]);

const { scale, badgeOpacity } = useSpring({
from: {
Expand All @@ -150,6 +183,48 @@ export const Badge: FC<BadgeProps> = ({
}
});

// Calculate content layout positions for icon and text
const contentLayout = useMemo(() => {
if (!icon) {
return {
textX: 0,
textY: 0,
iconX: 0,
iconY: 0
};
}

// If custom position is provided as an array
if (Array.isArray(iconPosition)) {
return {
iconX: iconPosition[0],
iconY: iconPosition[1],
textX: 0,
textY: 0
};
}

const estimatedTextWidth = label.length * CHAR_WIDTH_ESTIMATE;
const totalContentWidth = iconSize + estimatedTextWidth;
const startX = -totalContentWidth / 2;

if (iconPosition === 'start') {
return {
iconX: startX + iconSize - 0.5 / 2,
iconY: 0,
textX: startX + iconSize + estimatedTextWidth / 2,
textY: 0
};
} else {
return {
textX: startX + estimatedTextWidth / 2,
textY: 0,
iconX: startX + estimatedTextWidth + ICON_TEXT_GAP + iconSize / 2,
iconY: 0
};
}
}, [icon, iconSize, iconPosition, label.length]);

return (
<Billboard position={badgePosition}>
<a.group scale={scale as any} renderOrder={2}>
Expand Down Expand Up @@ -179,8 +254,20 @@ export const Badge: FC<BadgeProps> = ({
material-transparent={true}
/>
</a.mesh>
{/* Icon */}
{icon && (
<Image
url={icon}
position={[contentLayout.iconX, contentLayout.iconY, 1.1]}
scale={[iconSize, iconSize]}
transparent
material-depthTest={false}
material-depthWrite={false}
/>
)}
{/* Text */}
<Text
position={[0, 0, 1.1]}
position={[contentLayout.textX, contentLayout.textY, 1.1]}
fontSize={0.3}
color={normalizedTextColor}
anchorX="center"
Expand Down
82 changes: 82 additions & 0 deletions stories/demos/Badge.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import React from 'react';
import { GraphCanvas } from '../../src';
import { Badge, Sphere } from '../../src/symbols';
import { simpleEdges, simpleNodes } from '../assets/demo';
import fireSvg from '../assets/fire.svg';
import userSvg from '../assets/user.svg';

export default {
title: 'Demos/Badge',
Expand Down Expand Up @@ -75,3 +77,83 @@ export const DifferentSizes = () => (
)}
/>
);

export const WithIcon = () => (
<GraphCanvas
nodes={simpleNodes}
edges={simpleEdges}
cameraMode="rotate"
renderNode={({ node, ...rest }) => (
<group>
<Sphere {...rest} node={node} />
<Badge
{...rest}
node={node}
label="6"
backgroundColor="#6366f1"
textColor="#ffffff"
icon={userSvg}
iconSize={0.35}
padding={0.5}
radius={0.15}
iconPosition="start"
position={[0, -10, 0]}
/>
</group>
)}
/>
);

export const WithIconEnd = () => (
<GraphCanvas
nodes={simpleNodes}
edges={simpleEdges}
cameraMode="rotate"
renderNode={({ node, ...rest }) => (
<group>
<Sphere {...rest} node={node} />
<Badge
{...rest}
node={node}
label="Team"
backgroundColor="#10b981"
textColor="#ffffff"
icon={userSvg}
iconSize={0.3}
iconPosition="end"
padding={0.5}
strokeColor="#059669"
strokeWidth={0.1}
radius={0.15}
position="top-left"
/>
</group>
)}
/>
);

export const WithCustomIconPosition = () => (
<GraphCanvas
nodes={simpleNodes}
edges={simpleEdges}
cameraMode="rotate"
renderNode={({ node, ...rest }) => (
<group>
<Sphere {...rest} node={node} />
<Badge
{...rest}
node={node}
label="Custom"
backgroundColor="#f59e0b"
textColor="#ffffff"
icon={fireSvg}
iconSize={0.6}
iconPosition={[0.7, 0.3]}
strokeWidth={0.2}
radius={0.15}
position="bottom-right"
/>
</group>
)}
/>
);
Loading