网络图绘制
大约 9 分钟
网络图绘制
本教程介绍如何使用 WebCAD 的自定义实体和反应器系统创建具有拓扑关系的网络图。网络图由节点和分支组成,分支连接两个节点,移动节点时关联的分支自动跟随更新。
概念
- 节点(NetworkNodeEnt):继承
CustomEntityBase,是 Owner 实体。移动时setModified()触发 Reactor 通知。 - 分支(NetworkBranchEnt):继承
CustomEntityBase并实现IEntityReactor,订阅两端节点。节点变化时自动更新端点位置。
移动行为
| 操作 | 节点 | 分支 |
|---|---|---|
| 移动节点 | 位置更新 | Reactor 触发 → 端点自动跟随 |
| 移动分支 | 不动 | 端点由节点决定,不变 |
| 整体选择移动 | 节点移动 | 节点移动触发 Reactor → 端点跟随 |
删除/复制规则
- 节点和分支不能孤立存在
- 删除节点 → 级联删除所有关联分支
- 删除分支 → 清理孤立节点(无分支引用的节点)
- 复制粘贴不完整的拓扑 → 自动拒绝
定义节点实体
节点继承 CustomEntityBase,渲染为圆形 + 居中文字标签。
const { CustomEntityBase, Point2D, CircleEnt, TextEnt, TextAlignmentEnum } = vjcad;
function genId() {
return `nn_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
class NetworkNodeEnt extends CustomEntityBase {
get customType() { return 'NETWORK_NODE'; }
get customDisplayName() { return '网络节点'; }
constructor() {
super();
this._position = new Point2D(0, 0);
this._radius = 10;
this._label = '';
this._networkId = genId(); // 唯一标识,用于拓扑关系和复制粘贴重建
// 文本成员:在 buildNestEnts 中直接 push,不克隆(WebCAD 约束)
this._labelText = new TextEnt([0, 0], '', 7, 0, TextAlignmentEnum.MidCenter);
this._labelText.setDefaults();
}
// 捕捉点和夹点
getSnapPoints() {
return [{ point: this._position.clone(), type: 'center' }];
}
getGripPoints() {
return [{ point: this._position.clone(), gripId: 'center', type: 'move' }];
}
gripEdit(newPos, gripId) {
if (gripId === 'center') {
this._position = newPos.clone();
this.setModified(); // 触发 Reactor 通知关联分支
}
}
// 构建嵌套实体(渲染)
buildNestEnts() {
const circle = new CircleEnt(this._position.clone(), this._radius);
circle.fromDefaultProps(this);
// 文本直接使用持久成员,设置 block 属性
this._labelText.insertionPoint = this._position.clone();
this._labelText.text = this._label;
this._labelText.height = this._radius * 0.7;
this._labelText.textAlignment = TextAlignmentEnum.MidCenter;
this._labelText.fromDefaultProps(this);
this._labelText.block = this.block;
return [circle, this._labelText];
}
// 克隆(保留 networkId 用于拓扑重建)
clone() {
const c = new NetworkNodeEnt();
c.fromDefaultProps(this);
c._position = this._position.clone();
c._radius = this._radius;
c._label = this._label;
c._networkId = this._networkId;
c._labelText = this._labelText.clone();
c._labelText.fromDefaultProps(this._labelText);
return c;
}
// 序列化
getEntityData() {
return {
position: { x: this._position.x, y: this._position.y },
radius: this._radius,
label: this._label,
networkId: this._networkId
};
}
setEntityData(d) {
if (d.position) this._position = new Point2D(d.position.x, d.position.y);
if (d.radius !== undefined) this._radius = d.radius;
if (d.label !== undefined) this._label = d.label;
if (d.networkId !== undefined) this._networkId = d.networkId;
this.setModified();
}
fromDb(db) {
this.fromDbDefaultProps(db);
if (db.data) this.setEntityData(db.data);
}
// 移动:平移位置后 setModified 触发 Reactor
move(from, to) {
const fp = from instanceof Point2D ? from : new Point2D(from[0], from[1]);
const tp = to instanceof Point2D ? to : new Point2D(to[0], to[1]);
this._position.x += tp.x - fp.x;
this._position.y += tp.y - fp.y;
this.setModified();
}
}关键约束
buildNestEnts()中的TextEnt必须作为持久成员变量存储,直接 push 到返回数组,不能克隆- 必须设置
this._labelText.block = this.block - 构造函数必须无参(
CustomEntityRegistry要求)
定义分支实体
分支实现 IEntityReactor 接口,订阅两端节点的变化。支持直线和圆弧两种形式。
const { EntityReactorManager, ReactorEvent, LineEnt, ArcEnt, SolidEnt } = vjcad;
class NetworkBranchEnt extends CustomEntityBase {
get customType() { return 'NETWORK_BRANCH'; }
get customDisplayName() { return '网络分支'; }
constructor() {
super();
this._startPoint = new Point2D(0, 0); // 缓存端点(由 Reactor 更新)
this._endPoint = new Point2D(1, 0);
this._label = '';
this._bulge = 0; // 0=直线,非0=圆弧
this._startNetworkId = ''; // 起始节点的 networkId
this._endNetworkId = ''; // 终止节点的 networkId
this._startNodeRadius = 0; // 缓存节点半径(用于裁剪)
this._endNodeRadius = 0;
this._ownerRefs = []; // Reactor Owner 引用
this._reactorDirty = false;
this._reactorRegistered = false;
this._showArrow = true;
this._labelText = new TextEnt([0, 0], '', 5, 0, TextAlignmentEnum.MidCenter);
this._labelText.setDefaults();
}
// ---- IEntityReactor 实现 ----
getOwnerIds() {
return this._ownerRefs.map(r => r.entityId);
}
onOwnerChanged(args) {
if (args.event === ReactorEvent.Erased) {
// 节点被删除,移除引用
this._ownerRefs = this._ownerRefs.filter(r => r.entityId !== args.ownerId);
if (this._ownerRefs.length === 0) this.unlinkAllOwners();
} else {
this.setReactorDirty(); // 标记需要更新
}
}
setReactorDirty() { this._reactorDirty = true; this.setModified(); }
isReactorDirty() { return this._reactorDirty; }
// 从关联节点更新端点位置
updateFromOwners() {
if (!this._ownerRefs.length) { this._reactorDirty = false; return false; }
const space = this.block?.doc?.currentSpace;
if (!space) { this._reactorDirty = false; return false; }
for (const ref of this._ownerRefs) {
for (const e of space.aliveItems) {
if (e.id === ref.entityId && e instanceof NetworkNodeEnt) {
if (ref.meta?.role === 'start') {
this._startPoint = e.position.clone();
this._startNodeRadius = e.radius;
}
if (ref.meta?.role === 'end') {
this._endPoint = e.position.clone();
this._endNodeRadius = e.radius;
}
}
}
}
this._reactorDirty = false;
return true;
}
unlinkAllOwners() {
if (this._reactorRegistered) {
EntityReactorManager.getInstance().unregisterReactor(
this.id, this.block?.doc?.docId
);
this._reactorRegistered = false;
}
this._ownerRefs = [];
}
// 建立 Reactor 关联(必须在 addEntity 之后调用)
setSourceNodes(startId, endId) {
this.unlinkAllOwners();
this._ownerRefs = [
{ entityId: startId, meta: { role: 'start' } },
{ entityId: endId, meta: { role: 'end' } }
];
// 从节点同步端点和半径
const space = this.block?.doc?.currentSpace;
if (space) {
for (const e of space.aliveItems) {
if (e.id === startId && e instanceof NetworkNodeEnt) {
this._startPoint = e.position.clone();
this._startNodeRadius = e.radius;
}
if (e.id === endId && e instanceof NetworkNodeEnt) {
this._endPoint = e.position.clone();
this._endNodeRadius = e.radius;
}
}
}
// 注册 Reactor
const docId = this.block?.doc?.docId;
if (docId !== undefined) {
EntityReactorManager.getInstance().registerReactor(this, docId);
this._reactorRegistered = true;
}
}
// ---- 渲染 ----
buildNestEnts() {
// 如果 Reactor 标记为脏,先从节点更新端点
if (this._reactorDirty && this._ownerRefs.length > 0) {
this.updateFromOwners();
}
const entities = [];
const sp = this._startPoint, ep = this._endPoint;
// ... 裁剪、绘制直线/圆弧、箭头、标签 ...
return entities;
}
// ... clone, getEntityData, setEntityData, fromDb, move 等方法 ...
}setSourceNodes 时序
setSourceNodes() 必须在 Engine.addEntities() 之后调用,因为它需要实体的有效 id(由 addEntity 分配)。
注册实体类型
const { CustomEntityRegistry } = vjcad;
const registry = CustomEntityRegistry.getInstance();
registry.register('NETWORK_NODE', NetworkNodeEnt);
registry.register('NETWORK_BRANCH', NetworkBranchEnt);从数据生成网络图
const { Engine } = vjcad;
// 拓扑数据
const data = {
nodes: [
{ id: '1', x: 0, y: 0, label: '1', radius: 18 },
{ id: '2', x: 200, y: 120, label: '2', radius: 18 },
{ id: '3', x: 200, y: -120, label: '3', radius: 18 },
{ id: '4', x: 450, y: 0, label: '4', radius: 18 },
{ id: '5', x: 620, y: 0, label: '5', radius: 18 }
],
branches: [
{ startNodeId: '1', endNodeId: '2', label: '1' },
{ startNodeId: '1', endNodeId: '3', label: '2' },
{ startNodeId: '2', endNodeId: '4', label: '3', bulge: 0.15 }, // 圆弧
{ startNodeId: '3', endNodeId: '4', label: '4' },
{ startNodeId: '4', endNodeId: '5', label: '5' }
]
};
// 创建节点和分支实体
const nodeMap = new Map();
const nodes = [], branches = [];
for (const nd of data.nodes) {
const node = new NetworkNodeEnt();
node._position = new Point2D(nd.x, nd.y);
node._label = nd.label;
node._radius = nd.radius;
node.objectId = 'node_' + nd.id; // 设置 objectId(复制粘贴检测需要)
node.setDefaults();
nodeMap.set(nd.id, node);
nodes.push(node);
}
for (const bd of data.branches) {
const sn = nodeMap.get(bd.startNodeId);
const en = nodeMap.get(bd.endNodeId);
if (!sn || !en) continue;
const b = new NetworkBranchEnt();
b._startPoint = sn.position.clone();
b._endPoint = en.position.clone();
b._startNetworkId = sn.networkId;
b._endNetworkId = en.networkId;
b._startNodeRadius = sn._radius;
b._endNodeRadius = en._radius;
b._label = bd.label;
b._bulge = bd.bulge || 0;
b.objectId = 'branch_' + bd.label;
b.setDefaults();
branches.push(b);
}
// 1. 先添加到画布(获得有效 entity id)
Engine.addEntities([...nodes, ...branches]);
// 2. 再建立 Reactor 关联
const byNetId = new Map();
for (const n of nodes) byNetId.set(n.networkId, n);
for (const b of branches) {
const sn = byNetId.get(b.startNetworkId);
const en = byNetId.get(b.endNetworkId);
if (sn && en) b.setSourceNodes(sn.id, en.id);
}
Engine.zoomExtents();提示
分支支持直线(bulge=0)和圆弧(bulge≠0)两种形式。bulge 值定义弧的弯曲程度,正值向弦的左侧弯曲,负值向右侧弯曲。
级联删除
节点和分支不能孤立存在,需要监听删除事件实现级联:
const { CadEventManager, CadEvents } = vjcad;
// 辅助函数:通过 customType 检查实体类型(兼容内联类和插件类)
function isNode(e) { return e.type === 'CUSTOM' && e.customType === 'NETWORK_NODE'; }
function isBranch(e) { return e.type === 'CUSTOM' && e.customType === 'NETWORK_BRANCH'; }
function isAliveNode(e) { return e.isAlive && isNode(e); }
function isAliveBranch(e) { return e.isAlive && isBranch(e); }
function getNetId(e) { return e._networkId || e.networkId || ''; }
function getStartNetId(e) { return e._startNetworkId || e.startNetworkId || ''; }
function getEndNetId(e) { return e._endNetworkId || e.endNetworkId || ''; }
let cascadeProcessing = false;
let pendingErasedNodes = [], pendingErasedBranches = [];
let cascadeTimer = null;
function processCascadeDelete() {
cascadeProcessing = true;
try {
const deletedNodeNetIds = new Set(pendingErasedNodes.map(n => getNetId(n)));
const deletedBranchNodeNetIds = new Set();
for (const b of pendingErasedBranches) {
const sid = getStartNetId(b), eid = getEndNetId(b);
if (sid) deletedBranchNodeNetIds.add(sid);
if (eid) deletedBranchNodeNetIds.add(eid);
}
pendingErasedNodes = [];
pendingErasedBranches = [];
// 阶段1:删除关联到已删除节点的分支
if (deletedNodeNetIds.size > 0) {
const aliveBranches = Engine.getEntities(e => isAliveBranch(e));
const toDelete = aliveBranches.filter(b =>
deletedNodeNetIds.has(getStartNetId(b)) || deletedNodeNetIds.has(getEndNetId(b))
);
if (toDelete.length > 0) {
for (const b of toDelete) {
const sid = getStartNetId(b), eid = getEndNetId(b);
if (sid) deletedBranchNodeNetIds.add(sid);
if (eid) deletedBranchNodeNetIds.add(eid);
}
Engine.eraseEntities(toDelete);
}
}
// 阶段2:删除孤立节点
if (deletedBranchNodeNetIds.size > 0) {
const remainingBranches = Engine.getEntities(e => isAliveBranch(e));
const stillReferenced = new Set();
for (const b of remainingBranches) {
const sid = getStartNetId(b), eid = getEndNetId(b);
if (sid) stillReferenced.add(sid);
if (eid) stillReferenced.add(eid);
}
const candidates = [...deletedBranchNodeNetIds].filter(
id => !stillReferenced.has(id) && !deletedNodeNetIds.has(id)
);
if (candidates.length > 0) {
const allNodes = Engine.getEntities(e => isAliveNode(e));
const orphanSet = new Set(candidates);
const orphans = allNodes.filter(n => orphanSet.has(getNetId(n)));
if (orphans.length > 0) Engine.eraseEntities(orphans);
}
}
} finally {
cascadeProcessing = false;
}
}
// 注册事件监听
const eventMgr = CadEventManager.getInstance();
const erasedHandler = (args) => {
if (cascadeProcessing) return; // 防止递归
const entityList = args?.entities || (args?.entity ? [args.entity] : []);
let queued = false;
for (const entity of entityList) {
if (isNode(entity)) { pendingErasedNodes.push(entity); queued = true; }
else if (isBranch(entity)) { pendingErasedBranches.push(entity); queued = true; }
}
if (queued && !cascadeTimer) {
cascadeTimer = setTimeout(() => {
cascadeTimer = null;
processCascadeDelete();
}, 0); // 延迟批处理
}
};
eventMgr.on(CadEvents.EntitiesErased, erasedHandler);注意事项
- 事件名:WebCAD 删除操作触发
EntitiesErased(复数),不是EntityErased isAlive检查:Engine.getEntities()返回所有实体包括已删除的(isAlive=false),查询存活实体必须加isAlive过滤cascadeProcessing守卫:级联删除本身会触发新的EntitiesErased事件,需要守卫防止无限递归setTimeout(0)批处理:同一帧内的多个删除事件先收集,在下一个微任务中统一处理
复制粘贴拓扑保持
问题
复制粘贴时,clone() 保留原始 networkId,导致粘贴的分支可能链接到原始节点而非副本。
解决方案
监听 EntityAdded 事件,对粘贴的实体进行:
- 完整性验证:只有节点没有分支(或反之)→ 拒绝
- ID 重映射:检测重复
networkId,为副本生成新 ID - Reactor 重建:用新 ID 建立 Reactor 关联
let pendingAddedEntities = [];
let rebuildTimer = null;
function rebuildTopology(entities) {
const docId = Engine.currentDoc?.docId;
if (docId === undefined) return;
const pastedNodes = entities.filter(e => isNode(e));
const pastedBranches = entities.filter(e => isBranch(e));
if (!pastedNodes.length && !pastedBranches.length) return;
// 检测是否来自粘贴(clone_ 前缀)
const all = [...pastedNodes, ...pastedBranches];
const isFromPaste = all.length > 0 &&
all.every(e => e.objectId && e.objectId.startsWith('clone_'));
if (isFromPaste) {
// 验证完整性
let reject = false;
if (pastedNodes.length > 0 && pastedBranches.length === 0) reject = true;
else if (pastedBranches.length > 0 && pastedNodes.length === 0) reject = true;
// ... 检查混合情况下的孤立实体 ...
if (reject) {
cascadeProcessing = true;
try { Engine.eraseEntities(all.filter(e => e.isAlive), { recordUndo: false }); }
finally { cascadeProcessing = false; }
return;
}
}
// ID 重映射
const idMapping = new Map();
const allAliveNodes = Engine.getEntities(e => isAliveNode(e));
for (const pNode of pastedNodes) {
const netId = getNetId(pNode);
if (allAliveNodes.some(n => n !== pNode && getNetId(n) === netId)) {
const newId = genId();
pNode._networkId = newId;
idMapping.set(netId, newId);
}
}
for (const pBranch of pastedBranches) {
const oldStart = getStartNetId(pBranch), oldEnd = getEndNetId(pBranch);
if (idMapping.has(oldStart)) pBranch._startNetworkId = idMapping.get(oldStart);
if (idMapping.has(oldEnd)) pBranch._endNetworkId = idMapping.get(oldEnd);
}
// 注册 Reactor
for (const pBranch of pastedBranches) {
if (typeof pBranch.tryRegisterReactor === 'function') {
pBranch.tryRegisterReactor(docId);
}
if (typeof pBranch.updateFromOwners === 'function') {
pBranch.updateFromOwners();
}
}
Engine.pcanvas?.regen(true);
}
// 防抖监听 EntityAdded
const addedHandler = (args) => {
const entities = args?.entities || (args?.entity ? [args.entity] : []);
for (const entity of entities) {
if (isNode(entity) || isBranch(entity)) pendingAddedEntities.push(entity);
}
if (pendingAddedEntities.length > 0) {
if (rebuildTimer) clearTimeout(rebuildTimer);
rebuildTimer = setTimeout(() => {
const pending = [...pendingAddedEntities];
pendingAddedEntities = [];
rebuildTopology(pending);
}, 150);
}
};
eventMgr.on(CadEvents.EntityAdded, addedHandler);
eventMgr.on(CadEvents.EntitiesAdded, addedHandler);objectId 的作用
给实体设置非空 objectId(如 'node_1'),clone 时系统自动添加 clone_ 前缀。通过检查 objectId.startsWith('clone_') 可以区分:
- 粘贴:
clone_前缀 → 执行完整性验证 - Undo 恢复:保持原始 objectId → 跳过验证
- 正常创建:空字符串 → 跳过验证
完整示例
参考 网络图示例 查看完整的运行效果,包含:
- 用拓扑数据生成 5 节点 9 分支的网络图(含直线和圆弧)
- 注册
BINDRAWNETWORK命令交互绘制新的网络图 - 移动节点自动更新分支、级联删除、复制粘贴拓扑保持
插件开发
如果需要将网络图功能打包为独立插件,参考 webcad-plugins/network-graph-plugin/ 目录结构:
network-graph-plugin/
├── src/
│ ├── index.ts # 插件入口
│ ├── entities/
│ │ ├── NetworkNodeEnt.ts # 节点实体
│ │ └── NetworkBranchEnt.ts # 分支实体(含 Reactor)
│ ├── commands/
│ │ ├── DrawNetworkGraphCommand.ts # 绘制命令
│ │ ├── GenerateNetworkCommand.ts # 生成命令(带对话框)
│ │ ├── MergeNodesCommand.ts # 合并节点
│ │ ├── BreakBranchCommand.ts # 打断分支
│ │ └── ReverseBranchCommand.ts # 分支反向
│ ├── services/
│ │ ├── NetworkService.ts # 拓扑服务(生成/关联/级联删除/粘贴验证)
│ │ └── LayoutAlgorithm.ts # 布局算法(力导向/圆形/层次)
│ └── ui/
│ └── GenerateNetworkDialog.ts # 生成对话框(CSV输入)插件通过 CustomEntityRegistry 注册实体类型,通过 PluginContext 注册命令和 Ribbon 按钮,通过 CadEventManager 监听事件实现级联删除和粘贴验证。