UNPKG

@antv/x6

Version:

JavaScript diagramming library that uses SVG and HTML for rendering

1,552 lines (1,365 loc) 38.9 kB
import { Basecoat, disposable, FunctionExt, type KeyValue } from '../common' import { type DijkstraAdjacencyList, type DijkstraWeight, dijkstra, } from '../common/algorithm' import { Rectangle, type RectangleLike } from '../geometry' import type { Graph } from '../graph' import { Cell, CellGetDescendantsOptions, CellMetadata, CellRemoveOptions, CellSetOptions, CellTranslateOptions, CellToJSONOptions, CellProperties, CellGetCellsBBoxOptions, } from './cell' import { Collection } from './collection' import type { CollectionAddOptions, CollectionSetOptions, CollectionRemoveOptions, CellEventArgs, NodeEventArgs, EdgeEventArgs, } from './collection' import { Edge, EdgeMetadata, EdgeSetOptions, TerminalCellData, TerminalCellLooseData, TerminalType, } from './edge' import { Node, NodeMetadata } from './node' import type { PointLike, KeyPoint } from '../types' const toStringTag = 'X6.Model' export class Model extends Basecoat<ModelEventArgs> { static isModel(instance: unknown): instance is Model { if (instance == null) { return false } if (instance instanceof Model) { return true } const tag = instance[Symbol.toStringTag] const model = instance as Model if ( (tag == null || tag === toStringTag) && typeof model.addNode === 'function' && typeof model.addEdge === 'function' && model.collection != null ) { return true } return false } static toJSON(cells: Cell[], options: ToJSONOptions = {}) { return { cells: cells.map((cell) => cell.toJSON(options)), } } static fromJSON(data: FromJSONData) { const cells: CellMetadata[] = [] if (Array.isArray(data)) { cells.push(...data) } else { if (data.cells) { cells.push(...data.cells) } if (data.nodes) { data.nodes.forEach((node) => { if (node.shape == null) { node.shape = 'rect' } cells.push(node) }) } if (data.edges) { data.edges.forEach((edge) => { if (edge.shape == null) { edge.shape = 'edge' } cells.push(edge) }) } } return cells.map((cell) => { const type = cell.shape if (type) { if (Node.registry.exist(type)) { return Node.create(cell) } if (Edge.registry.exist(type)) { return Edge.create(cell) } } throw new Error( 'The `shape` should be specified when creating a node/edge instance', ) }) } public readonly collection: Collection protected readonly batches: KeyValue<number> = {} protected readonly addings: WeakMap<Cell, boolean> = new WeakMap() public graph: Graph protected nodes: KeyValue<boolean> = {} protected edges: KeyValue<boolean> = {} protected outgoings: KeyValue<string[]> = {} protected incomings: KeyValue<string[]> = {} constructor(cells: Cell[] = []) { super() this.collection = new Collection(cells) this.setup() } notify<Key extends keyof ModelEventArgs>( name: Key, args: ModelEventArgs[Key], ): this notify(name: Exclude<string, keyof ModelEventArgs>, args: unknown): this notify<Key extends keyof ModelEventArgs>( name: Key, args: ModelEventArgs[Key], ) { this.trigger(name, args) const graph = this.graph if (graph) { if (name === 'sorted' || name === 'reseted' || name === 'updated') { graph.trigger(`model:${name}`, args) } else { graph.trigger(name, args) } } return this } protected setup() { const collection = this.collection collection.on('sorted', () => this.notify('sorted', null)) collection.on('updated', (args) => this.notify('updated', args)) collection.on('cell:change:zIndex', () => this.sortOnChangeZ()) collection.on('added', ({ cell }) => { this.onCellAdded(cell) }) collection.on('removed', (args) => { const cell = args.cell this.onCellRemoved(cell, args.options) // Should trigger remove-event manually after cell was removed. this.notify('cell:removed', args) if (cell.isNode()) { this.notify('node:removed', { ...args, node: cell }) } else if (cell.isEdge()) { this.notify('edge:removed', { ...args, edge: cell }) } }) collection.on('reseted', (args) => { this.onReset(args.current) this.notify('reseted', args) }) collection.on('edge:change:source', ({ edge }) => this.onEdgeTerminalChanged(edge, 'source'), ) collection.on('edge:change:target', ({ edge }) => { this.onEdgeTerminalChanged(edge, 'target') }) } protected sortOnChangeZ() { this.collection.sort() } protected onCellAdded(cell: Cell) { const cellId = cell.id if (cell.isEdge()) { // Auto update edge's parent cell.updateParent() this.edges[cellId] = true this.onEdgeTerminalChanged(cell, 'source') this.onEdgeTerminalChanged(cell, 'target') } else { this.nodes[cellId] = true } } protected onCellRemoved(cell: Cell, options: CollectionRemoveOptions) { const cellId = cell.id if (cell.isEdge()) { delete this.edges[cellId] const source = cell.getSource() as TerminalCellData const target = cell.getTarget() as TerminalCellData if (source?.cell) { const cache = this.outgoings[source.cell] const index = cache ? cache.indexOf(cellId) : -1 if (index >= 0) { cache.splice(index, 1) if (cache.length === 0) { delete this.outgoings[source.cell] } } } if (target?.cell) { const cache = this.incomings[target.cell] const index = cache ? cache.indexOf(cellId) : -1 if (index >= 0) { cache.splice(index, 1) if (cache.length === 0) { delete this.incomings[target.cell] } } } } else { delete this.nodes[cellId] } if (!options.clear) { if (options.disconnectEdges) { this.disconnectConnectedEdges(cell, options) } else { this.removeConnectedEdges(cell, options) } } if (cell.model === this) { cell.model = null } } protected onReset(cells: Cell[]) { this.nodes = {} this.edges = {} this.outgoings = {} this.incomings = {} cells.forEach((cell) => { this.onCellAdded(cell) }) } protected onEdgeTerminalChanged(edge: Edge, type: TerminalType) { const ref = type === 'source' ? this.outgoings : this.incomings const prev = edge.previous<TerminalCellLooseData>(type) if (prev?.cell) { const cellId = Cell.isCell(prev.cell) ? prev.cell.id : prev.cell const cache = ref[cellId] const index = cache ? cache.indexOf(edge.id) : -1 if (index >= 0) { cache.splice(index, 1) if (cache.length === 0) { delete ref[cellId] } } } const terminal = edge.getTerminal(type) as TerminalCellLooseData if (terminal?.cell) { const terminalId = Cell.isCell(terminal.cell) ? terminal.cell.id : terminal.cell const cache = ref[terminalId] || [] const index = cache.indexOf(edge.id) if (index === -1) { cache.push(edge.id) } ref[terminalId] = cache } } protected prepareCell(cell: Cell, options: CollectionAddOptions) { if (!cell.model && (!options || !options.dryrun)) { cell.model = this } if (cell.zIndex == null) { cell.setZIndex(this.getMaxZIndex() + 1, { silent: true }) } return cell } resetCells(cells: Cell[], options: CollectionSetOptions = {}) { // Do not update model at this time. Because if we just update the graph // with the same json-data, the edge will reference to the old nodes. cells.map((cell) => this.prepareCell(cell, { ...options, dryrun: true })) this.collection.reset(cells, options) // Update model and trigger edge update it's references cells.map((cell) => this.prepareCell(cell, { options })) return this } clear(options: CellSetOptions = {}) { const raw = this.getCells() if (raw.length === 0) { return this } const localOptions = { ...options, clear: true } this.batchUpdate( 'clear', () => { // The nodes come after the edges. const cells = raw.sort((a, b) => { const v1 = a.isEdge() ? 1 : 2 const v2 = b.isEdge() ? 1 : 2 return v1 - v2 }) while (cells.length > 0) { // Note that all the edges are removed first, so it's safe to // remove the nodes without removing the connected edges first. const cell = cells.shift() if (cell) { cell.remove(localOptions) } } }, localOptions, ) return this } addNode(metadata: Node | NodeMetadata, options: AddOptions = {}) { const node = Node.isNode(metadata) ? metadata : this.createNode(metadata) this.addCell(node, options) return node } updateNode(metadata: NodeMetadata, options: SetOptions = {}) { const node = this.createNode(metadata) const prop = node.getProp() node.dispose() return this.updateCell(prop, options) } createNode(metadata: NodeMetadata) { return Node.create(metadata) } addEdge(metadata: EdgeMetadata | Edge, options: AddOptions = {}) { const edge = Edge.isEdge(metadata) ? metadata : this.createEdge(metadata) this.addCell(edge, options) return edge } createEdge(metadata: EdgeMetadata) { return Edge.create(metadata) } updateEdge(metadata: EdgeMetadata, options: SetOptions = {}) { const edge = this.createEdge(metadata) const prop = edge.getProp() edge.dispose() return this.updateCell(prop, options) } addCell(cell: Cell | Cell[], options: AddOptions = {}) { if (Array.isArray(cell)) { return this.addCells(cell, options) } if (!this.collection.has(cell) && !this.addings.has(cell)) { this.addings.set(cell, true) this.collection.add(this.prepareCell(cell, options), options) cell.eachChild((child) => this.addCell(child, options)) this.addings.delete(cell) } return this } addCells(cells: Cell[], options: AddOptions = {}) { const count = cells.length if (count === 0) { return this } const localOptions = { ...options, position: count - 1, maxPosition: count - 1, } this.startBatch('add', { ...localOptions, cells }) cells.forEach((cell) => { this.addCell(cell, localOptions) localOptions.position -= 1 }) this.stopBatch('add', { ...localOptions, cells }) return this } updateCell(prop: CellProperties, options: SetOptions = {}): boolean { const existing = prop.id && this.getCell(prop.id) if (existing) { return this.batchUpdate( 'update', () => { Object.entries(prop).forEach(([key, val]) => { existing.setProp(key, val, options) }) return true }, prop, ) } return false } removeCell(cellId: string, options?: CollectionRemoveOptions): Cell | null removeCell(cell: Cell, options?: CollectionRemoveOptions): Cell | null removeCell( obj: Cell | string, options: CollectionRemoveOptions = {}, ): Cell | null { const cell = typeof obj === 'string' ? this.getCell(obj) : obj if (cell && this.has(cell)) { return this.collection.remove(cell, options) } return null } updateCellId(cell: Cell, newId: string) { if (cell.id === newId) return this.startBatch('update', { id: newId }) cell.prop('id', newId) const newCell = cell.clone({ keepId: true }) this.addCell(newCell) // update connected edge terminal const edges = this.getConnectedEdges(cell) edges.forEach((edge) => { const sourceCell = edge.getSourceCell() const targetCell = edge.getTargetCell() if (sourceCell === cell) { edge.setSource({ ...edge.getSource(), cell: newId, }) } if (targetCell === cell) { edge.setTarget({ ...edge.getTarget(), cell: newId, }) } }) this.removeCell(cell) this.stopBatch('update', { id: newId }) return newCell } removeCells(cells: (Cell | string)[], options: CellRemoveOptions = {}) { if (cells.length) { return this.batchUpdate('remove', () => { return cells.map((cell) => this.removeCell(cell as Cell, options)) }) } return [] } removeConnectedEdges(cell: Cell | string, options: CellRemoveOptions = {}) { const edges = this.getConnectedEdges(cell) edges.forEach((edge) => { edge.remove(options) }) return edges } disconnectConnectedEdges(cell: Cell | string, options: EdgeSetOptions = {}) { const cellId = typeof cell === 'string' ? cell : cell.id this.getConnectedEdges(cell).forEach((edge) => { const sourceCellId = edge.getSourceCellId() const targetCellId = edge.getTargetCellId() if (sourceCellId === cellId) { edge.setSource({ x: 0, y: 0 }, options) } if (targetCellId === cellId) { edge.setTarget({ x: 0, y: 0 }, options) } }) } has(id: string): boolean has(cell: Cell): boolean has(obj: string | Cell): boolean { return this.collection.has(obj) } total() { return this.collection.length } indexOf(cell: Cell) { return this.collection.indexOf(cell) } /** * Returns a cell from the graph by its id. */ getCell<T extends Cell = Cell>(id: string) { return this.collection.get(id) as T } /** * Returns all the nodes and edges in the graph. */ getCells() { return this.collection.toArray() } /** * Returns the first cell (node or edge) in the graph. The first cell is * defined as the cell with the lowest `zIndex`. */ getFirstCell() { return this.collection.first() } /** * Returns the last cell (node or edge) in the graph. The last cell is * defined as the cell with the highest `zIndex`. */ getLastCell() { return this.collection.last() } /** * Returns the lowest `zIndex` value in the graph. */ getMinZIndex() { const first = this.collection.first() return first ? first.getZIndex() || 0 : 0 } /** * Returns the highest `zIndex` value in the graph. */ getMaxZIndex() { const last = this.collection.last() return last ? last.getZIndex() || 0 : 0 } protected getCellsFromCache<T extends Cell = Cell>(cache: { [key: string]: boolean }) { return cache ? Object.keys(cache) .map((id) => this.getCell<T>(id)) .filter((cell) => cell != null) : [] } /** * Returns all the nodes in the graph. */ getNodes() { return this.getCellsFromCache<Node>(this.nodes) } /** * Returns all the edges in the graph. */ getEdges() { return this.getCellsFromCache<Edge>(this.edges) } /** * Returns all outgoing edges for the node. */ getOutgoingEdges(cell: Cell | string) { const cellId = typeof cell === 'string' ? cell : cell.id const cellIds = this.outgoings[cellId] return cellIds ? cellIds .map((id) => this.getCell(id) as Edge) .filter((cell) => cell?.isEdge()) : null } /** * Returns all incoming edges for the node. */ getIncomingEdges(cell: Cell | string) { const cellId = typeof cell === 'string' ? cell : cell.id const cellIds = this.incomings[cellId] return cellIds ? cellIds .map((id) => this.getCell(id) as Edge) .filter((cell) => cell?.isEdge()) : null } /** * Returns edges connected with cell. */ getConnectedEdges( cell: Cell | string, options: GetConnectedEdgesOptions = {}, ) { const result: Edge[] = [] const node = typeof cell === 'string' ? this.getCell(cell) : cell if (node == null) { return result } const cache: { [id: string]: boolean } = {} const indirect = options.indirect let incoming = options.incoming let outgoing = options.outgoing if (incoming == null && outgoing == null) { incoming = outgoing = true } const collect = (cell: Cell, isOutgoing: boolean) => { const edges = isOutgoing ? this.getOutgoingEdges(cell) : this.getIncomingEdges(cell) if (edges != null) { edges.forEach((edge) => { if (cache[edge.id]) { return } result.push(edge) cache[edge.id] = true if (indirect) { if (incoming) { collect(edge, false) } if (outgoing) { collect(edge, true) } } }) } if (indirect && cell.isEdge()) { const terminal = isOutgoing ? cell.getTargetCell() : cell.getSourceCell() if (terminal?.isEdge()) { if (!cache[terminal.id]) { result.push(terminal) collect(terminal, isOutgoing) } } } } if (outgoing) { collect(node, true) } if (incoming) { collect(node, false) } if (options.deep) { const descendants = node.getDescendants({ deep: true }) const embedsCache: KeyValue<boolean> = {} descendants.forEach((cell) => { if (cell.isNode()) { embedsCache[cell.id] = true } }) const collectSub = (cell: Cell, isOutgoing: boolean) => { const edges = isOutgoing ? this.getOutgoingEdges(cell.id) : this.getIncomingEdges(cell.id) if (edges != null) { edges.forEach((edge) => { if (!cache[edge.id]) { const sourceCell = edge.getSourceCell() const targetCell = edge.getTargetCell() if ( !options.enclosed && sourceCell && embedsCache[sourceCell.id] && targetCell && embedsCache[targetCell.id] ) { return } result.push(edge) cache[edge.id] = true } }) } } descendants.forEach((cell) => { if (cell.isEdge()) { return } if (outgoing) { collectSub(cell, true) } if (incoming) { collectSub(cell, false) } }) } return result } protected isBoundary(cell: Cell | string, isOrigin: boolean) { const node = typeof cell === 'string' ? this.getCell(cell) : cell const arr = isOrigin ? this.getIncomingEdges(node) : this.getOutgoingEdges(node) return arr == null || arr.length === 0 } protected getBoundaryNodes(isOrigin: boolean) { const result: Node[] = [] Object.keys(this.nodes).forEach((nodeId) => { if (this.isBoundary(nodeId, isOrigin)) { const node = this.getCell<Node>(nodeId) if (node) { result.push(node) } } }) return result } /** * Returns an array of all the roots of the graph. */ getRoots() { return this.getBoundaryNodes(true) } /** * Returns an array of all the leafs of the graph. */ getLeafs() { return this.getBoundaryNodes(false) } /** * Returns `true` if the node is a root node, i.e. there is no edges * coming to the node. */ isRoot(cell: Cell | string) { return this.isBoundary(cell, true) } /** * Returns `true` if the node is a leaf node, i.e. there is no edges * going out from the node. */ isLeaf(cell: Cell | string) { return this.isBoundary(cell, false) } /** * Returns all the neighbors of node in the graph. Neighbors are all * the nodes connected to node via either incoming or outgoing edge. */ getNeighbors(cell: Cell, options: GetNeighborsOptions = {}) { let incoming = options.incoming let outgoing = options.outgoing if (incoming == null && outgoing == null) { incoming = outgoing = true } const edges = this.getConnectedEdges(cell, options) const map = edges.reduce<KeyValue<Cell>>((memo, edge) => { const hasLoop = edge.hasLoop(options) const sourceCell = edge.getSourceCell() const targetCell = edge.getTargetCell() if ( incoming && sourceCell && sourceCell.isNode() && !memo[sourceCell.id] ) { if ( hasLoop || (sourceCell !== cell && (!options.deep || !sourceCell.isDescendantOf(cell))) ) { memo[sourceCell.id] = sourceCell } } if ( outgoing && targetCell && targetCell.isNode() && !memo[targetCell.id] ) { if ( hasLoop || (targetCell !== cell && (!options.deep || !targetCell.isDescendantOf(cell))) ) { memo[targetCell.id] = targetCell } } return memo }, {}) if (cell.isEdge()) { if (incoming) { const sourceCell = cell.getSourceCell() if (sourceCell?.isNode() && !map[sourceCell.id]) { map[sourceCell.id] = sourceCell } } if (outgoing) { const targetCell = cell.getTargetCell() if (targetCell?.isNode() && !map[targetCell.id]) { map[targetCell.id] = targetCell } } } return Object.keys(map).map((id) => map[id]) } /** * Returns `true` if `cell2` is a neighbor of `cell1`. */ isNeighbor(cell1: Cell, cell2: Cell, options: GetNeighborsOptions = {}) { let incoming = options.incoming let outgoing = options.outgoing if (incoming == null && outgoing == null) { incoming = outgoing = true } return this.getConnectedEdges(cell1, options).some((edge) => { const sourceCell = edge.getSourceCell() const targetCell = edge.getTargetCell() if (incoming && sourceCell && sourceCell.id === cell2.id) { return true } if (outgoing && targetCell && targetCell.id === cell2.id) { return true } return false }) } getSuccessors(cell: Cell, options: GetPredecessorsOptions = {}) { const successors: Cell[] = [] this.search( cell, (curr, distance) => { if (curr !== cell && this.matchDistance(distance, options.distance)) { successors.push(curr) } }, { ...options, outgoing: true }, ) return successors } /** * Returns `true` if `cell2` is a successor of `cell1`. */ isSuccessor(cell1: Cell, cell2: Cell, options: GetPredecessorsOptions = {}) { let result = false this.search( cell1, (curr, distance) => { if ( curr === cell2 && curr !== cell1 && this.matchDistance(distance, options.distance) ) { result = true return false } }, { ...options, outgoing: true }, ) return result } getPredecessors(cell: Cell, options: GetPredecessorsOptions = {}) { const predecessors: Cell[] = [] this.search( cell, (curr, distance) => { if (curr !== cell && this.matchDistance(distance, options.distance)) { predecessors.push(curr) } }, { ...options, incoming: true }, ) return predecessors } /** * Returns `true` if `cell2` is a predecessor of `cell1`. */ isPredecessor( cell1: Cell, cell2: Cell, options: GetPredecessorsOptions = {}, ) { let result = false this.search( cell1, (curr, distance) => { if ( curr === cell2 && curr !== cell1 && this.matchDistance(distance, options.distance) ) { result = true return false } }, { ...options, incoming: true }, ) return result } protected matchDistance( distance: number, preset?: number | number[] | ((d: number) => boolean), ) { if (preset == null) { return true } if (typeof preset === 'function') { return preset(distance) } if (Array.isArray(preset) && preset.includes(distance)) { return true } return distance === preset } /** * Returns the common ancestor of the passed cells. */ getCommonAncestor(...cells: (Cell | Cell[] | null | undefined)[]) { const arr: Cell[] = [] cells.forEach((item) => { if (item) { if (Array.isArray(item)) { arr.push(...item) } else { arr.push(item) } } }) return Cell.getCommonAncestor(...arr) } /** * Returns an array of cells that result from finding nodes/edges that * are connected to any of the cells in the cells array. This function * loops over cells and if the current cell is a edge, it collects its * source/target nodes; if it is an node, it collects its incoming and * outgoing edges if both the edge terminal (source/target) are in the * cells array. */ getSubGraph(cells: Cell[], options: GetSubgraphOptions = {}) { const subgraph: Cell[] = [] const cache: KeyValue<Cell> = {} const nodes: Node[] = [] const edges: Edge[] = [] const collect = (cell: Cell) => { if (!cache[cell.id]) { subgraph.push(cell) cache[cell.id] = cell if (cell.isEdge()) { edges.push(cell) } if (cell.isNode()) { nodes.push(cell) } } } cells.forEach((cell) => { collect(cell) if (options.deep) { const descendants = cell.getDescendants({ deep: true }) descendants.forEach((descendant) => { collect(descendant) }) } }) edges.forEach((edge) => { // For edges, include their source & target const sourceCell = edge.getSourceCell() const targetCell = edge.getTargetCell() if (sourceCell && !cache[sourceCell.id]) { subgraph.push(sourceCell) cache[sourceCell.id] = sourceCell if (sourceCell.isNode()) { nodes.push(sourceCell) } } if (targetCell && !cache[targetCell.id]) { subgraph.push(targetCell) cache[targetCell.id] = targetCell if (targetCell.isNode()) { nodes.push(targetCell) } } }) nodes.forEach((node) => { // For nodes, include their connected edges if their source/target // is in the subgraph. const edges = this.getConnectedEdges(node, options) edges.forEach((edge) => { const sourceCell = edge.getSourceCell() const targetCell = edge.getTargetCell() if ( !cache[edge.id] && sourceCell && cache[sourceCell.id] && targetCell && cache[targetCell.id] ) { subgraph.push(edge) cache[edge.id] = edge } }) }) return subgraph } /** * Clones the whole subgraph (including all the connected links whose * source/target is in the subgraph). If `options.deep` is `true`, also * take into account all the embedded cells of all the subgraph cells. * * Returns a map of the form: { [original cell ID]: [clone] }. */ cloneSubGraph(cells: Cell[], options: GetSubgraphOptions = {}) { const subgraph = this.getSubGraph(cells, options) return this.cloneCells(subgraph) } cloneCells(cells: Cell[]) { return Cell.cloneCells(cells) } /** * Returns an array of nodes whose bounding box contains point. * Note that there can be more then one node as nodes might overlap. */ getNodesFromPoint(x: number, y: number): Node[] getNodesFromPoint(p: PointLike): Node[] getNodesFromPoint(x: number | PointLike, y?: number) { const p = typeof x === 'number' ? { x, y: y || 0 } : x return this.getNodes().filter((node) => { return node.getBBox().containsPoint(p) }) } /** * Returns an array of nodes whose bounding box top/left coordinate * falls into the rectangle. */ getNodesInArea( x: number, y: number, w: number, h: number, options?: GetCellsInAreaOptions, ): Node[] getNodesInArea(rect: RectangleLike, options?: GetCellsInAreaOptions): Node[] getNodesInArea( x: number | RectangleLike, y?: number | GetCellsInAreaOptions, w?: number, h?: number, options?: GetCellsInAreaOptions, ): Node[] { const rect = typeof x === 'number' ? new Rectangle(x, y as number, w as number, h as number) : Rectangle.create(x) const opts = typeof x === 'number' ? options : (y as GetCellsInAreaOptions) const strict = opts?.strict return this.getNodes().filter((node) => { const angle = node.angle() const bbox = node.getBBox().bbox(angle) return strict ? rect.containsRect(bbox) : rect.isIntersectWithRect(bbox) }) } /** * Returns an array of edges whose bounding box top/left coordinate * falls into the rectangle. */ getEdgesInArea( x: number, y: number, w: number, h: number, options?: GetCellsInAreaOptions, ): Edge[] getEdgesInArea(rect: RectangleLike, options?: GetCellsInAreaOptions): Edge[] getEdgesInArea( x: number | RectangleLike, y?: number | GetCellsInAreaOptions, w?: number, h?: number, options?: GetCellsInAreaOptions, ): Edge[] { const rect = typeof x === 'number' ? new Rectangle(x, y as number, w as number, h as number) : Rectangle.create(x) const opts = typeof x === 'number' ? options : (y as GetCellsInAreaOptions) const strict = opts?.strict return this.getEdges().filter((edge) => { const bbox = edge.getBBox() if (bbox.width === 0) { bbox.inflate(1, 0) } else if (bbox.height === 0) { bbox.inflate(0, 1) } return strict ? rect.containsRect(bbox) : rect.isIntersectWithRect(bbox) }) } getNodesUnderNode( node: Node, options: { by?: 'bbox' | KeyPoint } = {}, ) { const bbox = node.getBBox() const nodes = options.by == null || options.by === 'bbox' ? this.getNodesInArea(bbox) : this.getNodesFromPoint(bbox[options.by]) return nodes.filter( (curr) => node.id !== curr.id && !curr.isDescendantOf(node), ) } /** * Returns the bounding box that surrounds all cells in the graph. */ getAllCellsBBox() { return this.getCellsBBox(this.getCells()) } /** * Returns the bounding box that surrounds all the given cells. */ getCellsBBox(cells: Cell[], options: CellGetCellsBBoxOptions = {}) { return Cell.getCellsBBox(cells, options) } // #region search search(cell: Cell, iterator: SearchIterator, options: SearchOptions = {}) { if (options.breadthFirst) { this.breadthFirstSearch(cell, iterator, options) } else { this.depthFirstSearch(cell, iterator, options) } } breadthFirstSearch( cell: Cell, iterator: SearchIterator, options: GetNeighborsOptions = {}, ) { const queue: Cell[] = [] const visited: KeyValue<boolean> = {} const distance: KeyValue<number> = {} queue.push(cell) distance[cell.id] = 0 while (queue.length > 0) { const next = queue.shift() if (next == null || visited[next.id]) { continue } visited[next.id] = true if (FunctionExt.call(iterator, this, next, distance[next.id]) === false) { continue } const neighbors = this.getNeighbors(next, options) neighbors.forEach((neighbor) => { distance[neighbor.id] = distance[next.id] + 1 queue.push(neighbor) }) } } depthFirstSearch( cell: Cell, iterator: SearchIterator, options: GetNeighborsOptions = {}, ) { const queue: Cell[] = [] const visited: KeyValue<boolean> = {} const distance: KeyValue<number> = {} queue.push(cell) distance[cell.id] = 0 while (queue.length > 0) { const next = queue.pop() if (next == null || visited[next.id]) { continue } visited[next.id] = true if (FunctionExt.call(iterator, this, next, distance[next.id]) === false) { continue } const neighbors = this.getNeighbors(next, options) const lastIndex = queue.length neighbors.forEach((neighbor) => { distance[neighbor.id] = distance[next.id] + 1 queue.splice(lastIndex, 0, neighbor) }) } } // #endregion // #region shortest path /** * * Returns an array of IDs of nodes on the shortest * path between source and target. */ getShortestPath( source: Cell | string, target: Cell | string, options: GetShortestPathOptions = {}, ) { const adjacencyList: DijkstraAdjacencyList = {} this.getEdges().forEach((edge) => { const sourceId = edge.getSourceCellId() const targetId = edge.getTargetCellId() if (sourceId && targetId) { if (!adjacencyList[sourceId]) { adjacencyList[sourceId] = [] } if (!adjacencyList[targetId]) { adjacencyList[targetId] = [] } adjacencyList[sourceId].push(targetId) if (!options.directed) { adjacencyList[targetId].push(sourceId) } } }) const sourceId = typeof source === 'string' ? source : source.id const previous = dijkstra(adjacencyList, sourceId, options.weight) const path = [] let targetId = typeof target === 'string' ? target : target.id if (previous[targetId]) { path.push(targetId) } while (previous[targetId]) { const prev = previous[targetId] path.unshift(prev) targetId = prev } return path } // #endregion // #region transform /** * Translate all cells in the graph by `tx` and `ty` pixels. */ translate(tx: number, ty: number, options: CellTranslateOptions) { this.getCells() .filter((cell) => !cell.hasParent()) .forEach((cell) => { cell.translate(tx, ty, options) }) return this } resize(width: number, height: number, options: CellSetOptions) { return this.resizeCells(width, height, this.getCells(), options) } resizeCells( width: number, height: number, cells: Cell[], options: CellSetOptions = {}, ) { const bbox = this.getCellsBBox(cells) if (bbox) { const sx = Math.max(width / bbox.width, 0) const sy = Math.max(height / bbox.height, 0) const origin = bbox.getOrigin() cells.forEach((cell) => { cell.scale(sx, sy, origin, options) }) } return this } // #endregion // #region serialize/deserialize toJSON(options: ToJSONOptions = {}) { return Model.toJSON(this.getCells(), options) } parseJSON(data: FromJSONData) { return Model.fromJSON(data) } fromJSON(data: FromJSONData, options: FromJSONOptions = {}) { let cells: Cell[] = [] if (!options.diff) { cells = this.parseJSON(data) } else { const { nodes = [], edges = [], ...rest } = data as { nodes?: NodeMetadata[] edges?: EdgeMetadata[] } const updateNodes = nodes.filter((node) => !this.nodes[node.id]) || [] const updateEdges = edges.filter((edge) => !this.edges[edge.id]) || [] cells = this.parseJSON({ ...rest, nodes: updateNodes, edges: updateEdges, }) } this.resetCells(cells, options) return this } // #endregion // #region batch startBatch(name: BatchName, data: KeyValue = {}) { this.batches[name] = (this.batches[name] || 0) + 1 this.notify('batch:start', { name, data }) return this } stopBatch(name: BatchName, data: KeyValue = {}) { this.batches[name] = (this.batches[name] || 0) - 1 this.notify('batch:stop', { name, data }) return this } batchUpdate<T>(name: BatchName, execute: () => T, data: KeyValue = {}) { this.startBatch(name, data) const result = execute() this.stopBatch(name, data) return result } hasActiveBatch( name: BatchName | BatchName[] = Object.keys(this.batches) as BatchName[], ) { const names = Array.isArray(name) ? name : [name] return names.some((batch) => this.batches[batch] > 0) } // #endregion @disposable() dispose() { this.collection.dispose() } } export interface SetOptions extends CollectionSetOptions {} export interface AddOptions extends CollectionAddOptions {} export interface RemoveOptions extends CollectionRemoveOptions {} export interface FromJSONOptions extends CollectionSetOptions { // whether to perform a diff update diff?: boolean } export type FromJSONData = | (NodeMetadata | EdgeMetadata)[] | (Partial<ReturnType<typeof Model.toJSON>> & { nodes?: NodeMetadata[] edges?: EdgeMetadata[] }) export type ToJSONData = { cells: CellProperties[] } export interface GetCellsInAreaOptions { strict?: boolean } export interface SearchOptions extends GetNeighborsOptions { breadthFirst?: boolean } export type SearchIterator = ( this: Model, cell: Cell, distance: number, ) => boolean | void export interface GetNeighborsOptions { deep?: boolean incoming?: boolean outgoing?: boolean indirect?: boolean } export interface GetConnectedEdgesOptions extends GetNeighborsOptions { enclosed?: boolean } export interface GetSubgraphOptions { deep?: boolean } export interface GetShortestPathOptions { directed?: boolean weight?: DijkstraWeight } export interface GetPredecessorsOptions extends CellGetDescendantsOptions { distance?: number | number[] | ((distance: number) => boolean) } export interface ModelEventArgs extends CellEventArgs, NodeEventArgs, EdgeEventArgs { 'batch:start': { name: BatchName | string data: KeyValue } 'batch:stop': { name: BatchName | string data: KeyValue } sorted: null reseted: { current: Cell[] previous: Cell[] options: CollectionSetOptions } updated: { added: Cell[] merged: Cell[] removed: Cell[] options: CollectionSetOptions } } export type BatchName = | 'update' | 'add' | 'remove' | 'clear' | 'to-back' | 'to-front' | 'scale' | 'resize' | 'rotate' | 'translate' | 'mouse' | 'layout' | 'add-edge' | 'fit-embeds' | 'dnd' | 'halo' | 'cut' | 'paste' | 'knob' | 'add-vertex' | 'move-anchor' | 'move-vertex' | 'move-segment' | 'move-arrowhead' | 'move-selection' export interface ToJSONOptions extends CellToJSONOptions {}