跳到主要内容

节点

节点数据结构

基础接口

interface NodeData {
id: string; // 唯一标识
shape: string; // 形状类型
position: Point; // 位置 { x, y }
size: Size; // 尺寸 { width, height }
visible?: boolean; // 是否可见(默认 true)
selectable?: boolean; // 是否可选中(默认 true),对API操作不限制
draggable?: boolean; // 是否可拖拽(默认 true),对API操作不限制
resizable?: boolean; // 是否可调节尺寸(默认 true),对API操作不限制
rotatable?: boolean; // 是否可旋转(默认 true),对API操作不限制
rotation?: number; // 旋转角度(弧度)
zIndex?: number; // 层级(默认 0)
selected?: boolean; // 是否选中
groupId?: string; // 所属组ID
groupPath?: string[]; // 组路径(支持嵌套)
ports?: PortData[]; // 锚点列表
data?: NodeCustomData; // 自定义数据
}

interface NodeCustomData {
label?: string; // 节点标签文本
showPorts?: boolean; // 是否显示锚点(默认 true)
style?: { // 样式配置
fill?: string; // 填充色
stroke?: string; // 描边色
lineWidth?: number; // 描边宽度
radius?: number; // 圆角半径
alpha?: number; // 整体透明度
label?: { // 标签样式
color?: string; // 文字颜色
fontSize?: number; // 字体大小
position?: 'top' | 'center' | 'bottom'; // 标签位置
background?: string; // 背景色
backgroundAlpha?: number; // 背景透明度
};
text?: {
color?: string
[key: string]: any; // 其他文本特定样式
};
lineDash?: number[]; // 虚线段
flow?: {
enabled?: boolean; // 边框流动是否启用
speed?: number; // 流速(线段沿用lineDash属性)
};
[key: string]: any; // 其他形状特定样式
};
blink?: { // 闪烁动画配置
enabled: boolean; // 是否启用
period: number; // 周期(毫秒)
min: number; // 最小透明度
max: number; // 最大透明度
};
custom?: Record<string, any>; // 完全自定义的业务数据
[key: string]: any; // 其他扩展字段
}

interface Point {
x: number;
y: number;
}

interface Size {
width: number;
height: number;
}

属性说明

NodeData 核心属性

属性名类型默认值必选描述
idstring-节点唯一标识
shapestring-形状类型(rect、circle、diamond 等)
positionPoint-节点位置 { x, y }
sizeSize-节点尺寸 { width, height }
visiblebooleantrue-是否可见,为 false 时不渲染也不可交互
selectablebooleantrue-是否允许被选中,为 false 时点击/框选不会改变选中态 对API操作不限制
draggablebooleantrue-是否允许被拖拽,为 false 时不参与拖动 对API操作不限制
resizablebooleantrue-是否允许被调节尺寸,为 false 时不可调节 对API操作不限制
rotatablebooleantrue-是否允许被旋转,为 false 时不可旋转 对API操作不限制
rotationnumber0-旋转角度(角度制,非弧度)
zIndexnumber0-层级,数值越大越靠前
selectedbooleanfalse-是否选中
groupIdstring--所属组 ID(最内层组)
groupPathstring[]--完整组路径(从外到内),支持嵌套分组
portsPortData[]--锚点列表
dataNodeCustomData--自定义数据(样式、标签、动画等)

NodeCustomData 扩展属性

属性名类型默认值必选描述
labelstring--节点标签文本
showPortsbooleantrue-是否显示锚点(隐藏后仍可连线)
styleobject--样式配置对象
blinkobject--闪烁动画配置
customobject--完全自定义的业务数据

style 样式属性(常用)

属性名类型默认值描述
fillstring-填充色(如 '#3b82f6')
strokestring-描边色
lineWidthnumber1描边宽度
radiusnumber0圆角半径(仅矩形)
alphanumber1整体透明度(0-1)
lineDashnumber[]-虚线样式(如 [5, 5])
labelLabelStyle-标签样式配置(见下表)
textTextStyle-文本样式配置(见下表)
flowFlowStyle-流动动画配置(见下表)

label 标签样式属性

属性名类型默认值描述
colorstring'#000000'文字颜色
fontSizenumber12字体大小(像素)
position'top | 'right' | 'bottom' | 'left' | 'center''center'标签显示位置
backgroundstring-标签背景色
backgroundAlphanumber1背景透明度(0-1)

text 文本样式属性

属性名类型默认值描述
colorstring'#000000'文本颜色
fontSizenumber14字体大小(像素)
fontFamilystring'sans-serif'字体族
fontWeightstring'normal'字体粗细(normal、bold 等)
textAlignstring'left'水平对齐(left、center、right)
textBaselinestring'alphabetic'垂直对齐(top、middle、bottom 等)

flow 流动动画配置

属性名类型默认值必选描述
enabledbooleanfalse-是否启用边框流动效果
speednumber160-流动速度px/s(线段沿用 lineDash 属性)
属性名类型默认值必选描述
enabledboolean-是否启用闪烁
periodnumber-闪烁周期(毫秒)
minnumber-最小透明度(0-1)
maxnumber-最大透明度(0-1)

创建节点

基础节点

const node = {
id: 'node-1',
shape: 'rect',
position: { x: 100, y: 100 },
size: { width: 120, height: 80 }
};

engine.graph.addNode(node);

完整配置

const node = {
"id": "n1",
"shape": "rect",
"position": { "x": 98, "y": 106 },
"size": { "width": 120, "height": 80 },
"selectable": true,
"draggable": true,
"resizable": true,
"rotatable": true,
"selected": false,
"data": {
"label": "圆角矩形-中心",
"style": {
"label": {
"color": "#111827",
"fontSize": 12,
"position": "center",
"background": "#ffffff",
"backgroundAlpha": 1
},
"fill": "#ffffff",
"stroke": "#111827",
"lineWidth": 2,
"radius": 8,
"lineDash": [6, 3]
},
"blink": {
"enabled": false,
"period": 800,
"min": 0.3,
"max": 1
},
"custom": {
"code": "n1",
"name": "圆角矩形",
"local": "位于画布左上角"
}
},
"ports": [
{
"id": "t",
"offset": { "x": 60, "y": 0 }
},
{
"id": "r",
"offset": { "x": 120, "y": 40 }
},
{
"id": "b",
"offset": { "x": 60, "y": 80 }
},
{
"id": "l",
"offset": { "x": 0, "y": 40 }
}
]
};

engine.graph.addNode(node);

内置形状

Canvas 引擎内置了丰富的图形类型,可直接使用。通过设置节点的 shape 属性来指定图形类型。

图形总览

警告

除了基础的图形外,image、svg-path、svg-image为扩展图形,它们更似一类容器,根据传入的不同内容呈现。

图形类型shape 值描述特殊属性
矩形rect基础矩形,支持圆角cornerRadius
圆形circle标准圆形-
椭圆ellipse椭圆形-
菱形diamond四边相等的菱形-
三角形triangle等腰三角形-
直角三角形right-triangle直角三角形-
五边形pentagon正五边形-
六边形hexagon正六边形-
八边形octagon正八边形-
星形star多角星形points(角数)
直线/折线line支持多点折线points, borderRadius
圆柱cylinder3D 圆柱效果-
梯形trapezoid上窄下宽梯形-
平行四边形parallelogram平行四边形-
扇形sector扇形/圆弧startAngle, endAngle
半圆semicircle半圆形-
云朵cloud云朵形状-
右箭头right-arrow单向右箭头-
双向箭头double-arrow双向箭头-
十字cross十字形-
直角cornerL形直角-
文本text纯文本节点text, fontSize, fontFamily 等
图片image位图图片(URL/base64)src, fit(fill/contain/cover)
SVG路径svg-pathSVG path 矢量图形path/paths, viewBox, fit
SVG图像svg-image完整 SVG XMLxml, viewBox, fit

使用示例

rect - 矩形

{
"id": "rect-1",
"shape": "rect",
"position": { "x": 100, "y": 100 },
"size": { "width": 120," height": 80 },
"data": {
"label": "矩形",
"fill": "#3b82f6",
"stroke": "#1e40af",
"strokeWidth": 2,
"cornerRadius": 4 // 圆角
}
}

circle - 圆形

{
"id": "circle-1",
"shape": "circle",
"position": { "x": 200, "y": 200 },
"size": { "width": 80, "height": 80 }, // 使用 width 作为直径
"data": {
"label": "圆形",
"fill": "#10b981",
"stroke": "#059669"
}
}

ellipse - 椭圆

{
"id": "ellipse-1",
"shape": "ellipse",
"position": { "x": 300, "y": 300 },
"size": { "width": 120," height": 60 },
"data": {
"label": "椭圆",
"fill": "#f59e0b",
"stroke": "#d97706"
}
}

diamond - 菱形

{
"id": "diamond-1",
"shape": "diamond",
"position": { "x": 400, "y": 400 },
"size": { "width": 100," height": 100 },
"data": {
"label": "菱形",
"fill": "#ec4899",
"stroke": "#db2777"
}
}

triangle - 三角形

{
"id": "triangle-1",
"shape": "triangle",
"position": { "x": 500, "y": 500 },
"size": { "width": 80, "height": 80 },
"data": {
"label": "三角形",
"fill": "#8b5cf6",
"stroke": "#7c3aed"
}
}

hexagon - 六边形

{
"id": "hexagon-1",
"shape": "hexagon",
"position": { "x": 600, "y": 600 },
"size": { "width": 100," height": 100 },
"data": {
"label": "六边形",
"fill": "#06b6d4",
"stroke": "#0891b2"
}
}

pentagon - 五边形

{
"id": "pentagon-1",
"shape": "pentagon",
"position": { "x": 650, "y": 650 },
"size": { "width": 100," height": 100 },
"data": {
"label": "五边形",
"fill": "#a855f7",
"stroke": "#9333ea"
}
}

octagon - 八边形

{
"id": "octagon-1",
"shape": "octagon",
"position": { "x": 750, "y": 750 },
"size": { "width": 100," height": 100 },
"data": {
"label": "八边形",
"fill": "#ef4444",
"stroke": "#dc2626"
}
}

star - 星形

{
"id": "star-1",
"shape": "star",
"position": { "x": 700, "y": 700 },
"size": { "width": 80, "height": 80 },
"data": {
"label": "星形",
"fill": "#eab308",
"stroke": "#ca8a04",
"points": 5 // 星形角数
}
}

line - 直线/折线

{
"id": "line-1",
"shape": "line",
"position": { "x": 100, "y": 100 },
"size": { "width": 200, "height": 100 },
"data": {
"line": {
// 折点坐标(相对于 position)
"points": [
{ "x": 0, "y": 0 },
{ "x": 100, "y": 50 },
{ "x": 200, "y": 100 }
],
"borderRadius": 10 // 折点圆角半径(可选)
},
"style": {
"stroke": "#3b82f6",
"lineWidth": 3,
"lineCap": "round", // 线端样式:butt | round | square
"lineJoin": "round", // 连接样式:miter | round | bevel
"lineDash": [5, 5] // 虚线样式
}
}
}

cylinder - 圆柱

{
"id": "cylinder-1",
"shape": "cylinder",
"position": { "x": 300, "y": 300 },
"size": { "width": 100, "height": 150 },
"data": {
"label": "圆柱",
"fill": "#10b981",
"stroke": "#059669"
}
}

trapezoid - 梯形

{
"id": "trapezoid-1",
"shape": "trapezoid",
"position": { "x": 400, "y": 400 },
"size": { "width": 120, "height": 80 },
"data": {
"label": "梯形",
"fill": "#f59e0b",
"stroke": "#d97706"
}
}

sector - 扇形

{
"id": "sector-1",
"shape": "sector",
"position": { "x": 500, "y": 500 },
"size": { "width": 100, "height": 100 },
"data": {
"label": "扇形",
"fill": "#ec4899",
"stroke": "#db2777",
"startAngle": 0, // 起始角度(度数)
"endAngle": 90 // 结束角度(度数)
}
}

cloud - 云朵

{
"id": "cloud-1",
"shape": "cloud",
"position": { "x": 600, "y": 600 },
"size": { "width": 120, "height": 80 },
"data": {
"label": "云朵",
"fill": "#dbeafe",
"stroke": "#3b82f6"
}
}

parallelogram - 平行四边形

{
"id": "parallelogram-1",
"shape": "parallelogram",
"position": { "x": 700, "y": 700 },
"size": { "width": 120, "height": 80 },
"data": {
"label": "平行四边形",
"fill": "#a78bfa",
"stroke": "#7c3aed"
}
}

right-arrow - 右箭头

{
"id": "right-arrow-1",
"shape": "right-arrow",
"position": { "x": 100, "y": 200 },
"size": { "width": 150, "height": 60 },
"data": {
"label": "右箭头",
"fill": "#60a5fa",
"stroke": "#2563eb"
}
}

double-arrow - 双向箭头

{
"id": "double-arrow-1",
"shape": "double-arrow",
"position": { "x": 300, "y": 200 },
"size": { "width": 150, "height": 60 },
"data": {
"label": "双向箭头",
"fill": "#34d399",
"stroke": "#10b981"
}
}

semicircle - 半圆

{
"id": "semicircle-1",
"shape": "semicircle",
"position": { "x": 500, "y": 200 },
"size": { "width": 100, "height": 50 },
"data": {
"label": "半圆",
"fill": "#fbbf24",
"stroke": "#f59e0b"
}
}

right-triangle - 直角三角形

{
"id": "right-triangle-1",
"shape": "right-triangle",
"position": { "x": 700, "y": 200 },
"size": { "width": 80, "height": 80 },
"data": {
"label": "直角三角形",
"fill": "#f87171",
"stroke": "#ef4444"
}
}

cross - 十字

{
"id": "cross-1",
"shape": "cross",
"position": { "x": 100, "y": 400 },
"size": { "width": 80, "height": 80 },
"data": {
"label": "十字",
"fill": "#4ade80",
"stroke": "#22c55e"
}
}

corner - 直角

{
"id": "corner-1",
"shape": "corner",
"position": { "x": 300, "y": 400 },
"size": { "width": 80, "height": 80 },
"data": {
"label": "直角",
"fill": "#c084fc",
"stroke": "#a855f7"
}
}

text - 文本

{
"id": "text-1",
"shape": "text",
"position": { "x": 900, "y": 900 },
"size": { "width": 200, "height": 50 },
"data": {
"text": "文本节点",
"fontSize": 16,
"fontFamily": "sans-serif",
"fontWeight": "normal",
"textAlign": "center",
"textBaseline": "middle",
"color": "#000000"
}
}

image - 图片

支持显示位图图片,可以使用 URL 或 base64 格式,支持多种适配模式。

基本用法

{
"id": "image-1",
"shape": "image",
"position": { "x": 800, "y": 800 },
"size": { "width": 200, "height": 150 },
"data": {
"image": {
"src": "https://example.com/image.png", // 图片源(URL 或 base64)
"fit": "fill" // 适配模式:fill | contain | cover
},
"style": {
"stroke": "#e5e7eb", // 边框颜色
"lineWidth": 1, // 边框宽度
"borderRadius": 4 // 圆角半径
}
}
}

使用 URL

{
"id": "image-url",
"shape": "image",
"position": { "x": 100, "y": 100 },
"size": { "width": 200, "height": 150 },
"data": {
"image": {
"src": "https://docs.cq-tct.com/desp/images/logo_002.png",
"fit": "fill"
}
}
}

使用 base64

{
"id": "image-base64",
"shape": "image",
"position": { "x": 300, "y": 100 },
"size": { "width": 150, "height": 150 },
"data": {
"image": {
"src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA...",
"fit": "cover"
},
"style": {
"stroke": "#3b82f6",
"lineWidth": 2,
"borderRadius": 8
}
}
}

配置说明

属性类型默认值描述
srcstring-图片源,支持 URL 或 base64 格式
fitstring'fill'适配模式,见下表

适配模式(fit)

模式描述效果
fill拉伸填充完全填充节点区域,不保持宽高比,可能变形
contain完整显示完整显示图片,保持宽高比,可能留白
cover覆盖填充覆盖整个节点区域,保持宽高比,可能裁剪

样式配置

{
"data": {
"image": {
"src": "https://example.com/photo.jpg",
"fit": "cover"
},
"style": {
"stroke": "#10b981", // 边框颜色
"lineWidth": 3, // 边框宽度
"borderRadius": 12, // 圆角半径
"alpha": 0.9 // 整体透明度
}
}
}

从剪贴板粘贴图片

使用 ClipboardPlugin 可以直接从系统剪贴板粘贴图片:

import { ClipboardPlugin } from '@fnt-agilejs/core';

// 配置图片粘贴参数
engine.plugins.use(new ClipboardPlugin({
maxWidth: 800, // 图片最大宽度
maxHeight: 800, // 图片最大高度
maxSize: 1024 * 1024, // 最大文件大小(1MB)
quality: 0.85, // 压缩质量
defaultSize: { width: 200, height: 150 } // 默认节点尺寸
}));

// 使用方法:
// 1. 从截图工具或网页复制图片
// 2. 点击画布使其获得焦点
// 3. 按 Cmd/Ctrl+V 粘贴
// 自动创建 image 类型的节点,使用 base64 格式存储

图片缓存

ImageRenderer 内置图片缓存机制,相同 src 的图片只会加载一次:

// 多个节点使用相同图片,只加载一次
const nodes = [
{
id: "img1",
shape: "image",
data: { image: { src: "https://example.com/logo.png" } }
},
{
id: "img2",
shape: "image",
data: { image: { src: "https://example.com/logo.png" } } // 复用缓存
}
];

加载状态处理

图片加载过程中会显示占位符,加载失败也会显示占位符:

// 图片加载中或失败时,会显示灰色占位符和图标
// 无需额外配置,自动处理

最佳实践

  1. 选择合适的 fit 模式

    • 图标、logo 使用 contain
    • 背景图、照片使用 cover
    • 需要完整显示且不介意变形时使用 fill
  2. 优化图片大小

    • 使用 ClipboardPlugin 粘贴时会自动压缩
    • 手动添加时建议预先压缩图片
    • 避免使用超大尺寸图片
  3. 使用 base64 vs URL

    • base64:适合小图片、截图,无需额外请求,但会增加 JSON 大小
    • URL:适合大图片、复用图片,需要网络请求,但 JSON 更小
  4. 处理跨域图片

    // 确保图片服务器支持 CORS
    // 或使用 base64 格式避免跨域问题

注意事项

  • 图片加载是异步的,大图片可能需要一些时间
  • base64 格式会增加 JSON 数据大小,建议压缩后再使用
  • URL 图片需要考虑网络延迟和跨域问题
  • 图片缓存基于 src 字符串,相同 URL 会复用缓存

svg-path - SVG 路径

使用 SVG Path 数据绘制矢量图形,支持单个路径或多个路径组合。

单个路径

{
"id": "svg-path-1",
"shape": "svg-path",
"position": { "x": 100, "y": 100 },
"size": { "width": 100, "height": 100 },
"data": {
"svg": {
// SVG path 数据
"path": "M10,30 A20,20 0,0,1 50,30 A20,20 0,0,1 90,30 Q90,60 50,90 Q10,60 10,30 z",
// viewBox 定义路径坐标系(默认 0,0,100,100)
"viewBox": { "x": 0, "y": 0, "width": 100, "height": 100 },
// 适配模式:stretch | contain(默认) | cover
"fit": "stretch"
},
"style": {
"fill": "#ef4444", // 填充色
"stroke": "#991b1b", // 描边色
"lineWidth": 2, // 描边宽度
"alpha": 1 // 整体透明度
}
}
}

多路径组合(支持分别设置样式):

{
"id": "svg-path-2",
"shape": "svg-path",
"position": { "x": 200, "y": 200 },
"size": { "width": 120, "height": 120 },
"data": {
"svg": {
// 多个路径,每个可独立设置样式
"paths": [
{
"d": "M20,20 L80,20 L50,70 Z", // 路径数据
"fill": "#3b82f6", // 填充色
"fillAlpha": 1, // 填充透明度
"stroke": "#1e40af", // 描边色
"strokeWidth": 2, // 描边宽度
"strokeAlpha": 1, // 描边透明度
"lineCap": "round", // 线端样式
"lineJoin": "round", // 连接样式
"lineDash": [5, 5] // 虚线样式
},
{
"d": "M30,80 Q50,60 70,80",
"fill": "none",
"stroke": "#10b981",
"strokeWidth": 3
}
],
"viewBox": { "x": 0, "y": 0, "width": 100, "height": 100 },
"fit": "stretch"
},
"style": {
"alpha": 0.9 // 全局透明度
}
}
}

配置说明

  • path / paths: SVG path 数据字符串或路径数组
  • viewBox: 定义 SVG 坐标系统 { x, y, width, height }
  • fit: 适配模式
    • "contain": 完整显示,保持比例(默认)
    • "cover": 填充节点,保持比例
    • "stretch": 拉伸填充,不保持比例
  • 每个路径支持独立样式:fill, stroke, strokeWidth, fillAlpha, strokeAlpha, lineCap, lineJoin, lineDash, fillRule

常见图形示例

// 爱心
{
"shape": "svg-path",
"position": { "x": 100, "y": 100 },
"size": { "width": 100, "height": 100 },
"data": {
"svg": {
"path": "M50,30 A20,20 0,0,1 90,30 A20,20 0,0,1 90,70 L50,100 L10,70 A20,20 0,0,1 10,30 A20,20 0,0,1 50,30 z",
"viewBox": { "x": 0, "y": 0, "width": 100, "height": 100 }
},
"style": {
"fill": "red"
}
}
}

svg-image - SVG 图像

使用完整的 SVG XML 渲染复杂矢量图形,通过光栅化转换为位图显示。

基础用法

{
"id": "svg-image-1",
"shape": "svg-image",
"position": { "x": 100, "y": 100 },
"size": { "width": 150, "height": 150 },
"data": {
"svg": {
// 完整的 SVG XML 字符串
"xml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="40" fill="#3b82f6" />
<path d="M30,50 L45,65 L70,40" stroke="white" stroke-width="4" fill="none" />
</svg>`,
// viewBox(可选,用于 XML 片段)
"viewBox": { "x": 0, "y": 0, "width": 100, "height": 100 },
// 适配模式
"fit": "stretch"
},
"style": {
"alpha": 1 // 整体透明度
}
}
}

SVG 片段(自动包装):

如果 xml 不包含 <svg> 标签,引擎会自动添加包装:

{
"id": "svg-image-2",
"shape": "svg-image",
"position": { "x": 200, "y": 200 },
"size": { "width": 100, "height": 100 },
"data": {
"svg": {
// 仅提供 SVG 内容,不含根标签
"xml": `
<rect x="10" y="10" width="80" height="80" rx="10" fill="#10b981" />
<circle cx="50" cy="50" r="25" fill="white" opacity="0.5" />
`,
"viewBox": { "x": 0, "y": 0, "width": 100, "height": 100 },
"fit": "stretch"
}
}
}

复杂图标示例

{
"id": "icon-chart",
"shape": "svg-image",
"position": { "x": 300, "y": 300 },
"size": { "width": 80, "height": 80 },
"data": {
"svg": {
"xml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<rect x="3" y="12" width="4" height="9" fill="#3b82f6" />
<rect x="10" y="8" width="4" height="13" fill="#10b981" />
<rect x="17" y="4" width="4" height="17" fill="#f59e0b" />
</svg>`,
"fit": "stretch"
}
}
}

配置说明

  • xml: 完整的 SVG XML 字符串或 SVG 内容片段
    • 完整格式:包含 <svg> 根标签
    • 片段格式:仅包含 SVG 元素(如 <rect>, <circle> 等),引擎会自动添加 <svg> 包装
  • viewBox: 当使用片段格式时必需,定义 SVG 坐标系统
  • fit: 适配模式(同 svg-path)

性能说明

  • SVG 会被转换为位图缓存,避免每帧重新光栅化
  • 缓存基于 xml 内容和 viewBox 尺寸
  • 图像加载为异步,首次渲染可能延迟一帧

svg-path vs svg-image 选择

特性svg-pathsvg-image
适用场景简单路径图形复杂 SVG 图标
性能高(直接绘制)中等(光栅化缓存)
灵活性多路径独立样式完整 SVG 特性
渐变/滤镜❌ 不支持✅ 支持
文本❌ 不支持✅ 支持
路径操作✅ 支持 lineDash 等❌ 由 SVG 内部定义
推荐用途图标、Logo图表、复杂图形

节点操作

添加节点

// 单个添加
engine.graph.addNode(node);

// 批量添加
for (const n of [node1, node2, node3]) {
engine.graph.addNode(n);
}

获取节点

// 通过ID获取
const node = engine.graph.getNode('node-1');

// 获取所有节点
const nodes = engine.graph.getNodes();

// 获取选中的节点
const selected = engine.graph.getNodes().filter(n => n.selected);

更新节点

// 更新位置
const node = engine.graph.getNode('node-1');
if (node) {
node.position = { x: 200, y: 200 };
engine.graph.markDirty('structure');
}

// 更新尺寸
const node = engine.graph.getNode('node-1');
if (node) {
node.size = { width: 150, height: 100 };
engine.graph.markDirty('structure');
}

// 更新多个属性
const node = engine.graph.getNode('node-1');
if (node) {
node.position = { x: 200, y: 200 };
node.size = { width: 150, height: 100 };
node.rotation = Math.PI / 6;
node.data = { ...node.data, label: '新标签' };
engine.graph.markDirty('structure');
}

删除节点

// 删除单个节点
engine.graph.removeNode('node-1');

// 删除多个节点
for (const id of ['node-1', 'node-2']) {
engine.graph.removeNode(id);
}

// 删除所有节点和边
engine.graph.clear();

选中节点

// 选中单个节点
const node = engine.graph.getNode('node-1');
if (node) {
node.selected = true;
engine.graph.markDirty('style');
}

// 多选
for (const id of ['node-1', 'node-2']) {
const node = engine.graph.getNode(id);
if (node) node.selected = true;
}
engine.graph.markDirty('style');

// 取消选中
const node = engine.graph.getNode('node-1');
if (node) {
node.selected = false;
engine.graph.markDirty('style');
}

// 取消所有选中
for (const node of engine.graph.getNodes()) {
node.selected = false;
}
engine.graph.markDirty('style');

// 切换选中状态
const node = engine.graph.getNode('node-1');
if (node) {
node.selected = !node.selected;
engine.graph.markDirty('style');
}

节点属性

提示

列举部分属性,其他属性见属性说明,变更操作类似。

visible - 可见性

控制节点是否显示:

// 隐藏节点
const node = engine.graph.getNode('node-1');
if (node) {
node.visible = false;
engine.graph.markDirty('structure');
}

// 显示节点
const node = engine.graph.getNode('node-1');
if (node) {
node.visible = true;
engine.graph.markDirty('structure');
}

selectable - 可选中性

控制节点是否可被选中:

// 禁止选中
const node = engine.graph.getNode('node-1');
if (node) {
node.selectable = false;
engine.graph.markDirty('style');
}

// 允许选中
const node = engine.graph.getNode('node-1');
if (node) {
node.selectable = true;
engine.graph.markDirty('style');
}

draggable - 可拖拽性

控制节点是否可被拖拽:

// 禁止拖拽
const node = engine.graph.getNode('node-1');
if (node) {
node.draggable = false;
engine.graph.markDirty('style');
}

// 允许拖拽
const node = engine.graph.getNode('node-1');
if (node) {
node.draggable = true;
engine.graph.markDirty('style');
}

isContainer - 是否为容器节点

控制节点是否为容器节点,其子节点同时需添加 parentId 属性指向容器节点 ID

// 开启容器节点
const node = engine.graph.getNode('node-1');
if (node) {
node.isContainer = true;
engine.graph.markDirty('style');
}

// 添加容器的子节点
const node = engine.graph.getNode('node-2');
if (node) {
node.parentId = 'node-1';
engine.graph.markDirty('style');
}

zIndex - 层级

控制节点的绘制顺序(数值越大越靠前):

// 置于顶层
const node = engine.graph.getNode('node-1');
if (node) {
node.zIndex = 100;
engine.graph.markDirty('structure');
}

// 置于底层
const node = engine.graph.getNode('node-1');
if (node) {
node.zIndex = -100;
engine.graph.markDirty('structure');
}

// 前移一层
const node = engine.graph.getNode('node-1');
if (node) {
node.zIndex = (node.zIndex || 0) + 1;
engine.graph.markDirty('structure');
}

rotation - 旋转

控制节点旋转角度(弧度制):

// 旋转 45 度
const node = engine.graph.getNode('node-1');
if (node) {
node.rotation = 45;
engine.graph.markDirty('structure');
}

// 旋转 90 度
const node = engine.graph.getNode('node-1');
if (node) {
node.rotation = 90;
engine.graph.markDirty('structure');
}

// 旋转 180 度
const node = engine.graph.getNode('node-1');
if (node) {
node.rotation = 180;
engine.graph.markDirty('structure');
}

锚点系统

可采用相对、绝对定位的方式设置锚点位置,如果锚点不存在且有边连接时,边端点自动连接到节点的几何中心点。

锚点配置

interface PortData {
id: string; // 锚点ID
anchorMode?: 'relative' | 'absolute'; // 锚点模式
anchorPosition?: AnchorPosition; // 相对位置
offset: Point; // 偏移量
radius?: number; // 显示半径
label?: string; // 标签
}

type AnchorPosition =
| 'top' | 'top-left' | 'top-right'
| 'right' | 'right-top' | 'right-bottom'
| 'bottom' | 'bottom-left' | 'bottom-right'
| 'left' | 'left-top' | 'left-bottom'
| 'center';

相对锚点(推荐)

相对锚点基于节点边界百分比定位,不受节点尺寸变化影响:

{
"id": "node-1",
"shape": "rect",
"position": { "x": 100, "y": 100 },
"size": { "width": 120, "height": 80 },
"ports": [
{
"id": "top",
"anchorMode": "relative",
"anchorPosition": "top",
"offset": { "x": 0, "y": 0 }
},
{
"id": "right",
"anchorMode": "relative",
"anchorPosition": "right",
"offset": { "x": 0, "y": 0 }
},
{
"id": "bottom",
"anchorMode": "relative",
"anchorPosition": "bottom",
"offset": { "x": 0, "y": 0 }
},
{
"id": "left",
"anchorMode": "relative",
"anchorPosition": "left",
"offset": { "x": 0, "y": 0 }
}
]
}

绝对锚点

绝对锚点基于像素偏移定位:

{
"id": "node-1",
"shape": "rect",
"position": { "x": 100, "y": 100 },
"size": { "width": 120, "height": 80 },
"ports": [
{
"id": "custom-1",
"anchorMode": "absolute",
"offset": { "x": 30, "y": 0 } // 相对左上角偏移
}
]
}

显示/隐藏锁点

// 隐藏锁点(但仍可连线)
const node = engine.graph.getNode('node-1');
if (node) {
node.data = { ...node.data, showPorts: false };
engine.graph.markDirty('style');
}

// 显示锁点
const node = engine.graph.getNode('node-1');
if (node) {
node.data = { ...node.data, showPorts: true };
engine.graph.markDirty('style');
}

分组系统

核心是基于节点是否存在相同的groupId区分的,groupPath(是groupId数组)记录了分组的次序,便于解组时按原次序解组。

创建分组

// 添加节点到分组
const node = engine.graph.getNode('node-1');
if (node) {
node.groupId = 'group-1';
engine.graph.markDirty('structure');
}

// 嵌套分组
const node = engine.graph.getNode('node-1');
if (node) {
node.groupPath = ['group-1', 'sub-group-1', 'sub-sub-group-1'];
engine.graph.markDirty('structure');
}

分组操作

// 获取组内所有节点
const groupNodes = engine.graph.getNodes().filter(
node => node.groupId === 'group-1'
);

// 批量移动组内节点
const dx = 50, dy = 50;
groupNodes.forEach(node => {
node.position.x += dx;
node.position.y += dy;
});
engine.graph.markDirty('structure');

// 解散分组
groupNodes.forEach(node => {
node.groupId = undefined;
node.groupPath = undefined;
});
engine.graph.markDirty('structure');

自定义数据

data 字段

data 字段可以存储任意自定义数据:

{
"id": "node-1",
"shape": "rect",
"position": { "x": 98, "y": 106 },
"size": { "width": 120, "height": 80 },
"selected": true,
"data": {
// 样式
"style": {
"fill": "#ffffff",
"stroke": "#111827",
"lineWidth": 2,
"radius": 8,
"alpha": 0.31
},
// 自定义数据
"custom": {
"code": "n1",
"name": "圆角矩形",
"local": "位于画布左上角"
}
},
}

访问自定义数据

const node = engine.graph.getNode('node-1');
if (node && node.data) {
console.log(node.data.label);
console.log(node.data.style);
console.log(node.data.custom?.code);
}

批量操作

批量更新

这里的批量更新不是批量匹配并更新,而是针对多个更新时,对于历史操作记录标记为1次(内部存在复合命令 CompositeCommand 处理),如编辑器中可实现整体的撤销、重做功能。

// 使用事务(Transaction)批量执行命令,支持撤销
import { MoveNodesCommand } from '@fnt-agilejs/core';

// 开始事务
engine.history.beginTransaction('Batch Move');

// 执行多个命令
for (const id of ['node-1', 'node-2', 'node-3']) {
const node = engine.graph.getNode(id);
if (node) {
engine.history.execute(
new MoveNodesCommand(engine.graph, [id], 50, 50)
);
}
}

// 提交事务(作为一个整体可撤销)
engine.history.commitTransaction();

// 如果需要回滚事务
// engine.history.rollbackTransaction();

性能优化

重要说明:引擎使用 requestAnimationFrame 循环渲染,不会因为每次 addNode 就立即重绘。批量添加 1000 个节点,无论用 for 循环还是 forEach,都只会在下一帧渲染一次。因此,普通的批量添加已经足够高效。

// ✅ 推荐:直接循环添加(性能已足够好)
for (let i = 0; i < 1000; i++) {
engine.graph.addNode({
id: `node-${i}`,
shape: 'rect',
position: { x: Math.random() * 1000, y: Math.random() * 1000 },
size: { width: 50, height: 50 }
});
}
// 虽然每次 addNode 都会 bump('structure'),但渲染是异步的,
// 所有节点添加完成后才会在下一帧统一渲染一次

// ✅ 更优:使用序列化 API 批量导入(减少版本更新次数)
import { fromScene } from '@fnt-agilejs/core';

const sceneData = {
nodes: Array.from({ length: 1000 }, (_, i) => ({
id: `node-${i}`,
shape: 'rect',
position: { x: Math.random() * 1000, y: Math.random() * 1000 },
size: { width: 50, height: 50 }
})),
edges: []
};

fromScene(engine.graph, sceneData);
// fromScene 会清空图并批量添加,只触发 1 次版本更新

真正需要优化的场景

// ❌ 问题:超大规模数据(> 10000 节点)导致主线程阻塞
for (let i = 0; i < 50000; i++) {
engine.graph.addNode({...}); // 长时间循环阻塞主线程
}

// ✅ 解决:分批添加 + yield 主线程
const BATCH_SIZE = 1000;
async function addNodesAsync(nodes: NodeData[]) {
for (let i = 0; i < nodes.length; i += BATCH_SIZE) {
const batch = nodes.slice(i, i + BATCH_SIZE);
batch.forEach(node => engine.graph.addNode(node));
// 让出主线程,保持界面响应
await new Promise(resolve => setTimeout(resolve, 0));
}
}

// 使用
const nodes = Array.from({ length: 50000 }, (_, i) => ({
id: `node-${i}`,
shape: 'rect',
position: { x: Math.random() * 5000, y: Math.random() * 5000 },
size: { width: 50, height: 50 }
}));

await addNodesAsync(nodes);

性能对比

场景节点数量版本更新渲染次数主线程阻塞推荐方案
少量< 1000N 次1 次< 50ms直接循环 addNode
中等1000-5000N 次1 次50-200ms直接循环或 fromScene
大量5000-10000N 次1 次200-500msfromScene(减少版本更新)
超大> 10000N 次多次> 500ms分批 + async(避免卡顿)

高级功能

警告

以下功能主要基于画布引擎基础接口实现,并非完全由引擎内置。

节点查询

// 按条件过滤
const circleNodes = engine.graph.getNodes().filter(
node => node.shape === 'circle'
);

// 按位置查询
const nodesInArea = engine.graph.getNodes().filter(node => {
return node.position.x >= 0 && node.position.x <= 500 &&
node.position.y >= 0 && node.position.y <= 500;
});

// 按数据查询
const importantNodes = engine.graph.getNodes().filter(
node => node.data?.isImportant === true
);

碰撞检测

function isColliding(node1: NodeData, node2: NodeData): boolean {
return !(
node1.position.x + node1.size.width < node2.position.x ||
node2.position.x + node2.size.width < node1.position.x ||
node1.position.y + node1.size.height < node2.position.y ||
node2.position.y + node2.size.height < node1.position.y
);
}

// 检查节点是否与其他节点碰撞
const node = engine.graph.getNode('node-1');
if (node) {
const others = engine.graph.getNodes().filter(n => n.id !== 'node-1');
const collisions = others.filter(other => isColliding(node, other));
}

节点对齐

// 左对齐
function alignLeft(nodeIds: string[]): void {
const nodes = nodeIds.map(id => engine.graph.getNode(id)).filter(Boolean);
if (nodes.length === 0) return;
const minX = Math.min(...nodes.map(n => n!.position.x));

nodes.forEach(node => {
if (node) node.position.x = minX;
});
engine.graph.markDirty('structure');
}

// 顶部对齐
function alignTop(nodeIds: string[]): void {
const nodes = nodeIds.map(id => engine.graph.getNode(id)).filter(Boolean);
if (nodes.length === 0) return;
const minY = Math.min(...nodes.map(n => n!.position.y));

nodes.forEach(node => {
if (node) node.position.y = minY;
});
engine.graph.markDirty('structure');
}

// 水平居中对齐
function alignCenterHorizontal(nodeIds: string[]): void {
const nodes = nodeIds.map(id => engine.graph.getNode(id)).filter(Boolean);
if (nodes.length === 0) return;
const centerX = nodes.reduce((sum, n) =>
sum + (n!.position.x + n!.size.width / 2), 0
) / nodes.length;

nodes.forEach(node => {
if (node) {
node.position.x = centerX - node.size.width / 2;
}
});
engine.graph.markDirty('structure');
}

最佳实践

1. 使用唯一ID

// ✅ 使用UUID或递增ID
import { v4 as uuidv4 } from 'uuid';

const node = {
id: uuidv4(),
// ...
};

// 或使用递增ID
let nodeCounter = 0;
const node = {
id: `node-${++nodeCounter}`,
// ...
};

2. 合理使用 zIndex

// ✅ 预留足够的层级空间
const LAYER_BACKGROUND = -1000;
const LAYER_DEFAULT = 0;
const LAYER_IMPORTANT = 1000;
const LAYER_OVERLAY = 2000;

const node = {
id: 'node-1',
zIndex: LAYER_IMPORTANT,
// ...
};

3. 性能优化

// ✅ 批量操作分批进行,减少 markDirty 调用
const nodes = engine.graph.getNodes();
for (const node of nodes) {
node.position.x += 10;
}
engine.graph.markDirty('structure'); // 只调用一次

// ✅ 缓存查询结果
const nodes = engine.graph.getNodes(); // 缓存
nodes.forEach(node => {
// 使用缓存的结果
});

4. 数据验证

// ✅ 添加数据验证
function validateNode(node: Partial<NodeData>): boolean {
return !!(
node.id &&
node.shape &&
node.position &&
node.size &&
node.size.width > 0 &&
node.size.height > 0
);
}

if (validateNode(node)) {
engine.graph.addNode(node as NodeData);
}