@joint/core 4.2.0-alpha.1 → 4.2.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/dia/Graph.mjs CHANGED
@@ -2,330 +2,194 @@ import * as util from '../util/index.mjs';
2
2
  import * as g from '../g/index.mjs';
3
3
 
4
4
  import { Model } from '../mvc/Model.mjs';
5
- import { Collection } from '../mvc/Collection.mjs';
5
+ import { Listener } from '../mvc/Listener.mjs';
6
6
  import { wrappers, wrapWith } from '../util/wrappers.mjs';
7
7
  import { cloneCells } from '../util/index.mjs';
8
+ import { GraphLayersController } from './GraphLayersController.mjs';
9
+ import { GraphLayerCollection } from './GraphLayerCollection.mjs';
10
+ import { config } from '../config/index.mjs';
11
+ import { CELL_MARKER } from './symbols.mjs';
12
+ import { GraphTopologyIndex } from './GraphTopologyIndex.mjs';
8
13
 
9
- const GraphCells = Collection.extend({
10
-
11
- initialize: function(models, opt) {
12
-
13
- // Set the optional namespace where all model classes are defined.
14
- if (opt.cellNamespace) {
15
- this.cellNamespace = opt.cellNamespace;
16
- } else {
17
- /* eslint-disable no-undef */
18
- this.cellNamespace = typeof joint !== 'undefined' && util.has(joint, 'shapes') ? joint.shapes : null;
19
- /* eslint-enable no-undef */
20
- }
21
-
22
-
23
- this.graph = opt.graph;
24
- },
25
-
26
- model: function(attrs, opt) {
27
-
28
- const collection = opt.collection;
29
- const namespace = collection.cellNamespace;
30
- const { type } = attrs;
31
-
32
- // Find the model class based on the `type` attribute in the cell namespace
33
- const ModelClass = util.getByPath(namespace, type, '.');
34
- if (!ModelClass) {
35
- throw new Error(`dia.Graph: Could not find cell constructor for type: '${type}'. Make sure to add the constructor to 'cellNamespace'.`);
36
- }
37
-
38
- return new ModelClass(attrs, opt);
39
- },
40
-
41
- _addReference: function(model, options) {
42
- Collection.prototype._addReference.apply(this, arguments);
43
- // If not in `dry` mode and the model does not have a graph reference yet,
44
- // set the reference.
45
- if (!options.dry && !model.graph) {
46
- model.graph = this.graph;
47
- }
48
- },
49
-
50
- _removeReference: function(model, options) {
51
- Collection.prototype._removeReference.apply(this, arguments);
52
- // If not in `dry` mode and the model has a reference to this exact graph,
53
- // remove the reference.
54
- if (!options.dry && model.graph === this.graph) {
55
- model.graph = null;
56
- }
57
- },
58
-
59
- // `comparator` makes it easy to sort cells based on their `z` index.
60
- comparator: function(model) {
61
-
62
- return model.get('z') || 0;
63
- }
64
- });
65
-
14
+ // The ID of the default graph layer.
15
+ const DEFAULT_LAYER_ID = 'cells';
66
16
 
67
17
  export const Graph = Model.extend({
68
18
 
69
- initialize: function(attrs, opt) {
70
-
71
- opt = opt || {};
72
-
73
- // Passing `cellModel` function in the options object to graph allows for
74
- // setting models based on attribute objects. This is especially handy
75
- // when processing JSON graphs that are in a different than JointJS format.
76
- var cells = new GraphCells([], {
77
- model: opt.cellModel,
78
- cellNamespace: opt.cellNamespace,
79
- graph: this
19
+ /**
20
+ * @todo Remove in v5.0.0
21
+ * @description In legacy mode, the information about layers is not
22
+ * exported into JSON.
23
+ */
24
+ legacyMode: true,
25
+
26
+ /**
27
+ * @protected
28
+ * @description The ID of the default layer.
29
+ */
30
+ defaultLayerId: DEFAULT_LAYER_ID,
31
+
32
+ initialize: function(attrs, options = {}) {
33
+
34
+ const layerCollection = this.layerCollection = new GraphLayerCollection([], {
35
+ layerNamespace: options.layerNamespace,
36
+ cellNamespace: options.cellNamespace,
37
+ graph: this,
38
+ /** @deprecated use cellNamespace instead */
39
+ model: options.cellModel,
80
40
  });
81
- Model.prototype.set.call(this, 'cells', cells);
82
41
 
83
- // Make all the events fired in the `cells` collection available.
84
- // to the outside world.
85
- cells.on('all', this.trigger, this);
42
+ // The default setup includes a single default layer.
43
+ layerCollection.add({ id: DEFAULT_LAYER_ID }, { graph: this.cid });
44
+
45
+ /**
46
+ * @todo Remove in v5.0.0
47
+ * @description Retain legacy 'cells' collection in attributes for backward compatibility.
48
+ * Applicable only when the default layer setup is used.
49
+ */
50
+ this.attributes.cells = this.getLayer(DEFAULT_LAYER_ID).cellCollection;
86
51
 
87
- // JointJS automatically doesn't trigger re-sort if models attributes are changed later when
88
- // they're already in the collection. Therefore, we're triggering sort manually here.
89
- this.on('change:z', this._sortOnChangeZ, this);
52
+ // Controller that manages communication between the graph and its layers.
53
+ this.layersController = new GraphLayersController({ graph: this });
90
54
 
91
- // `joint.dia.Graph` keeps an internal data structure (an adjacency list)
55
+ // `Graph` keeps an internal data structure (an adjacency list)
92
56
  // for fast graph queries. All changes that affect the structure of the graph
93
57
  // must be reflected in the `al` object. This object provides fast answers to
94
- // questions such as "what are the neighbours of this node" or "what
58
+ // questions such as "what are the neighbors of this node" or "what
95
59
  // are the sibling links of this link".
96
-
97
- // Outgoing edges per node. Note that we use a hash-table for the list
98
- // of outgoing edges for a faster lookup.
99
- // [nodeId] -> Object [edgeId] -> true
100
- this._out = {};
101
- // Ingoing edges per node.
102
- // [nodeId] -> Object [edgeId] -> true
103
- this._in = {};
104
- // `_nodes` is useful for quick lookup of all the elements in the graph, without
105
- // having to go through the whole cells array.
106
- // [node ID] -> true
107
- this._nodes = {};
108
- // `_edges` is useful for quick lookup of all the links in the graph, without
109
- // having to go through the whole cells array.
110
- // [edgeId] -> true
111
- this._edges = {};
60
+ this.topologyIndex = new GraphTopologyIndex({ layerCollection });
112
61
 
113
62
  this._batches = {};
114
-
115
- cells.on('add', this._restructureOnAdd, this);
116
- cells.on('remove', this._restructureOnRemove, this);
117
- cells.on('reset', this._restructureOnReset, this);
118
- cells.on('change:source', this._restructureOnChangeSource, this);
119
- cells.on('change:target', this._restructureOnChangeTarget, this);
120
- cells.on('remove', this._removeCell, this);
121
- },
122
-
123
- _sortOnChangeZ: function() {
124
-
125
- this.get('cells').sort();
126
63
  },
127
64
 
128
- _restructureOnAdd: function(cell) {
129
-
130
- if (cell.isLink()) {
131
- this._edges[cell.id] = true;
132
- var { source, target } = cell.attributes;
133
- if (source.id) {
134
- (this._out[source.id] || (this._out[source.id] = {}))[cell.id] = true;
135
- }
136
- if (target.id) {
137
- (this._in[target.id] || (this._in[target.id] = {}))[cell.id] = true;
138
- }
139
- } else {
140
- this._nodes[cell.id] = true;
141
- }
142
- },
143
-
144
- _restructureOnRemove: function(cell) {
145
-
146
- if (cell.isLink()) {
147
- delete this._edges[cell.id];
148
- var { source, target } = cell.attributes;
149
- if (source.id && this._out[source.id] && this._out[source.id][cell.id]) {
150
- delete this._out[source.id][cell.id];
151
- }
152
- if (target.id && this._in[target.id] && this._in[target.id][cell.id]) {
153
- delete this._in[target.id][cell.id];
154
- }
155
- } else {
156
- delete this._nodes[cell.id];
157
- }
158
- },
159
-
160
- _restructureOnReset: function(cells) {
161
-
162
- // Normalize into an array of cells. The original `cells` is GraphCells mvc collection.
163
- cells = cells.models;
164
-
165
- this._out = {};
166
- this._in = {};
167
- this._nodes = {};
168
- this._edges = {};
169
-
170
- cells.forEach(this._restructureOnAdd, this);
171
- },
65
+ toJSON: function(opt = {}) {
172
66
 
173
- _restructureOnChangeSource: function(link) {
67
+ const { layerCollection } = this;
68
+ // Get the graph model attributes as a base JSON.
69
+ const json = Model.prototype.toJSON.apply(this, arguments);
174
70
 
175
- var prevSource = link.previous('source');
176
- if (prevSource.id && this._out[prevSource.id]) {
177
- delete this._out[prevSource.id][link.id];
178
- }
179
- var source = link.attributes.source;
180
- if (source.id) {
181
- (this._out[source.id] || (this._out[source.id] = {}))[link.id] = true;
182
- }
183
- },
71
+ // Add `cells` array holding all the cells in the graph.
72
+ json.cells = this.getCells().map(cell => cell.toJSON(opt.cellAttributes));
184
73
 
185
- _restructureOnChangeTarget: function(link) {
186
-
187
- var prevTarget = link.previous('target');
188
- if (prevTarget.id && this._in[prevTarget.id]) {
189
- delete this._in[prevTarget.id][link.id];
190
- }
191
- var target = link.get('target');
192
- if (target.id) {
193
- (this._in[target.id] || (this._in[target.id] = {}))[link.id] = true;
74
+ if (this.legacyMode) {
75
+ // Backwards compatibility for legacy setup
76
+ // with single default layer 'cells'.
77
+ // In this case, we do not need to export layers.
78
+ return json;
194
79
  }
195
- },
196
80
 
197
- // Return all outbound edges for the node. Return value is an object
198
- // of the form: [edgeId] -> true
199
- getOutboundEdges: function(node) {
81
+ // Add `layers` array holding all the layers in the graph.
82
+ json.layers = layerCollection.toJSON();
200
83
 
201
- return (this._out && this._out[node]) || {};
202
- },
203
-
204
- // Return all inbound edges for the node. Return value is an object
205
- // of the form: [edgeId] -> true
206
- getInboundEdges: function(node) {
207
-
208
- return (this._in && this._in[node]) || {};
209
- },
210
-
211
- toJSON: function(opt = {}) {
84
+ // Add `defaultLayer` property indicating the default layer ID.
85
+ json.defaultLayer = this.defaultLayerId;
212
86
 
213
- // JointJS does not recursively call `toJSON()` on attributes that are themselves models/collections.
214
- // It just clones the attributes. Therefore, we must call `toJSON()` on the cells collection explicitly.
215
- var json = Model.prototype.toJSON.apply(this, arguments);
216
- json.cells = this.get('cells').toJSON(opt.cellAttributes);
217
87
  return json;
218
88
  },
219
89
 
220
90
  fromJSON: function(json, opt) {
91
+ const { cells, layers, defaultLayer, ...attributes } = json;
221
92
 
222
- if (!json.cells) {
223
-
93
+ if (!cells) {
224
94
  throw new Error('Graph JSON must contain cells array.');
225
95
  }
226
96
 
227
- return this.set(json, opt);
228
- },
229
-
230
- set: function(key, val, opt) {
97
+ // The `fromJSON` should trigger a single 'reset' event at the end.
98
+ // Set all attributes silently for now.
99
+ this.set(attributes, { silent: true });
231
100
 
232
- var attrs;
233
-
234
- // Handle both `key`, value and {key: value} style arguments.
235
- if (typeof key === 'object') {
236
- attrs = key;
237
- opt = val;
238
- } else {
239
- (attrs = {})[key] = val;
101
+ if (layers) {
102
+ // Reset the layers collection
103
+ // (`layers:reset` is not forwarded to the graph).
104
+ this._resetLayers(layers, defaultLayer, opt);
240
105
  }
241
106
 
242
- // Make sure that `cells` attribute is handled separately via resetCells().
243
- if (attrs.hasOwnProperty('cells')) {
244
- this.resetCells(attrs.cells, opt);
245
- attrs = util.omit(attrs, 'cells');
107
+ if (cells) {
108
+ // Reset the cells collection and trigger the 'reset' event.
109
+ this.resetCells(cells, opt);
246
110
  }
247
111
 
248
- // The rest of the attributes are applied via original set method.
249
- return Model.prototype.set.call(this, attrs, opt);
112
+ return this;
250
113
  },
251
114
 
115
+ /** @deprecated */
252
116
  clear: function(opt) {
253
-
254
117
  opt = util.assign({}, opt, { clear: true });
255
118
 
256
- var collection = this.get('cells');
119
+ const cells = this.getCells();
257
120
 
258
- if (collection.length === 0) return this;
121
+ if (cells.length === 0) return this;
259
122
 
260
123
  this.startBatch('clear', opt);
261
124
 
262
- // The elements come after the links.
263
- var cells = collection.sortBy(function(cell) {
125
+ const sortedCells = util.sortBy(cells, (cell) => {
264
126
  return cell.isLink() ? 1 : 2;
265
127
  });
266
128
 
267
129
  do {
268
-
269
130
  // Remove all the cells one by one.
270
131
  // Note that all the links are removed first, so it's
271
132
  // safe to remove the elements without removing the connected
272
133
  // links first.
273
- cells.shift().remove(opt);
134
+ this.layerCollection.removeCell(sortedCells.shift(), opt);
274
135
 
275
- } while (cells.length > 0);
136
+ } while (sortedCells.length > 0);
276
137
 
277
138
  this.stopBatch('clear');
278
139
 
279
140
  return this;
280
141
  },
281
142
 
282
- _prepareCell: function(cell) {
143
+ _prepareCell: function(cellInit, opt) {
283
144
 
284
- let attrs;
285
- if (cell instanceof Model) {
286
- attrs = cell.attributes;
145
+ let cellAttributes;
146
+ if (cellInit[CELL_MARKER]) {
147
+ cellAttributes = cellInit.attributes;
287
148
  } else {
288
- attrs = cell;
149
+ cellAttributes = cellInit;
289
150
  }
290
151
 
291
- if (!util.isString(attrs.type)) {
152
+ if (!util.isString(cellAttributes.type)) {
292
153
  throw new TypeError('dia.Graph: cell type must be a string.');
293
154
  }
294
155
 
295
- return cell;
296
- },
297
-
298
- minZIndex: function() {
156
+ // Backward compatibility: prior v4.2, z-index was not set during reset.
157
+ if (opt && opt.ensureZIndex) {
158
+ if (cellAttributes.z === undefined) {
159
+ const layerId = cellAttributes[config.layerAttribute] || this.defaultLayerId;
160
+ const zIndex = this.maxZIndex(layerId) + 1;
161
+ if (cellInit[CELL_MARKER]) {
162
+ // Set with event in case there is a listener
163
+ // directly on the cell instance
164
+ // (the cell is not part of graph yet)
165
+ cellInit.set('z', zIndex, opt);
166
+ } else {
167
+ cellAttributes.z = zIndex;
168
+ }
169
+ }
170
+ }
299
171
 
300
- var firstCell = this.get('cells').first();
301
- return firstCell ? (firstCell.get('z') || 0) : 0;
172
+ return cellInit;
302
173
  },
303
174
 
304
- maxZIndex: function() {
305
-
306
- var lastCell = this.get('cells').last();
307
- return lastCell ? (lastCell.get('z') || 0) : 0;
175
+ minZIndex: function(layerId = this.defaultLayerId) {
176
+ const layer = this.getLayer(layerId);
177
+ return layer.cellCollection.minZIndex();
308
178
  },
309
179
 
310
- addCell: function(cell, opt) {
311
-
312
- if (Array.isArray(cell)) {
313
-
314
- return this.addCells(cell, opt);
315
- }
316
-
317
- if (cell instanceof Model) {
318
-
319
- if (!cell.has('z')) {
320
- cell.set('z', this.maxZIndex() + 1);
321
- }
180
+ maxZIndex: function(layerId = this.defaultLayerId) {
181
+ const layer = this.getLayer(layerId);
182
+ return layer.cellCollection.maxZIndex();
183
+ },
322
184
 
323
- } else if (cell.z === undefined) {
185
+ addCell: function(cellInit, options) {
324
186
 
325
- cell.z = this.maxZIndex() + 1;
187
+ if (Array.isArray(cellInit)) {
188
+ return this.addCells(cellInit, options);
326
189
  }
327
190
 
328
- this.get('cells').add(this._prepareCell(cell, opt), opt || {});
191
+ this._prepareCell(cellInit, { ...options, ensureZIndex: true });
192
+ this.layerCollection.addCellToLayer(cellInit, this.getCellLayerId(cellInit), options);
329
193
 
330
194
  return this;
331
195
  },
@@ -338,62 +202,290 @@ export const Graph = Model.extend({
338
202
  opt.maxPosition = opt.position = cells.length - 1;
339
203
 
340
204
  this.startBatch('add', opt);
341
- cells.forEach(function(cell) {
205
+ cells.forEach((cell) => {
342
206
  this.addCell(cell, opt);
343
207
  opt.position--;
344
- }, this);
208
+ });
345
209
  this.stopBatch('add', opt);
346
210
 
347
211
  return this;
348
212
  },
349
213
 
350
- // When adding a lot of cells, it is much more efficient to
351
- // reset the entire cells collection in one go.
352
- // Useful for bulk operations and optimizations.
353
- resetCells: function(cells, opt) {
214
+ /**
215
+ * @public
216
+ * @description Reset the cells in the graph.
217
+ * Useful for bulk operations and optimizations.
218
+ */
219
+ resetCells: function(cellInits, options) {
220
+ const { layerCollection } = this;
221
+ // Note: `cellInits` is always an array and `options` is always an object.
222
+ // See `wrappers.cells` at the end of this file.
223
+
224
+ // When resetting cells, do not set z-index if not provided.
225
+ const prepareOptions = { ...options, ensureZIndex: false };
226
+
227
+ // Initialize a map of layer IDs to arrays of cells
228
+ const layerCellsMap = layerCollection.reduce((map, layer) => {
229
+ map[layer.id] = [];
230
+ return map;
231
+ }, {});
232
+
233
+ // Distribute cells into their respective layers
234
+ for (let i = 0; i < cellInits.length; i++) {
235
+ const cellInit = cellInits[i];
236
+ const layerId = this.getCellLayerId(cellInit);
237
+ if (layerId in layerCellsMap) {
238
+ this._prepareCell(cellInit, prepareOptions);
239
+ layerCellsMap[layerId].push(cellInit);
240
+ } else {
241
+ throw new Error(`dia.Graph: Layer "${layerId}" does not exist.`);
242
+ }
243
+ }
354
244
 
355
- var preparedCells = util.toArray(cells).map(function(cell) {
356
- return this._prepareCell(cell, opt);
357
- }, this);
358
- this.get('cells').reset(preparedCells, opt);
245
+ // Reset each layer's cell collection with the corresponding cells.
246
+ layerCollection.each(layer => {
247
+ layer.cellCollection.reset(layerCellsMap[layer.id], options);
248
+ });
249
+
250
+ // Trigger a single `reset` event on the graph
251
+ // (while multiple `reset` events are triggered on layers).
252
+ // Backwards compatibility: use default layer collection
253
+ // The `collection` parameter is retained for backwards compatibility,
254
+ // and it is subject to removal in future releases.
255
+ this.trigger('reset', this.getDefaultLayer().cellCollection, options);
359
256
 
360
257
  return this;
361
258
  },
362
259
 
363
- removeCells: function(cells, opt) {
260
+ /**
261
+ * @public
262
+ * @description Get the layer ID in which the cell resides.
263
+ * Cells without an explicit layer are assigned to the default layer.
264
+ * @param {dia.Cell | Object} cellInit - Cell model or attributes.
265
+ * @returns {string} - The layer ID.
266
+ */
267
+ getCellLayerId: function(cellInit) {
268
+ if (!cellInit) {
269
+ throw new Error('dia.Graph: No cell provided.');
270
+ }
271
+ const cellAttributes = cellInit[CELL_MARKER]
272
+ ? cellInit.attributes
273
+ : cellInit;
274
+ return cellAttributes[config.layerAttribute] || this.defaultLayerId;
275
+ },
276
+
277
+ /**
278
+ * @protected
279
+ * @description Reset the layers in the graph.
280
+ * It assumes the existing cells have been removed beforehand
281
+ * or can be discarded.
282
+ */
283
+ _resetLayers: function(layers, defaultLayerId, options = {}) {
284
+ if (!Array.isArray(layers) || layers.length === 0) {
285
+ throw new Error('dia.Graph: At least one layer must be defined.');
286
+ }
287
+
288
+ // Resetting layers disables legacy mode
289
+ this.legacyMode = false;
364
290
 
365
- if (cells.length) {
291
+ this.layerCollection.reset(layers, { ...options, graph: this.cid });
366
292
 
367
- this.startBatch('remove');
368
- util.invoke(cells, 'remove', opt);
369
- this.stopBatch('remove');
293
+ // If no default layer is specified, use the first layer as default
294
+ if (defaultLayerId) {
295
+ // The default layer must be one of the defined layers
296
+ if (!this.hasLayer(defaultLayerId)) {
297
+ throw new Error(`dia.Graph: default layer "${defaultLayerId}" does not exist.`);
298
+ }
299
+ this.defaultLayerId = defaultLayerId;
300
+ } else {
301
+ this.defaultLayerId = this.layerCollection.at(0).id;
370
302
  }
371
303
 
372
304
  return this;
373
305
  },
374
306
 
375
- _removeCell: function(cell, collection, options) {
376
-
377
- options = options || {};
307
+ /**
308
+ * @public
309
+ * @description Remove multiple cells from the graph.
310
+ * @param {Array<dia.Cell | dia.Cell.ID>} cellRefs - Array of cell references (models or IDs) to remove.
311
+ * @param {Object} [options] - Removal options. See {@link dia.Graph#removeCell}.
312
+ */
313
+ removeCells: function(cellRefs, options) {
314
+ if (!cellRefs.length) return this;
315
+ // Remove multiple cells in a single batch
316
+ this.startBatch('remove');
317
+ for (const cellRef of cellRefs) {
318
+ if (!cellRef) continue;
319
+ let cell;
320
+ if (cellRef[CELL_MARKER]) {
321
+ cell = cellRef;
322
+ } else {
323
+ cell = this.getCell(cellRef);
324
+ if (!cell) {
325
+ // The cell might have been already removed (embedded cell, connected link, etc.)
326
+ continue;
327
+ }
328
+ }
329
+ this.layerCollection.removeCell(cell, options);
330
+ }
331
+ this.stopBatch('remove');
332
+ return this;
333
+ },
378
334
 
379
- if (!options.clear) {
380
- // Applications might provide a `disconnectLinks` option set to `true` in order to
381
- // disconnect links when a cell is removed rather then removing them. The default
382
- // is to remove all the associated links.
383
- if (options.disconnectLinks) {
335
+ /**
336
+ * @protected
337
+ * @description Replace an existing cell with a new cell.
338
+ */
339
+ _replaceCell: function(currentCell, newCellInit, opt = {}) {
340
+ const batchName = 'replace-cell';
341
+ const replaceOptions = { ...opt, replace: true };
342
+ this.startBatch(batchName, opt);
343
+ // 1. Remove the cell without removing connected links or embedded cells.
344
+ this.layerCollection.removeCell(currentCell, replaceOptions);
345
+
346
+ const newCellInitAttributes = (newCellInit[CELL_MARKER])
347
+ ? newCellInit.attributes
348
+ : newCellInit;
349
+ // 2. Combine the current cell attributes with the new cell attributes
350
+ const replacementCellAttributes = Object.assign({}, currentCell.attributes, newCellInitAttributes);
351
+ let replacement;
352
+
353
+ if (newCellInit[CELL_MARKER]) {
354
+ // If the new cell is a model, set the merged attributes on the model
355
+ newCellInit.set(replacementCellAttributes, replaceOptions);
356
+ replacement = newCellInit;
357
+ } else {
358
+ replacement = replacementCellAttributes;
359
+ }
384
360
 
385
- this.disconnectLinks(cell, options);
361
+ // 3. Add the replacement cell
362
+ this.addCell(replacement, replaceOptions);
363
+ this.stopBatch(batchName);
364
+ },
386
365
 
366
+ /**
367
+ * @protected
368
+ * @description Synchronize a single graph cell with the provided cell (model or attributes).
369
+ * If the cell with the same `id` exists, it is updated. If the cell does not exist, it is added.
370
+ * If the existing cell type is different from the incoming cell type, the existing cell is replaced.
371
+ */
372
+ _syncCell: function(cellInit, opt = {}) {
373
+ const cellAttributes = (cellInit[CELL_MARKER])
374
+ ? cellInit.attributes
375
+ : cellInit;
376
+ const currentCell = this.getCell(cellInit.id);
377
+ if (currentCell) {
378
+ // `cellInit` is either a model or attributes object
379
+ if ('type' in cellAttributes && currentCell.get('type') !== cellAttributes.type) {
380
+ // Replace the cell if the type has changed
381
+ this._replaceCell(currentCell, cellInit, opt);
387
382
  } else {
383
+ // Update existing cell
384
+ // Note: the existing cell attributes are not removed,
385
+ // if they're missing in `cellAttributes`.
386
+ currentCell.set(cellAttributes, opt);
387
+ }
388
+ } else {
389
+ // The cell does not exist yet, add it
390
+ this.addCell(cellInit, opt);
391
+ }
392
+ },
393
+
394
+ /**
395
+ * @public
396
+ * @description Synchronize the graph cells with the provided array of cells (models or attributes).
397
+ */
398
+ syncCells: function(cellInits, opt = {}) {
388
399
 
389
- this.removeLinks(cell, options);
400
+ const batchName = 'sync-cells';
401
+ const { remove = false, ...setOpt } = opt;
402
+
403
+ let currentCells, newCellsMap;
404
+ if (remove) {
405
+ // We need to track existing cells to remove the missing ones later
406
+ currentCells = this.getCells();
407
+ newCellsMap = new Map();
408
+ }
409
+
410
+ // Observe changes to the graph cells
411
+ let changeObserver, changedLayers;
412
+ const shouldSort = opt.sort !== false;
413
+ if (shouldSort) {
414
+ changeObserver = new Listener();
415
+ changedLayers = new Set();
416
+ changeObserver.listenTo(this, {
417
+ 'add': (cell) => {
418
+ changedLayers.add(this.getCellLayerId(cell));
419
+ },
420
+ 'change': (cell) => {
421
+ if (cell.hasChanged(config.layerAttribute) || cell.hasChanged('z')) {
422
+ changedLayers.add(this.getCellLayerId(cell));
423
+ }
424
+ }
425
+ });
426
+ }
427
+
428
+ this.startBatch(batchName, opt);
429
+
430
+ // Prevent multiple sorts during sync
431
+ setOpt.sort = false;
432
+
433
+ // Add or update incoming cells
434
+ for (const cellInit of cellInits) {
435
+ if (remove) {
436
+ // only track existence
437
+ newCellsMap.set(cellInit.id, true);
390
438
  }
439
+ this._syncCell(cellInit, setOpt);
391
440
  }
392
- // Silently remove the cell from the cells collection. Silently, because
393
- // `joint.dia.Cell.prototype.remove` already triggers the `remove` event which is
394
- // then propagated to the graph model. If we didn't remove the cell silently, two `remove` events
395
- // would be triggered on the graph model.
396
- this.get('cells').remove(cell, { silent: true });
441
+
442
+ if (remove) {
443
+ // Remove cells not present in the incoming array
444
+ for (const cell of currentCells) {
445
+ if (!newCellsMap.has(cell.id)) {
446
+ this.layerCollection.removeCell(cell, setOpt);
447
+ }
448
+ }
449
+ }
450
+
451
+ if (shouldSort) {
452
+ // Sort layers that had changes affecting z-index or layer
453
+ changeObserver.stopListening();
454
+ for (const layerId of changedLayers) {
455
+ this.getLayer(layerId).cellCollection.sort(opt);
456
+ }
457
+ }
458
+
459
+ this.stopBatch(batchName);
460
+ },
461
+
462
+ /**
463
+ * @public
464
+ * @description Remove a cell from the graph.
465
+ * @param {dia.Cell} cell
466
+ * @param {Object} [options]
467
+ * @param {boolean} [options.disconnectLinks=false] - If `true`, the connected links are
468
+ * disconnected instead of removed.
469
+ * @param {boolean} [options.clear=false] - If `true`, the connected links
470
+ * are kept. @internal
471
+ * @param {boolean} [options.replace=false] - If `true`, the connected links and
472
+ * embedded cells are kept. @internal
473
+ * @throws Will throw an error if no cell is provided
474
+ * @throws Will throw an error if the ID of the cell to remove
475
+ * does not exist in the graph
476
+ **/
477
+ removeCell: function(cellRef, options) {
478
+ if (!cellRef) {
479
+ throw new Error('dia.Graph: no cell provided.');
480
+ }
481
+ const cell = cellRef[CELL_MARKER] ? cellRef : this.getCell(cellRef);
482
+ if (!cell) {
483
+ throw new Error('dia.Graph: cell to remove does not exist in the graph.');
484
+ }
485
+ if (cell.graph !== this) return;
486
+ this.startBatch('remove');
487
+ cell.collection.remove(cell, options);
488
+ this.stopBatch('remove');
397
489
  },
398
490
 
399
491
  transferCellEmbeds: function(sourceCell, targetCell, opt = {}) {
@@ -429,35 +521,249 @@ export const Graph = Model.extend({
429
521
  this.stopBatch(batchName);
430
522
  },
431
523
 
432
- // Get a cell by `id`.
433
- getCell: function(id) {
524
+ /**
525
+ * @private
526
+ * Helper method for addLayer and moveLayer methods
527
+ */
528
+ _getBeforeLayerIdFromOptions(options, layer = null) {
529
+ let { before = null, index } = options;
530
+
531
+ if (before && index !== undefined) {
532
+ throw new Error('dia.Graph: Options "before" and "index" are mutually exclusive.');
533
+ }
534
+
535
+ let computedBefore;
536
+ if (index !== undefined) {
537
+ const layersArray = this.getLayers();
538
+ if (index >= layersArray.length) {
539
+ // If index is greater than the number of layers,
540
+ // return before as null (move to the end).
541
+ computedBefore = null;
542
+ } else if (index < 0) {
543
+ // If index is negative, move to the beginning.
544
+ computedBefore = layersArray[0].id;
545
+ } else {
546
+ const originalIndex = layersArray.indexOf(layer);
547
+ if (originalIndex !== -1 && index > originalIndex) {
548
+ // If moving a layer upwards in the stack, we need to adjust the index
549
+ // to account for the layer being removed from its original position.
550
+ index += 1;
551
+ }
552
+ // Otherwise, get the layer ID at the specified index.
553
+ computedBefore = layersArray[index]?.id || null;
554
+ }
555
+ } else {
556
+ computedBefore = before;
557
+ }
558
+
559
+ return computedBefore;
560
+ },
561
+
562
+ /**
563
+ * @public
564
+ * Adds a new layer to the graph.
565
+ * @param {GraphLayer | GraphLayerJSON} layerInit
566
+ * @param {*} options
567
+ * @param {string | null} [options.before] - ID of the layer
568
+ * before which to insert the new layer. If `null`, the layer is added at the end.
569
+ * @param {number} [options.index] - Zero-based index to which to add the layer.
570
+ * @throws Will throw an error if the layer to add is invalid
571
+ * @throws Will throw an error if a layer with the same ID already exists
572
+ * @throws Will throw if `before` reference is invalid
573
+ */
574
+ addLayer(layerInit, options = {}) {
575
+ if (!layerInit || !layerInit.id) {
576
+ throw new Error('dia.Graph: Layer to add is invalid.');
577
+ }
578
+ if (this.hasLayer(layerInit.id)) {
579
+ throw new Error(`dia.Graph: Layer "${layerInit.id}" already exists.`);
580
+ }
581
+ const { before = null, index, ...insertOptions } = options;
582
+ insertOptions.graph = this.cid;
583
+
584
+ // Adding a new layer disables legacy mode
585
+ this.legacyMode = false;
586
+
587
+ const beforeId = this._getBeforeLayerIdFromOptions({ before, index });
588
+ this.layerCollection.insert(layerInit, beforeId, insertOptions);
589
+ },
590
+
591
+ /**
592
+ * @public
593
+ * Moves an existing layer to a new position in the layer stack.
594
+ * @param {string | GraphLayer} layerRef - ID or reference of the layer to move.
595
+ * @param {*} options
596
+ * @param {string | null} [options.before] - ID of the layer
597
+ * before which to insert the moved layer. If `null`, the layer is moved to the end.
598
+ * @param {number} [options.index] - Zero-based index to which to move the layer.
599
+ * @throws Will throw an error if the layer to move does not exist
600
+ * @throws Will throw an error if `before` reference is invalid
601
+ * @throws Will throw an error if both `before` and `index` options are provided
602
+ */
603
+ moveLayer(layerRef, options = {}) {
604
+ if (!layerRef || !this.hasLayer(layerRef)) {
605
+ throw new Error('dia.Graph: Layer to move does not exist.');
606
+ }
607
+ const layer = this.getLayer(layerRef);
608
+ const { before = null, index, ...insertOptions } = options;
609
+ insertOptions.graph = this.cid;
610
+
611
+ // Moving a layer disables legacy mode
612
+ this.legacyMode = false;
613
+
614
+ const beforeId = this._getBeforeLayerIdFromOptions({ before, index }, layer);
615
+ this.layerCollection.insert(layer, beforeId, insertOptions);
616
+ },
617
+
618
+ /**
619
+ * @public
620
+ * Removes an existing layer from the graph.
621
+ * @param {string | GraphLayer} layerRef - ID or reference of the layer to remove.
622
+ * @param {*} options
623
+ * @throws Will throw an error if no layer is provided
624
+ * @throws Will throw an error if the layer to remove does not exist
625
+ */
626
+ removeLayer(layerRef, options = {}) {
627
+ if (!layerRef) {
628
+ throw new Error('dia.Graph: No layer provided.');
629
+ }
434
630
 
435
- return this.get('cells').get(id);
631
+ // The layer must exist
632
+ const layerId = layerRef.id ? layerRef.id : layerRef;
633
+ const layer = this.getLayer(layerId);
634
+
635
+ // Prevent removing the default layer
636
+ // Note: if there is only one layer, it is also the default layer.
637
+ const { id: defaultLayerId } = this.getDefaultLayer();
638
+ if (layerId === defaultLayerId) {
639
+ throw new Error('dia.Graph: default layer cannot be removed.');
640
+ }
641
+
642
+ // A layer with cells cannot be removed
643
+ if (layer.cellCollection.length > 0) {
644
+ throw new Error(`dia.Graph: Layer "${layerId}" cannot be removed because it is not empty.`);
645
+ }
646
+
647
+ this.layerCollection.remove(layerId, { ...options, graph: this.cid });
436
648
  },
437
649
 
438
- getCells: function() {
650
+ getDefaultLayer() {
651
+ return this.layerCollection.get(this.defaultLayerId);
652
+ },
653
+
654
+ setDefaultLayer(layerRef, options = {}) {
655
+ if (!layerRef) {
656
+ throw new Error('dia.Graph: No default layer ID provided.');
657
+ }
658
+
659
+ // Make sure the layer exists
660
+ const defaultLayerId = layerRef.id ? layerRef.id : layerRef;
661
+ const defaultLayer = this.getLayer(defaultLayerId);
662
+
663
+ // If the default layer is not changing, do nothing
664
+ const currentDefaultLayerId = this.defaultLayerId;
665
+ if (defaultLayerId === currentDefaultLayerId) {
666
+ // The default layer stays the same
667
+ return;
668
+ }
669
+
670
+ // Get all cells that belong to the current default layer implicitly
671
+ const implicitLayerCells = this.getImplicitLayerCells();
439
672
 
440
- return this.get('cells').toArray();
673
+ // Set the new default layer ID
674
+ this.defaultLayerId = defaultLayerId;
675
+
676
+ const batchName = 'default-layer-change';
677
+ this.startBatch(batchName, options);
678
+
679
+ if (implicitLayerCells.length > 0) {
680
+ // Reassign any cells lacking an explicit layer to the new default layer.
681
+ // Do not sort yet, wait until all cells are moved.
682
+ const moveOptions = { ...options, sort: false };
683
+ for (const cell of implicitLayerCells) {
684
+ this.layerCollection.moveCellBetweenLayers(cell, defaultLayerId, moveOptions);
685
+ }
686
+ // Now sort the new default layer
687
+ if (options.sort !== false) {
688
+ defaultLayer.cellCollection.sort(options);
689
+ }
690
+ }
691
+
692
+ // Pretend to trigger the event on the layer itself.
693
+ // It will bubble up as `layer:default` event on the graph.
694
+ defaultLayer.trigger(defaultLayer.eventPrefix + 'default', defaultLayer, {
695
+ ...options,
696
+ previousDefaultLayerId: currentDefaultLayerId
697
+ });
698
+
699
+ this.stopBatch(batchName, options);
441
700
  },
442
701
 
443
- getElements: function() {
702
+ /**
703
+ * @protected
704
+ * @description Get all cells that do not have an explicit layer assigned.
705
+ * These cells belong to the default layer implicitly.
706
+ * @return {Array<dia.Cell>} Array of cells without an explicit layer.
707
+ */
708
+ getImplicitLayerCells() {
709
+ return this.getDefaultLayer().cellCollection.filter(cell => {
710
+ return cell.get(config.layerAttribute) == null;
711
+ });
712
+ },
713
+
714
+ getLayer(layerId) {
715
+ if (!this.hasLayer(layerId)) {
716
+ throw new Error(`dia.Graph: Layer "${layerId}" does not exist.`);
717
+ }
718
+ return this.layerCollection.get(layerId);
719
+ },
444
720
 
445
- return this.get('cells').toArray().filter(cell => cell.isElement());
721
+ hasLayer(layerRef) {
722
+ return this.layerCollection.has(layerRef);
446
723
  },
447
724
 
448
- getLinks: function() {
725
+ getLayers() {
726
+ return this.layerCollection.toArray();
727
+ },
449
728
 
450
- return this.get('cells').toArray().filter(cell => cell.isLink());
729
+ getCell: function(cellRef) {
730
+ return this.layerCollection.getCell(cellRef);
451
731
  },
452
732
 
453
- getFirstCell: function() {
733
+ getCells: function() {
734
+ return this.layerCollection.getCells();
735
+ },
454
736
 
455
- return this.get('cells').first();
737
+ getElements: function() {
738
+
739
+ return this.getCells().filter(cell => cell.isElement());
456
740
  },
457
741
 
458
- getLastCell: function() {
742
+ getLinks: function() {
743
+
744
+ return this.getCells().filter(cell => cell.isLink());
745
+ },
459
746
 
460
- return this.get('cells').last();
747
+ getFirstCell: function(layerId) {
748
+ let layer;
749
+ if (!layerId) {
750
+ // Get the first cell from the bottom-most layer
751
+ layer = this.getLayers().at(0);
752
+ } else {
753
+ layer = this.getLayer(layerId);
754
+ }
755
+ return layer.cellCollection.models.at(0);
756
+ },
757
+
758
+ getLastCell: function(layerId) {
759
+ let layer;
760
+ if (!layerId) {
761
+ // Get the last cell from the top-most layer
762
+ layer = this.getLayers().at(-1);
763
+ } else {
764
+ layer = this.getLayer(layerId);
765
+ }
766
+ return layer.cellCollection.models.at(-1);
461
767
  },
462
768
 
463
769
  // Get all inbound and outbound links connected to the cell `model`.
@@ -486,12 +792,13 @@ export const Graph = Model.extend({
486
792
  }
487
793
 
488
794
  function addOutbounds(graph, model) {
489
- util.forIn(graph.getOutboundEdges(model.id), function(_, edge) {
795
+ util.forIn(graph.topologyIndex.getOutboundEdges(model.id), function(_, edge) {
490
796
  // skip links that were already added
491
797
  // (those must be self-loop links)
492
798
  // (because they are inbound and outbound edges of the same two elements)
493
799
  if (edges[edge]) return;
494
800
  var link = graph.getCell(edge);
801
+ if (!link) return;
495
802
  links.push(link);
496
803
  edges[edge] = true;
497
804
  if (indirect) {
@@ -511,12 +818,13 @@ export const Graph = Model.extend({
511
818
  }
512
819
 
513
820
  function addInbounds(graph, model) {
514
- util.forIn(graph.getInboundEdges(model.id), function(_, edge) {
821
+ util.forIn(graph.topologyIndex.getInboundEdges(model.id), function(_, edge) {
515
822
  // skip links that were already added
516
823
  // (those must be self-loop links)
517
824
  // (because they are inbound and outbound edges of the same two elements)
518
825
  if (edges[edge]) return;
519
826
  var link = graph.getCell(edge);
827
+ if (!link) return;
520
828
  links.push(link);
521
829
  edges[edge] = true;
522
830
  if (indirect) {
@@ -551,7 +859,7 @@ export const Graph = Model.extend({
551
859
  embeddedCells.forEach(function(cell) {
552
860
  if (cell.isLink()) return;
553
861
  if (outbound) {
554
- util.forIn(this.getOutboundEdges(cell.id), function(exists, edge) {
862
+ util.forIn(this.topologyIndex.getOutboundEdges(cell.id), function(exists, edge) {
555
863
  if (!edges[edge]) {
556
864
  var edgeCell = this.getCell(edge);
557
865
  var { source, target } = edgeCell.attributes;
@@ -571,7 +879,7 @@ export const Graph = Model.extend({
571
879
  }.bind(this));
572
880
  }
573
881
  if (inbound) {
574
- util.forIn(this.getInboundEdges(cell.id), function(exists, edge) {
882
+ util.forIn(this.topologyIndex.getInboundEdges(cell.id), function(exists, edge) {
575
883
  if (!edges[edge]) {
576
884
  var edgeCell = this.getCell(edge);
577
885
  var { source, target } = edgeCell.attributes;
@@ -880,38 +1188,22 @@ export const Graph = Model.extend({
880
1188
 
881
1189
  // Get all the roots of the graph. Time complexity: O(|V|).
882
1190
  getSources: function() {
883
-
884
- var sources = [];
885
- util.forIn(this._nodes, function(exists, node) {
886
- if (!this._in[node] || util.isEmpty(this._in[node])) {
887
- sources.push(this.getCell(node));
888
- }
889
- }.bind(this));
890
- return sources;
1191
+ return this.topologyIndex.getSourceNodes().map(nodeId => this.getCell(nodeId));
891
1192
  },
892
1193
 
893
1194
  // Get all the leafs of the graph. Time complexity: O(|V|).
894
1195
  getSinks: function() {
895
-
896
- var sinks = [];
897
- util.forIn(this._nodes, function(exists, node) {
898
- if (!this._out[node] || util.isEmpty(this._out[node])) {
899
- sinks.push(this.getCell(node));
900
- }
901
- }.bind(this));
902
- return sinks;
1196
+ return this.topologyIndex.getSinkNodes().map(nodeId => this.getCell(nodeId));
903
1197
  },
904
1198
 
905
1199
  // Return `true` if `element` is a root. Time complexity: O(1).
906
1200
  isSource: function(element) {
907
-
908
- return !this._in[element.id] || util.isEmpty(this._in[element.id]);
1201
+ return this.topologyIndex.isSourceNode(element.id);
909
1202
  },
910
1203
 
911
1204
  // Return `true` if `element` is a leaf. Time complexity: O(1).
912
1205
  isSink: function(element) {
913
-
914
- return !this._out[element.id] || util.isEmpty(this._out[element.id]);
1206
+ return this.topologyIndex.isSinkNode(element.id);
915
1207
  },
916
1208
 
917
1209
  // Return `true` is `elementB` is a successor of `elementA`. Return `false` otherwise.
@@ -987,9 +1279,10 @@ export const Graph = Model.extend({
987
1279
  },
988
1280
 
989
1281
  // Remove links connected to the cell `model` completely.
990
- removeLinks: function(model, opt) {
991
-
992
- util.invoke(this.getConnectedLinks(model), 'remove', opt);
1282
+ removeLinks: function(cell, opt) {
1283
+ this.getConnectedLinks(cell).forEach(link => {
1284
+ this.layerCollection.removeCell(link, opt);
1285
+ });
993
1286
  },
994
1287
 
995
1288
  // Find all cells at given point