Loading...
G6 provides a series of built-in nodes, including circle (Circle Node), diamond (Diamond Node), donut (Donut Node), ellipse (Ellipse Node), hexagon (Hexagon Node), html (HTML Node), image (Image Node), rect (Rectangle Node), star (Star Node), and triangle (Triangle Node). These built-in nodes can meet most basic scenario requirements.
However, in actual projects, you may encounter needs that these basic nodes cannot satisfy. In such cases, you need to create custom nodes. Don't worry, this is simpler than you might think!
There are mainly two approaches to creating custom nodes:
This is the most commonly used approach, where you can choose to inherit from one of the following types:
BaseNode
- The most basic node class, providing core node functionalityCircle
- Circle nodeRect
- Rectangle nodeEllipse
- Ellipse nodeDiamond
- Diamond nodeTriangle
- Triangle nodeStar
- Star nodeImage
- Image nodeDonut
- Donut nodeHexagon
- Hexagon nodeWhy choose this approach?
If you choose to inherit from existing node types (recommended), you can jump directly to Create Your First Custom Node in Three Steps to start practicing. Most users will choose this approach!
If existing node types don't meet your requirements, you can create nodes from scratch based on G's underlying graphics system.
Why choose this approach?
Custom nodes built from scratch require handling all details yourself, including graphics rendering, event response, state changes, etc., with higher development difficulty. You can refer directly to the source code for implementation.
Let's start with a simple example - creating a rectangle node with main and subtitle:
import { Graph, register, Rect, ExtensionCategory } from '@antv/g6';// Step 1: Create custom node classclass DualLabelNode extends Rect {// Subtitle stylegetSubtitleStyle(attributes) {return {x: 0,y: 45, // Place below the main titletext: attributes.subtitle || '',fontSize: 12,fill: '#666',textAlign: 'center',textBaseline: 'middle',};}// Draw subtitledrawSubtitleShape(attributes, container) {const subtitleStyle = this.getSubtitleStyle(attributes);this.upsert('subtitle', 'text', subtitleStyle, container);}// Render methodrender(attributes = this.parsedAttributes, container) {// 1. Render basic rectangle and main titlesuper.render(attributes, container);// 2. Add subtitlethis.drawSubtitleShape(attributes, container);}}// Step 2: Register custom noderegister(ExtensionCategory.NODE, 'dual-label-node', DualLabelNode);// Step 3: Use custom nodeconst graph = new Graph({container: 'container',height: 200,data: {nodes: [{id: 'node1',style: { x: 100, y: 100 },data: {title: 'Node A', // Main titlesubtitle: 'Your First Custom Node', // Subtitle},},],},node: {type: 'dual-label-node',style: {fill: '#7FFFD4',stroke: '#5CACEE',lineWidth: 2,radius: 5,// Main title stylelabelText: (d) => d.data.title,labelFill: '#222',labelFontSize: 14,labelFontWeight: 500,// Subtitlesubtitle: (d) => d.data.subtitle,},},});graph.render();
Inherit from G6's Rect
(rectangle node) and add a subtitle:
import { Rect, register, Graph, ExtensionCategory } from '@antv/g6';// Create custom node, inheriting from Rectclass DualLabelNode extends Rect {// Subtitle stylegetSubtitleStyle(attributes) {return {x: 0,y: 45, // Place below the main titletext: attributes.subtitle || '',fontSize: 12,fill: '#666',textAlign: 'center',textBaseline: 'middle',};}// Draw subtitledrawSubtitleShape(attributes, container) {const subtitleStyle = this.getSubtitleStyle(attributes);this.upsert('subtitle', 'text', subtitleStyle, container);}// Render methodrender(attributes = this.parsedAttributes, container) {// 1. Render basic rectangle and main titlesuper.render(attributes, container);// 2. Add subtitlethis.drawSubtitleShape(attributes, container);}}
Use the register
method to register the node type so that G6 can recognize your custom node:
register(ExtensionCategory.NODE, 'dual-label-node', DualLabelNode);
The register
method requires three parameters:
ExtensionCategory.NODE
indicates this is a node typedual-label-node
is the name we give to this custom node, which will be used in configuration laterDualLabelNode
is the node class we just createdUse the custom node in graph configuration:
const graph = new Graph({data: {nodes: [{id: 'node1',style: { x: 100, y: 100 },data: {title: 'Node A', // Main titlesubtitle: 'Your First Custom Node', // Subtitle},},],},node: {type: 'dual-label-node',style: {fill: '#7FFFD4',stroke: '#5CACEE',lineWidth: 2,radius: 8,// Main title stylelabelText: (d) => d.data.title,labelFill: '#222',labelFontSize: 14,labelFontWeight: 500,// Subtitlesubtitle: (d) => d.data.subtitle,},},});graph.render();
🎉 Congratulations! You have created your first custom node. It looks simple, but this process contains the core concept of custom nodes: inherit from a basic node type, then override the render
method to add custom content.
Before creating complex custom nodes, understanding how data flows into custom nodes is very important. G6 provides multiple ways to access data for custom nodes:
attributes
Parameter (Recommended)The first parameter attributes
of the render
method contains processed style attributes, including data-driven styles:
class CustomNode extends Rect {render(attributes, container) {// attributes contains all style attributes, including data-driven stylesconsole.log('All properties of current node:', attributes);// If customData: (d) => d.data.someValue is defined in style// Then you can access it through attributes.customDataconst customValue = attributes.customData;super.render(attributes, container);}}
this.context.graph
to Access Raw DataWhen you need to access the node's raw data, you can get it through the graph instance:
class CustomNode extends Rect {// Convenient data access methodget nodeData() {return this.context.graph.getNodeData(this.id);}get data() {return this.nodeData.data || {};}render(attributes, container) {// Get complete node dataconst nodeData = this.nodeData;console.log('Complete node data:', nodeData);// Get business data from data fieldconst businessData = this.data;console.log('Business data:', businessData);super.render(attributes, container);}}
Let's understand how data flows from graph data to custom nodes through a specific example:
import { Graph, register, Rect, ExtensionCategory } from '@antv/g6';class DataFlowNode extends Rect {// Method 2: Get raw data through graphget nodeData() {return this.context.graph.getNodeData(this.id);}get data() {return this.nodeData.data || {};}render(attributes, container) {// Method 1: Get processed styles from attributesconsole.log('Get from attributes:', {iconUrl: attributes.iconUrl,userName: attributes.userName,});// Method 2: Get from raw dataconsole.log('Get from raw data:', {icon: this.data.icon,name: this.data.name,role: this.data.role,});// Render basic rectanglesuper.render(attributes, container);// Use data to render custom contentif (attributes.iconUrl) {this.upsert('icon','image',{x: -25,y: -12,width: 20,height: 20,src: attributes.iconUrl,},container,);}if (attributes.userName) {this.upsert('username','text',{x: 10,y: 0,text: attributes.userName,fontSize: 10,fill: '#666',textAlign: 'center',textBaseline: 'middle',},container,);}}}register(ExtensionCategory.NODE, 'data-flow-node', DataFlowNode);const graph = new Graph({container: 'container',height: 200,data: {nodes: [{id: 'user1',style: { x: 100, y: 100 },// This is the node's business datadata: {name: 'Zhang San',role: 'Developer',icon: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix',},},],},node: {type: 'data-flow-node',style: {size: [80, 40],fill: '#f0f9ff',stroke: '#0ea5e9',lineWidth: 1,radius: 4,// Map data from data field to style attributesiconUrl: (d) => d.data.icon, // This becomes attributes.iconUrluserName: (d) => d.data.name, // This becomes attributes.userName// Main title uses role informationlabelText: (d) => d.data.role,labelFontSize: 12,labelFill: '#0369a1',},},});graph.render();
data.nodes[].data
node.style
to map data to style attributesattributes
or this.context.graph
Let's gradually increase the complexity and functionality of nodes through practical examples.
This example shows how to create a user card node containing avatar, name, and status badge:
import { Graph, register, Rect, ExtensionCategory } from '@antv/g6';class UserCardNode extends Rect {get nodeData() {return this.context.graph.getNodeData(this.id);}get data() {return this.nodeData.data || {};}// Avatar stylegetAvatarStyle(attributes) {const [width, height] = this.getSize(attributes);return {x: -width / 2 + 20,y: -height / 2 + 15,width: 30,height: 30,src: attributes.avatarUrl || '',radius: 15, // Circular avatar};}drawAvatarShape(attributes, container) {if (!attributes.avatarUrl) return;const avatarStyle = this.getAvatarStyle(attributes);this.upsert('avatar', 'image', avatarStyle, container);}// Status badge stylegetBadgeStyle(attributes) {const [width, height] = this.getSize(attributes);const status = this.data.status || 'offline';const colorMap = {online: '#52c41a',busy: '#faad14',offline: '#8c8c8c',};return {x: width / 2 - 8,y: -height / 2 + 8,r: 4,fill: colorMap[status],stroke: '#fff',lineWidth: 2,};}drawBadgeShape(attributes, container) {const badgeStyle = this.getBadgeStyle(attributes);this.upsert('badge', 'circle', badgeStyle, container);}// Username stylegetUsernameStyle(attributes) {const [width, height] = this.getSize(attributes);return {x: -width / 2 + 55,y: -height / 2 + 20,text: attributes.username || '',fontSize: 14,fill: '#262626',fontWeight: 'bold',textAlign: 'left',textBaseline: 'middle',};}drawUsernameShape(attributes, container) {if (!attributes.username) return;const usernameStyle = this.getUsernameStyle(attributes);this.upsert('username', 'text', usernameStyle, container);}// Role label stylegetRoleStyle(attributes) {const [width, height] = this.getSize(attributes);return {x: -width / 2 + 55,y: -height / 2 + 35,text: attributes.userRole || '',fontSize: 11,fill: '#8c8c8c',textAlign: 'left',textBaseline: 'middle',};}drawRoleShape(attributes, container) {if (!attributes.userRole) return;const roleStyle = this.getRoleStyle(attributes);this.upsert('role', 'text', roleStyle, container);}render(attributes, container) {// Render basic rectanglesuper.render(attributes, container);// Add various componentsthis.drawAvatarShape(attributes, container);this.drawBadgeShape(attributes, container);this.drawUsernameShape(attributes, container);this.drawRoleShape(attributes, container);}}register(ExtensionCategory.NODE, 'user-card-node', UserCardNode);const graph = new Graph({container: 'container',height: 200,data: {nodes: [{id: 'user1',style: { x: 100, y: 100 },data: {name: 'Zhang Xiaoming',role: 'Frontend Engineer',status: 'online',avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Zhang',},},],},node: {type: 'user-card-node',style: {size: [140, 50],fill: '#ffffff',stroke: '#d9d9d9',lineWidth: 1,radius: 6,// Data mappingavatarUrl: (d) => d.data.avatar,username: (d) => d.data.name,userRole: (d) => d.data.role,},},});graph.render();
Add a blue button to the node that triggers events (prints logs or executes callbacks) when clicked.
import { Graph, register, Rect, ExtensionCategory } from '@antv/g6';class ClickableNode extends Rect {getButtonStyle(attributes) {return {x: 40,y: -10,width: 20,height: 20,radius: 10,fill: '#1890ff',cursor: 'pointer', // Mouse pointer becomes hand};}drawButtonShape(attributes, container) {const btnStyle = this.getButtonStyle(attributes, container);const btn = this.upsert('button', 'rect', btnStyle, container);// Add click event to buttonif (!btn.__clickBound) {btn.addEventListener('click', (e) => {// Prevent event bubbling to avoid triggering node click evente.stopPropagation();// Execute business logicconsole.log('Button clicked on node:', this.id);// If there's a callback function in data, call itif (typeof attributes.onButtonClick === 'function') {attributes.onButtonClick(this.id, this.data);}});btn.__clickBound = true; // Mark as bound to avoid duplicate binding}}render(attributes, container) {super.render(attributes, container);// Add a buttonthis.drawButtonShape(attributes, container);}}register(ExtensionCategory.NODE, 'clickable-node', ClickableNode);const graph = new Graph({container: 'container',height: 200,data: {nodes: [{id: 'node1',style: { x: 100, y: 100 },},],},node: {type: 'clickable-node', // Specify using our custom nodestyle: {size: [60, 30],fill: '#7FFFD4',stroke: '#5CACEE',lineWidth: 2,radius: 5,onButtonClick: (id, data) => {},},},});graph.render();
Common interactions require nodes and edges to provide feedback through style changes, such as when the mouse moves over a node, clicking to select nodes/edges, or activating interactions on edges through interaction. All these require changing the styles of nodes and edges. There are two ways to achieve this effect:
data.states
and handle state changes in the custom node class;We recommend users use the second approach to implement node state adjustments, which can be achieved through the following steps:
graph.setElementState()
method.Based on rect, extend a hole shape with default white fill color that turns orange when clicked. The sample code to achieve this effect is as follows:
import { Rect, register, Graph, ExtensionCategory } from '@antv/g6';// 1. Define node classclass SelectableNode extends Rect {getHoleStyle(attributes) {return {x: 20,y: -10,radius: 10,width: 20,height: 20,fill: attributes.holeFill,};}drawHoleShape(attributes, container) {const holeStyle = this.getHoleStyle(attributes, container);this.upsert('hole', 'rect', holeStyle, container);}render(attributes, container) {super.render(attributes, container);this.drawHoleShape(attributes, container);}}// 2. Register noderegister(ExtensionCategory.NODE, 'selectable-node', SelectableNode, true);// 3. Create graph instanceconst graph = new Graph({container: 'container',height: 200,data: {nodes: [{ id: 'node-1', style: { x: 100, y: 100 } }],},node: {type: 'selectable-node',style: {size: [120, 60],radius: 6,fill: '#7FFFD4',stroke: '#5CACEE',lineWidth: 2,holeFill: '#fff',},state: {// Mouse selected stateselected: {holeFill: 'orange',},},},});// 4. Add node interactiongraph.on('node:click', (evt) => {const nodeId = evt.target.id;graph.setElementState(nodeId, ['selected']);});graph.render();