Network Graph Drawing
Network Graph Drawing
This tutorial explains how to use WebCAD custom entities and the reactor system to create network graphs with topology. A network graph consists of nodes and branches. A branch connects two nodes, and when a node moves, connected branches automatically update with it.
Concepts
- Node (
NetworkNodeEnt): extendsCustomEntityBaseand serves as the Owner entity. When it moves,setModified()triggers reactor notifications. - Branch (
NetworkBranchEnt): extendsCustomEntityBaseand implementsIEntityReactor, subscribing to the nodes at both ends. When nodes change, the branch endpoints update automatically.
Move Behavior
| Operation | Node | Branch |
|---|---|---|
| Move node | Position updates | Reactor triggers → endpoints follow automatically |
| Move branch | Unchanged | Endpoints are determined by nodes and stay unchanged |
| Move whole selection | Nodes move | Node movement triggers reactor → endpoints follow |
Delete / Copy Rules
- Nodes and branches cannot exist independently
- Deleting a node cascades to delete all related branches
- Deleting a branch cleans up orphan nodes with no branch references
- Copy-pasting incomplete topology is automatically rejected
Define Node Entity
The node extends CustomEntityBase and is rendered as a circle with a centered text label.
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 'Network Node'; }
constructor() {
super();
this._position = new Point2D(0, 0);
this._radius = 10;
this._label = '';
this._networkId = genId(); // Unique identifier for topology and copy-paste reconstruction
// Text member: push directly in buildNestEnts, do not clone (WebCAD constraint)
this._labelText = new TextEnt([0, 0], '', 7, 0, TextAlignmentEnum.MidCenter);
this._labelText.setDefaults();
}
// Snap points and grip points
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(); // Trigger reactor notification for related branches
}
}
// Build nested entities (rendering)
buildNestEnts() {
const circle = new CircleEnt(this._position.clone(), this._radius);
circle.fromDefaultProps(this);
// Use the persistent text member directly and set block property
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];
}
// Clone (keep networkId for topology reconstruction)
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;
}
// Serialization
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);
}
// Move: translate position then call setModified to trigger 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();
}
}Key Constraints
- The
TextEntinbuildNestEnts()must be stored as a persistent member and pushed directly into the returned array, and must not be cloned - You must set
this._labelText.block = this.block - The constructor must be parameterless (
CustomEntityRegistryrequirement)
Define Branch Entity
The branch implements IEntityReactor and subscribes to the changes of the nodes at both ends. It supports both straight lines and arcs.
const { EntityReactorManager, ReactorEvent, LineEnt, ArcEnt, SolidEnt } = vjcad;
class NetworkBranchEnt extends CustomEntityBase {
get customType() { return 'NETWORK_BRANCH'; }
get customDisplayName() { return 'Network Branch'; }
constructor() {
super();
this._startPoint = new Point2D(0, 0); // Cached endpoints (updated by reactor)
this._endPoint = new Point2D(1, 0);
this._label = '';
this._bulge = 0; // 0 = straight line, non-zero = arc
this._startNetworkId = ''; // networkId of start node
this._endNetworkId = ''; // networkId of end node
this._startNodeRadius = 0; // Cached node radius (for trimming)
this._endNodeRadius = 0;
this._ownerRefs = []; // Reactor owner references
this._reactorDirty = false;
this._reactorRegistered = false;
this._showArrow = true;
this._labelText = new TextEnt([0, 0], '', 5, 0, TextAlignmentEnum.MidCenter);
this._labelText.setDefaults();
}
// ---- IEntityReactor implementation ----
getOwnerIds() {
return this._ownerRefs.map(r => r.entityId);
}
onOwnerChanged(args) {
if (args.event === ReactorEvent.Erased) {
// Node deleted, remove reference
this._ownerRefs = this._ownerRefs.filter(r => r.entityId !== args.ownerId);
if (this._ownerRefs.length === 0) this.unlinkAllOwners();
} else {
this.setReactorDirty(); // Mark as needing update
}
}
setReactorDirty() { this._reactorDirty = true; this.setModified(); }
isReactorDirty() { return this._reactorDirty; }
// Update endpoint positions from related nodes
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 = [];
}
// Create reactor association (must be called after addEntities)
setSourceNodes(startId, endId) {
this.unlinkAllOwners();
this._ownerRefs = [
{ entityId: startId, meta: { role: 'start' } },
{ entityId: endId, meta: { role: 'end' } }
];
// Sync endpoints and radii from nodes
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;
}
}
}
// Register reactor
const docId = this.block?.doc?.docId;
if (docId !== undefined) {
EntityReactorManager.getInstance().registerReactor(this, docId);
this._reactorRegistered = true;
}
}
// ---- Rendering ----
buildNestEnts() {
// If the reactor is dirty, update endpoints from nodes first
if (this._reactorDirty && this._ownerRefs.length > 0) {
this.updateFromOwners();
}
const entities = [];
const sp = this._startPoint, ep = this._endPoint;
// ... trimming, drawing line/arc, arrow, label ...
return entities;
}
// ... clone, getEntityData, setEntityData, fromDb, move, etc. ...
}setSourceNodes() Call Timing
setSourceNodes() must be called after Engine.addEntities() because it requires valid entity id values assigned during addEntities.
Register Entity Types
const { CustomEntityRegistry } = vjcad;
const registry = CustomEntityRegistry.getInstance();
registry.register('NETWORK_NODE', NetworkNodeEnt);
registry.register('NETWORK_BRANCH', NetworkBranchEnt);Generate Network Graph from Data
const { Engine } = vjcad;
// Topology data
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 }, // Arc
{ startNodeId: '3', endNodeId: '4', label: '4' },
{ startNodeId: '4', endNodeId: '5', label: '5' }
]
};
// Create node and branch entities
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; // Set objectId (required for copy-paste detection)
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. Add to canvas first (to obtain valid entity ids)
Engine.addEntities([...nodes, ...branches]);
// 2. Then establish reactor associations
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();Tips
Branches support both straight lines (bulge = 0) and arcs (bulge ≠ 0). The bulge value defines arc curvature: positive bends to the left side of the chord, negative bends to the right.
Cascade Delete
Nodes and branches cannot exist independently, so you need to listen for delete events to implement cascading behavior:
const { CadEventManager, CadEvents } = vjcad;
// Helper functions: check entity type via 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 = [];
// Phase 1: delete branches connected to deleted nodes
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);
}
}
// Phase 2: delete orphan nodes
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;
}
}
// Register event listener
const eventMgr = CadEventManager.getInstance();
const erasedHandler = (args) => {
if (cascadeProcessing) return; // Prevent recursion
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); // Deferred batch processing
}
};
eventMgr.on(CadEvents.EntitiesErased, erasedHandler);Notes
- Event name: WebCAD delete operations trigger
EntitiesErased(plural), notEntityErased isAlivecheck:Engine.getEntities()returns all entities including deleted ones (isAlive = false), so always add anisAlivefilter when querying live entitiescascadeProcessingguard: cascade deletion itself triggers newEntitiesErasedevents, so a guard is required to prevent infinite recursionsetTimeout(0)batching: collect multiple delete events in the same frame and process them together in the next microtask
Preserve Topology During Copy-Paste
Problem
During copy-paste, clone() keeps the original networkId, which may cause pasted branches to link to original nodes rather than their copies.
Solution
Listen to the EntityAdded event and, for pasted entities, perform:
- Integrity validation: nodes without branches (or the reverse) → reject
- ID remapping: detect duplicate
networkIdvalues and generate new IDs for copies - Reactor reconstruction: rebuild reactor associations using the new IDs
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;
// Detect whether from paste (clone_ prefix)
const all = [...pastedNodes, ...pastedBranches];
const isFromPaste = all.length > 0 &&
all.every(e => e.objectId && e.objectId.startsWith('clone_'));
if (isFromPaste) {
// Integrity validation
let reject = false;
if (pastedNodes.length > 0 && pastedBranches.length === 0) reject = true;
else if (pastedBranches.length > 0 && pastedNodes.length === 0) reject = true;
// ... check isolated entities in mixed cases ...
if (reject) {
cascadeProcessing = true;
try { Engine.eraseEntities(all.filter(e => e.isAlive), { recordUndo: false }); }
finally { cascadeProcessing = false; }
return;
}
}
// ID remapping
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);
}
// Register 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);
}
// Debounced EntityAdded listener
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);Role of objectId
Assigning a non-empty objectId to an entity (such as 'node_1') causes the system to automatically prepend clone_ during cloning. Checking objectId.startsWith('clone_') lets you distinguish:
- Paste:
clone_prefix → perform integrity validation - Undo restore: original
objectIdkept → skip validation - Normal creation: empty string → skip validation
Complete Example
Refer to the network graph example for the full running demo, including:
- Generate a 5-node, 9-branch network graph from topology data (including straight lines and arcs)
- Register the
BINDRAWNETWORKcommand to interactively draw new network graphs - Automatic branch updates when moving nodes, cascade deletion, and topology-preserving copy-paste
Plugin Development
If you need to package the network graph functionality as an independent plugin, refer to the structure under webcad-plugins/network-graph-plugin/:
network-graph-plugin/
├── src/
│ ├── index.ts # Plugin entry
│ ├── entities/
│ │ ├── NetworkNodeEnt.ts # Node entity
│ │ └── NetworkBranchEnt.ts # Branch entity (with reactor)
│ ├── commands/
│ │ ├── DrawNetworkGraphCommand.ts # Drawing command
│ │ ├── GenerateNetworkCommand.ts # Generation command (with dialog)
│ │ ├── MergeNodesCommand.ts # Merge nodes
│ │ ├── BreakBranchCommand.ts # Break branch
│ │ └── ReverseBranchCommand.ts # Reverse branch
│ ├── services/
│ │ ├── NetworkService.ts # Topology service (generation / association / cascade delete / paste validation)
│ │ └── LayoutAlgorithm.ts # Layout algorithms (force-directed / circular / hierarchical)
│ └── ui/
│ └── GenerateNetworkDialog.ts # Generation dialog (CSV input)The plugin registers entity types through CustomEntityRegistry, registers commands and Ribbon buttons through PluginContext, and uses CadEventManager event listeners to implement cascade deletion and paste validation.