UNPKG

ngx-diagrams

Version:

> Light Weight, Modular, Typed Diagram Engine for Angular

1,527 lines (1,507 loc) 108 kB
import { __decorate, __metadata } from 'tslib'; import { DOCUMENT, CommonModule } from '@angular/common'; import { EventEmitter, Component, ChangeDetectionStrategy, Inject, NgZone, Renderer2, ChangeDetectorRef, ElementRef, Input, Output, ViewChild, ViewContainerRef, Directive, TemplateRef, NgModule, ɵɵdefineInjectable, ɵɵinject, ComponentFactoryResolver, RendererFactory2, Injectable } from '@angular/core'; import { BehaviorSubject, Subject, ReplaySubject, combineLatest, fromEvent, merge } from 'rxjs'; import { tap, distinctUntilChanged, shareReplay, takeUntil, map, filter, take, switchMap, delay } from 'rxjs/operators'; import SVGPath from 'paths-js/path'; import { Heuristic, AStarFinder, DiagonalMovement, Grid, Util } from 'pathfinding'; import { graphlib, layout } from 'dagre'; class BaseAction { constructor(mouseX, mouseY) { this.mouseX = mouseX; this.mouseY = mouseY; this.ms = new Date().getTime(); } } class MoveCanvasAction extends BaseAction { constructor(mouseX, mouseY, diagramModel) { super(mouseX, mouseY); this.initialOffsetX = diagramModel.getOffsetX(); this.initialOffsetY = diagramModel.getOffsetY(); } } class SelectingAction extends BaseAction { constructor(mouseX, mouseY) { super(mouseX, mouseY); this.mouseX2 = mouseX; this.mouseY2 = mouseY; } getBoxDimensions() { this.dimensions = { left: this.mouseX2 > this.mouseX ? this.mouseX : this.mouseX2, top: this.mouseY2 > this.mouseY ? this.mouseY : this.mouseY2, width: Math.abs(this.mouseX2 - this.mouseX), height: Math.abs(this.mouseY2 - this.mouseY), right: this.mouseX2 < this.mouseX ? this.mouseX : this.mouseX2, bottom: this.mouseY2 < this.mouseY ? this.mouseY : this.mouseY2, }; return this.dimensions; } containsElement({ x, y }, diagramModel) { const z = diagramModel.getZoomLevel() / 100.0; const dimensions = this.getBoxDimensions(); return (x * z + diagramModel.getOffsetX() > dimensions.left && x * z + diagramModel.getOffsetX() < dimensions.right && y * z + diagramModel.getOffsetY() > dimensions.top && y * z + diagramModel.getOffsetY() < dimensions.bottom); } } const ROUTING_SCALING_FACTOR = 10; class PathFinding { constructor(diagramEngine, heuristic = Heuristic.manhattan) { this.diagramEngine = diagramEngine; this.pathFinderInstance = new AStarFinder({ heuristic, diagonalMovement: DiagonalMovement.Always, weight: 0, }); } /** * Taking as argument a fully unblocked walking matrix, this method * finds a direct path from point A to B. */ calculateDirectPath(from, to) { const matrix = this.diagramEngine.getCanvasMatrix(); const grid = new Grid(matrix); const fromX = this.diagramEngine.translateRoutingX(Math.floor(from.x / ROUTING_SCALING_FACTOR)); const toX = this.diagramEngine.translateRoutingX(Math.floor(to.x / ROUTING_SCALING_FACTOR)); const fromY = this.diagramEngine.translateRoutingX(Math.floor(from.y / ROUTING_SCALING_FACTOR)); const toY = this.diagramEngine.translateRoutingX(Math.floor(to.y / ROUTING_SCALING_FACTOR)); const path = this.pathFinderInstance.findPath(fromX, fromY, toX, toY, grid); return path; } /** * Using @link{#calculateDirectPath}'s result as input, we here * determine the first walkable point found in the matrix that includes * blocked paths. */ calculateLinkStartEndCoords(matrix, path) { const startIndex = path.findIndex((point) => matrix[point[1]][point[0]] === 0); const endIndex = path.length - 1 - path .slice() .reverse() .findIndex((point) => matrix[point[1]][point[0]] === 0); // are we trying to create a path exclusively through blocked areas? // if so, let's fallback to the linear routing if (startIndex === -1 || endIndex === -1) { return undefined; } const pathToStart = path.slice(0, startIndex); const pathToEnd = path.slice(endIndex); return { start: { x: path[startIndex][0], y: path[startIndex][1], }, end: { x: path[endIndex][0], y: path[endIndex][1], }, pathToStart, pathToEnd, }; } /** * Puts everything together: merges the paths from/to the centre of the ports, * with the path calculated around other elements. */ calculateDynamicPath(routingMatrix, start, end, pathToStart, pathToEnd) { // generate the path based on the matrix with obstacles const grid = new Grid(routingMatrix); const dynamicPath = this.pathFinderInstance.findPath(start.x, start.y, end.x, end.y, grid); // aggregate everything to have the calculated path ready for rendering const pathCoords = pathToStart .concat(dynamicPath, pathToEnd) .map((coords) => [ this.diagramEngine.translateRoutingX(coords[0], true), this.diagramEngine.translateRoutingY(coords[1], true), ]); return Util.compressPath(pathCoords); } } // eslint-disable no-bitwise var LOG_LEVEL; (function (LOG_LEVEL) { LOG_LEVEL[LOG_LEVEL["LOG"] = 0] = "LOG"; LOG_LEVEL[LOG_LEVEL["ERROR"] = 1] = "ERROR"; })(LOG_LEVEL || (LOG_LEVEL = {})); // @internal let __DEV__ = true; // @internal let __LOG__ = LOG_LEVEL.ERROR; function enableDiagramProdMode() { __DEV__ = false; } // @internal function setLogLevel(level) { __LOG__ = level; } // @internal function isDev() { return __DEV__; } // @internal function log(message, level = LOG_LEVEL.LOG, ...args) { if (isDev() && __LOG__ === level) { if (__LOG__ === LOG_LEVEL.ERROR) { console.error(message, ...args); } console.log(message, ...args); } } /** * rxjs log operator * @internal */ function withLog(message, level = LOG_LEVEL.LOG, ...args) { return (source) => isDev() ? source.pipe(tap((val) => log(message, level, val, ...args))) : source; } /** * rxjs entity properties operator * @internal */ function entityProperty(destroyedNotifier, replayBy = 1, logMessage = '') { return (source) => source.pipe(distinctUntilChanged((a, b) => a instanceof Map || b instanceof Map ? false : a === b), shareReplay(replayBy), withLog(logMessage), takeUntil(destroyedNotifier)); } /** * Generates a unique ID */ function UID() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } function isArray(val) { return Array.isArray(val); } function isString(val) { return typeof val === 'string'; } function isFunction(val) { return typeof val === 'function'; } // @internal function isNil(v) { return v === null || v === undefined; } function coerceArray(value) { if (isNil(value)) { return []; } return Array.isArray(value) ? value : [value]; } function isEmptyArray(arr) { return !arr || !isArray(arr) || arr.length === 0; } function mapToArray(map) { const result = []; for (const key in map) { if (!isNil(map[key])) { result.push(map[key]); } } return result; } function mapToEntries(map) { const result = []; for (const key in map) { result.push([key, map[key]]); } return result; } function unique(arr) { return [...new Set(arr)]; } function arrayToMap(arr) { const result = {}; for (const val of arr) { if (!isNil(val)) { result[val.id] = val; } } return result; } function generateLinePath(firstPoint, lastPoint) { return `M${firstPoint.x$},${firstPoint.y} L ${lastPoint.x$},${lastPoint.y}`; } function generateCurvePath(firstPoint, lastPoint, curvy = 0) { const isHorizontal = Math.abs(firstPoint.x - lastPoint.x) > Math.abs(firstPoint.y - lastPoint.y); const curvyX = isHorizontal ? curvy : 0; const curvyY = isHorizontal ? 0 : curvy; return `M${firstPoint.x},${firstPoint.y} C ${firstPoint.x + curvyX},${firstPoint.y + curvyY} ${lastPoint.x - curvyX},${lastPoint.y - curvyY} ${lastPoint.x},${lastPoint.y}`; } function generateDynamicPath(pathCoords) { let path = SVGPath(); path = path.moveto(pathCoords[0][0] * ROUTING_SCALING_FACTOR, pathCoords[0][1] * ROUTING_SCALING_FACTOR); pathCoords.slice(1).forEach((coords) => { path = path.lineto(coords[0] * ROUTING_SCALING_FACTOR, coords[1] * ROUTING_SCALING_FACTOR); }); return path.print(); } function OutsideZone(targetClass, functionName, descriptor) { const source = descriptor.value; descriptor.value = function (...data) { if (!this.ngZone) { throw new Error("Class with 'OutsideZone' decorator should have 'ngZone' class property with 'NgZone' class."); } return this.ngZone.runOutsideAngular(() => source.call(this, ...data)); }; return descriptor; } class ValueState { constructor(value, operator) { this.stream$ = new BehaviorSubject(value); this.value$ = operator ? this.stream$.pipe(operator) : this.stream$.asObservable(); } get value() { return this.stream$.getValue(); } set(value) { this.stream$.next(value); return this; } emit() { this.stream$.next(this.value); } select(project) { const mapFn = project || ((v) => v); return this.value$.pipe(map((value) => mapFn(value)), distinctUntilChanged()); } } function createValueState(value, operator) { return new ValueState(value, operator); } class EntityState extends ValueState { constructor(value, entityPipe) { super(value, entityPipe); } destroy() { this.clear(); this.stream$ = null; this.value$ = null; } clear(destroy = true) { if (destroy) { this.forEach((entity) => entity.destroy()); } this.value.clear(); return this; } get(id) { return this.value.get(id); } has(id) { return this.value.has(id); } add(entity) { this.value.set(entity.id, entity); return this; } addMany(entities) { for (const entity of entities) { this.add(entity); } return this; } remove(id, destroy = true) { var _a; if (destroy) { (_a = this.value.get(id)) === null || _a === void 0 ? void 0 : _a.destroy(); } this.value.delete(id); return this; } array() { return Array.from(this.value.values()); } array$() { return this.select((value) => Array.from(value.values())); } forEach(cb) { this.value.forEach(cb); } map(cb) { return this.array().map(cb); } } function createEntityState(value = [], entityPipe) { if (isArray(value)) { return new EntityState(new Map(value), entityPipe); } else { return new EntityState(new Map(mapToEntries(value)), entityPipe); } } // region events class BaseEvent { constructor(entity, options) { this.id = UID(); this.entity = entity; this.entityId = entity.id; this.firing = true; this.stopPropagation = () => (this.firing = false); this.propogate = options ? options.propagate : null; } } class LockEvent extends BaseEvent { constructor(entity, locked = false) { super(entity); this.locked = locked; } } class ParentChangeEvent extends BaseEvent { constructor(entity, parent) { super(entity); this.parent = parent; } } class SelectionEvent extends BaseEvent { constructor(entity, selected) { super(entity); this.isSelected = selected; } } class PaintedEvent extends BaseEvent { constructor(entity, painted = false) { super(entity); this.isPainted = painted; } } // endregion class BaseEntity { constructor(id, logPrefix = '') { this.destroyed$ = new Subject(); this.locked$ = createValueState(false, this.entityPipe('locked')); this._id = id || UID(); this._logPrefix = `${logPrefix}`; } get id() { return this._id; } set id(id) { this._id = id; } log(message, ...args) { log(`${this._logPrefix} ${message}: `, LOG_LEVEL.LOG, ...args); } withLog(message, ...args) { return withLog(`${this._logPrefix} ${message}: `, LOG_LEVEL.LOG, ...args); } entityPipe(logMessage = '') { return entityProperty(this.onEntityDestroy(), 0, `${this._logPrefix}: ${logMessage}`); } getLocked() { return this.locked$.value; } setLocked(locked = true) { this.locked$.set(locked).emit(); } // eslint-disable-next-line doClone(lookupTable = {}, clone) { /*noop*/ } clone(lookupTable = {}) { // try and use an existing clone first if (lookupTable[this.id]) { return lookupTable[this.id]; } const clone = Object.assign({}, this); clone.id = UID(); // clone.clearListeners(); lookupTable[this.id] = clone; this.doClone(lookupTable, clone); return clone; } serialize() { return { id: this.id, locked: this.getLocked() }; } lockChanges() { return this.locked$.select((locked) => new LockEvent(this, locked)); } destroy() { this.log('entity destroyed'); this.destroyed$.next(); this.destroyed$.complete(); } onEntityDestroy() { return this.destroyed$.pipe(map((opts) => new BaseEvent(this, opts))); } } class BaseModel extends BaseEntity { constructor(type, id, logPrefix = '[Base]') { super(id, logPrefix); this.parent$ = createValueState(null, this.entityPipe('ParentsChange')); this.selected$ = createValueState(false, this.entityPipe('SelectedChange')); this.hovered$ = createValueState(false, this.entityPipe('HoveredChange')); this.painted$ = createValueState(false, this.entityPipe('PaintedChange')); this._type = type; } serialize() { return Object.assign(Object.assign({}, super.serialize()), { type: this.getType() }); } getParent() { return this.parent$.value; } setParent(parent) { this.parent$.set(parent).emit(); } parentChanges() { return this.parent$.select((p) => new ParentChangeEvent(this, p)); } getPainted() { return this.painted$.value; } setPainted(painted = true) { this.painted$.set(painted).emit(); } getHovered() { return this.hovered$.value; } setHovered(painted = true) { this.hovered$.set(painted).emit(); } selectHovered() { return this.hovered$.value$; } paintChanges() { return this.painted$.select((p) => new PaintedEvent(this, p)); } getType() { return this._type; } getSelected() { return this.selected$.value; } selectSelected() { return this.selected$.select(); } setSelected(selected = true) { this.selected$.set(selected).emit(); } selectionChanges() { return this.selected$.select((selected) => new SelectionEvent(this, selected)); } getSelectedEntities() { return this.getSelected() ? [this] : []; } } class PointModel extends BaseModel { constructor(link, coords, id, logPrefix = '[Point]') { super(link.getType(), id, logPrefix); this.coords$ = createValueState(coords, this.entityPipe('coords')); this.setParent(link); } serialize() { return Object.assign(Object.assign({}, super.serialize()), { coords: this.getCoords() }); } isConnectedToPort() { return this.getParent().getPortForPoint(this) !== null; } getLink() { return this.getParent(); } destroy() { if (this.getParent) { this.getParent().removePoint(this); } super.destroy(); } setCoords(newCoords) { this.coords$.set(Object.assign(Object.assign({}, this.getCoords()), newCoords)).emit(); } selectCoords() { return this.coords$.value$; } getCoords() { return this.coords$.value; } selectX() { return this.coords$.select((coords) => coords.x); } selectY() { return this.coords$.select((coords) => coords.y); } } class LinkModel extends BaseModel { constructor(linkType = 'default', id, logPrefix = '[Link]') { super(linkType, id, logPrefix); this.label$ = createValueState(null, this.entityPipe('label')); this.points = [new PointModel(this, { x: 0, y: 0 }), new PointModel(this, { x: 0, y: 0 })]; this.extras = {}; this.sourcePort = null; this.targetPort = null; } serialize() { var _a; const serializedPoints = this.points.map((point) => point.serialize()); const label = (_a = this.getLabel()) === null || _a === void 0 ? void 0 : _a.serialize(); return Object.assign(Object.assign({}, super.serialize()), { name: this.getName(), sourcePort: this.getSourcePort().id, targetPort: this.getTargetPort().id, extras: this.getExtras(), points: serializedPoints, label }); } setName(name) { this.name = name; } getName() { return this.name; } getExtras() { return this.extras; } setExtras(extras) { this.extras = extras; } destroy() { if (this.sourcePort) { this.sourcePort.removeLink(this); } if (this.targetPort) { this.targetPort.removeLink(this); } super.destroy(); } doClone(lookupTable = {}, clone) { clone.setPoints(this.getPoints().map((point) => { return point.clone(lookupTable); })); if (this.sourcePort) { clone.setSourcePort(this.sourcePort.clone(lookupTable)); } if (this.targetPort) { clone.setTargetPort(this.targetPort.clone(lookupTable)); } } isLastPoint(point) { const index = this.getPointIndex(point); return index === this.points.length - 1; } getPointIndex(point) { return this.points.indexOf(point); } getPointModel(id) { for (const point of this.points) { if (point.id === id) { return point; } } return null; } getPortForPoint(point) { if (this.sourcePort !== null && this.getFirstPoint().id === point.id) { return this.sourcePort; } if (this.targetPort !== null && this.getLastPoint().id === point.id) { return this.targetPort; } return null; } getPointForPort(port) { if (this.sourcePort !== null && this.sourcePort.id === port.id) { return this.getFirstPoint(); } if (this.targetPort !== null && this.targetPort.id === port.id) { return this.getLastPoint(); } return null; } getFirstPoint() { return this.points[0]; } getLastPoint() { return this.points[this.points.length - 1]; } setSourcePort(port) { if (port !== null) { port.addLink(this); } if (this.sourcePort !== null) { this.sourcePort.removeLink(this); } this.sourcePort = port; } getSourcePort() { return this.sourcePort; } getTargetPort() { return this.targetPort; } setTargetPort(port) { if (port !== null) { port.addLink(this); } if (this.targetPort !== null) { this.targetPort.removeLink(this); } this.targetPort = port; } point({ x, y }) { return this.addPoint(this.generatePoint({ x, y })); } getPoints() { return this.points; } setPoints(points) { points.forEach((point) => { point.setParent(this); }); this.points = points; } setLabel(label) { label.setParent(this); this.label$.set(label).emit(); } selectLabel() { return this.label$.value$; } getLabel() { return this.label$.value; } resetLabel() { const currentLabel = this.getLabel(); if (currentLabel) { currentLabel.setParent(null); currentLabel.setPainted(false); } this.setLabel(null); } removePoint(pointModel) { this.points.splice(this.getPointIndex(pointModel), 1); } removePointsBefore(pointModel) { this.points.splice(0, this.getPointIndex(pointModel)); } removePointsAfter(pointModel) { this.points.splice(this.getPointIndex(pointModel) + 1); } removeMiddlePoints() { if (this.points.length > 2) { this.points.splice(0, this.points.length - 2); } } addPoint(pointModel, index = 1) { pointModel.setParent(this); pointModel.setLocked(this.getLocked()); this.points.splice(index, 0, pointModel); return pointModel; } generatePoint({ x = 0, y = 0 }) { return new PointModel(this, { x, y }); } setLocked(locked = true) { super.setLocked(locked); this.points.forEach((point) => point.setLocked(locked)); } } class MoveItemsAction extends BaseAction { constructor(mouseX, mouseY, diagramEngine) { super(mouseX, mouseY); this.moved = false; let selectedItems = diagramEngine.getDiagramModel().getSelectedItems(); // dont allow items which are locked to move and links which generate their position based on points. selectedItems = selectedItems.filter((item) => { return !diagramEngine.isModelLocked(item) && !(item instanceof LinkModel); }); this.selectionModels = selectedItems.map((item) => { const { x: initialX, y: initialY } = item.getCoords(); return { model: item, initialX, initialY, }; }); } } // TODO: refactor into entity-created.action, and fire every time a new entity is created! class LinkCreatedAction extends BaseAction { constructor(mouseX, mouseY, link) { super(mouseX, mouseY); this.sourcePort = link.getSourcePort(); this.targetPort = link.getTargetPort(); this.link = link; } getOutPortNode() { return this.sourcePort.getParent(); } getInPortNode() { return this.targetPort.getParent(); } } class LooseLinkDestroyed extends BaseAction { constructor(mouseX, mouseY, link) { super(mouseX, mouseY); this.sourcePort = link.getSourcePort(); this.link = link; } getOutPortNode() { return this.sourcePort.getParent(); } } class InvalidLinkDestroyed extends BaseAction { constructor(mouseX, mouseY, link) { super(mouseX, mouseY); this.sourcePort = link.getSourcePort(); this.link = link; } getOutPortNode() { return this.sourcePort.getParent(); } } class NodeModel extends BaseModel { constructor(nodeType = 'default', id, extras = {}, x = 0, y = 0, width = 0, height = 0, logPrefix = '[Node]') { super(nodeType, id, logPrefix); this.diagramEngine$ = createValueState(null, this.entityPipe('diagramEngine')); this.extras$ = createValueState({}, this.entityPipe('extras')); this.ports$ = createEntityState([], this.entityPipe('ports')); this.coords$ = createValueState({ x: 0, y: 0 }, this.entityPipe('coords')); this.dimensions$ = createValueState({ width: 0, height: 0 }, this.entityPipe('dimensions')); this.setExtras(extras); this.setDimensions({ width, height }); this.setCoords({ x, y }); } getDiagramEngine() { return this.diagramEngine$.value; } selectDiagramEngine() { return this.diagramEngine$.value$; } setDiagramEngine(diagramEngine) { this.diagramEngine$.set(diagramEngine).emit(); } getCoords() { return this.coords$.value; } setCoords({ x, y }) { const { x: oldX, y: oldY } = this.getCoords(); this.getPorts().forEach((port) => { port.getLinks().forEach((link) => { const point = link.getPointForPort(port); const { x: pointX, y: pointY } = point.getCoords(); point.setCoords({ x: pointX + x - oldX, y: pointY + y - oldY }); }); }); this.coords$.set({ x, y }).emit(); } serialize() { const serializedPorts = this.getPortsArray().map((port) => port.serialize()); return Object.assign(Object.assign(Object.assign(Object.assign({}, super.serialize()), { nodeType: this.getType(), extras: this.getExtras(), width: this.getWidth(), height: this.getHeight() }), this.getCoords()), { ports: serializedPorts }); } // TODO: override selectionChanges and replace this with it (convert to rx) getSelectedEntities() { let entities = super.getSelectedEntities(); // add the points of each link that are selected here if (this.getSelected()) { this.getPorts().forEach((port) => { const points = port.getLinksArray().map((link) => link.getPointForPort(port)); entities = entities.concat(points); }); } this.log('selectedEntities', entities); return entities; } // TODO: map to BaseEvent coordsChanges() { return this.coords$.value$; } selectCoords() { return this.coords$.value$; } selectX() { return this.coords$.select((coords) => coords.x); } selectY() { return this.coords$.select((coords) => coords.y); } /** * Assign a port to the node and set the node as its getParent * @returns the inserted port */ addPort(port) { port.setParent(this); this.ports$.add(port).emit(); return port; } removePort(portOrId) { const portId = typeof portOrId === 'string' ? portOrId : portOrId.id; this.ports$.remove(portId).emit(); return portId; } getPort(id) { return this.ports$.get(id); } selectPorts(selector) { // TODO: implement selector // TODO: create coerce func return this.ports$.array$().pipe(this.withLog('selectPorts')); } getPorts() { return this.ports$.value; } getPortsArray() { return this.ports$.array(); } setDimensions(dimensions) { this.dimensions$.set(Object.assign(Object.assign({}, this.getDimensions()), dimensions)).emit(); } getDimensions() { return this.dimensions$.value; } // TODO: return BaseEvent extension dimensionChanges() { return this.dimensions$.select(); } getHeight() { return this.getDimensions().height; } setHeight(height) { return this.setDimensions({ height }); } getWidth() { return this.getDimensions().width; } setWidth(width) { return this.setDimensions({ width }); } selectWidth() { return this.dimensions$.select((d) => d.width).pipe(this.withLog('selectWidth')); } selectHeight() { return this.dimensions$.select((d) => d.height).pipe(this.withLog('selectHeight')); } setExtras(extras) { this.extras$.set(extras).emit(); } getExtras() { return this.extras$.value; } selectExtras(selector) { return this.extras$.select(selector); } destroy() { super.destroy(); this.removeAllPorts(); } removeAllPorts() { this.ports$.clear().emit(); } } class DiagramModel extends BaseEntity { constructor(diagramEngine, id, logPrefix = '[Diagram]') { super(id, logPrefix); this.diagramEngine = diagramEngine; this.nodes$ = createEntityState([], this.entityPipe('nodes')); this.links$ = createEntityState([], this.entityPipe('links')); this.offsetX$ = createValueState(0, this.entityPipe('offsetX')); this.offsetY$ = createValueState(0, this.entityPipe('offsetY')); this.zoom$ = createValueState(100, this.entityPipe('zoom')); this.maxZoomOut$ = createValueState(null); this.maxZoomIn$ = createValueState(null); this.gridSize$ = createValueState(0); } // TODO: support the following events for links and nodes // removed, updated<positionChanged/dataChanged>, added getNodes() { return this.nodes$.value; } getNodesArray() { return this.nodes$.array(); } getNode(id) { return this.nodes$.get(id); } getLink(id) { return this.links$.get(id); } getLinks() { return this.links$.value; } getLinksArray() { return this.links$.array(); } getAllPorts(options = {}) { const result = new Map(); this.getNodes().forEach((node) => { const ports = options.filter ? node.getPortsArray().filter(options.filter) : node.getPortsArray(); ports.forEach((port) => result.set(port.id, port)); }); return result; } /** * Add a node to the diagram * @returns Inserted Node */ addNode(node) { this.nodes$.add(node).emit(); return node; } /** * Delete a node from the diagram */ deleteNode(nodeOrId) { const nodeId = typeof nodeOrId === 'string' ? nodeOrId : nodeOrId.id; const node = this.getNode(nodeId); for (const port of node.getPorts().values()) { for (const link of port.getLinks().values()) { this.deleteLink(link); } } this.nodes$.remove(nodeId).emit(); } /** * Get nodes as observable, use `.getValue()` for snapshot */ selectNodes() { return this.nodes$.value$; } /** * Add link * @returns Newly created link */ addLink(link) { this.links$.add(link).emit(); return link; } /** * Delete link */ deleteLink(linkOrId) { const linkId = typeof linkOrId === 'string' ? linkOrId : linkOrId.id; this.links$.remove(linkId).emit(); } reset() { this.nodes$.clear().emit(); this.links$.clear().emit(); } /** * Get links behaviour subject, use `.getValue()` for snapshot */ selectLinks() { return this.links$.value$; } // /** // * Serialize the diagram model to JSON // * @returns diagram model as a string // */ serialize() { const serializedNodes = this.nodes$.map((node) => node.serialize()); const serializedLinks = this.links$.map((link) => link.serialize()); return Object.assign(Object.assign({}, super.serialize()), { nodes: serializedNodes, links: serializedLinks }); } setMaxZoomOut(maxZoomOut) { this.maxZoomOut$.set(maxZoomOut).emit(); } setMaxZoomIn(maxZoomIn) { this.maxZoomIn$.set(maxZoomIn).emit(); } getMaxZoomOut() { return this.maxZoomOut$.value; } getMaxZoomIn() { return this.maxZoomIn$.value; } setOffset(x, y) { this.offsetX$.set(x).emit(); this.offsetY$.set(y).emit(); } setOffsetX(x) { this.offsetX$.set(x).emit(); } getOffsetX() { return this.offsetX$.value; } selectOffsetX() { return this.offsetX$.value$; } setOffsetY(y) { this.offsetY$.set(y).emit(); } getOffsetY() { return this.offsetY$.value; } selectOffsetY() { return this.offsetY$.value$; } setZoomLevel(z) { const maxZoomIn = this.getMaxZoomIn(); const maxZoomOut = this.getMaxZoomOut(); // check if zoom levels exceeded defined boundaries if ((maxZoomIn && z > maxZoomIn) || (maxZoomOut && z < maxZoomOut)) { return; } this.zoom$.set(z).emit(); } getZoomLevel() { return this.zoom$.value; } selectZoomLevel() { return this.zoom$.value$; } getDiagramEngine() { return this.diagramEngine; } clearSelection(ignore = null) { this.getSelectedItems().forEach((element) => { if ((ignore === null || ignore === void 0 ? void 0 : ignore.id) === element.id) { return; } element.setSelected(false); }); } getGridPosition({ x, y }) { const gridSize = this.gridSize$.value; if (gridSize === 0) { return { x, y }; } return { x: gridSize * Math.floor((x + gridSize / 2) / gridSize), y: gridSize * Math.floor((y + gridSize / 2) / gridSize) }; } getSelectedItems(...filters) { filters = coerceArray(filters); const items = []; const nodes = this.nodes$.array(); const links = this.links$.array(); const selectedNodes = () => nodes.flatMap((node) => node.getSelectedEntities()); const selectedPorts = () => nodes.flatMap((node) => node.getPortsArray().flatMap((port) => port.getSelectedEntities())); const selectedLinks = () => links.flatMap((link) => link.getSelectedEntities()); const selectedPoints = () => links.flatMap((link) => link.getPoints().flatMap((point) => point.getSelectedEntities())); if (isEmptyArray(filters)) { items.push(...selectedNodes(), ...selectedPorts(), ...selectedLinks(), ...selectedPoints()); } else { const byType = { node: selectedNodes, port: selectedPorts, link: selectedLinks, point: selectedPoints }; for (const type of filters) { items.push(...byType[type]()); } } return unique(items); } addAll(...models) { const links = []; const nodes = []; for (const model of models) { if (model instanceof LinkModel) { links.push(model); } else if (model instanceof NodeModel) { nodes.push(model); } } this.addLinks(links); this.addNodes(nodes); return models; } addLinks(links) { this.links$.addMany(links).emit(); } addNodes(nodes) { this.nodes$.addMany(nodes).emit(); } destroy() { super.destroy(); this.nodes$.destroy(); this.links$.destroy(); } } class PortModel extends BaseModel { constructor(name, type, id, maximumLinks, linkType, magnetic = true, logPrefix = '[Port]') { super(type, id, logPrefix); this.links$ = createEntityState([], this.entityPipe('links')); this.x$ = createValueState(0, this.entityPipe('x')); this.y$ = createValueState(0, this.entityPipe('y')); this.width$ = createValueState(0, this.entityPipe('y')); this.height$ = createValueState(0, this.entityPipe('y')); this.magnetic$ = createValueState(true, this.entityPipe('magnetic')); this.canCreateLinks$ = createValueState(true, this.entityPipe('magnetic')); this.name = name; this.maximumLinks = maximumLinks; this.linkType = linkType; this.setMagnetic(magnetic); } serialize() { return Object.assign(Object.assign(Object.assign({}, super.serialize()), { name: this.getName(), linkType: this.getLinkType(), maximumLinks: this.getMaximumLinks(), type: this.getType(), magnetic: this.getMagnetic(), height: this.getHeight(), width: this.getWidth(), canCreateLinks: this.getCanCreateLinks() }), this.getCoords()); } getNode() { return this.getParent(); } getName() { return this.name; } getCanCreateLinks() { const numberOfLinks = this.getLinks().size; if (this.maximumLinks && numberOfLinks >= this.maximumLinks) { return false; } return this.canCreateLinks$.value; } getCoords() { return { x: this.getX(), y: this.getY() }; } selectCanCreateLinks() { return this.canCreateLinks$.value$; } setCanCreateLinks(value) { this.canCreateLinks$.set(value).emit(); } getMagnetic() { return this.magnetic$.value; } selectMagnetic() { return this.magnetic$.value$; } setMagnetic(magnetic) { this.magnetic$.set(magnetic).emit(); } selectX() { return this.x$.value$; } selectY() { return this.y$.value$; } getY() { return this.y$.value; } getX() { return this.x$.value; } getHeight() { return this.height$.value; } getWidth() { return this.width$.value; } getMaximumLinks() { return this.maximumLinks; } setMaximumLinks(maximumLinks) { this.maximumLinks = maximumLinks; } getLinkType() { return this.linkType; } setLinkType(type) { this.linkType = type; } removeLink(linkOrId) { const linkId = isString(linkOrId) ? linkOrId : linkOrId.id; this.links$.remove(linkId, false).emit(); } addLink(link) { this.links$.add(link).emit(); } getLinks() { return this.links$.value; } getLinksArray() { return this.links$.array(); } selectLinks() { return this.links$.value$; } updateCoords({ x, y, width, height }) { this.x$.set(x).emit(); this.y$.set(y).emit(); this.width$.set(width).emit(); this.height$.set(height).emit(); } canLinkToPort(port) { return true; } isLocked() { return super.getLocked(); } createLinkModel() { if (this.getCanCreateLinks()) { return new LinkModel(); } } destroy() { super.destroy(); this.links$.clear().emit(); } } class NgxDiagramComponent { constructor(document, ngZone, renderer, cdRef, elRef) { this.document = document; this.ngZone = ngZone; this.renderer = renderer; this.cdRef = cdRef; this.elRef = elRef; this.allowCanvasZoom = true; this.allowCanvasTranslation = true; this.inverseZoom = true; this.allowLooseLinks = true; this.maxZoomOut = null; this.maxZoomIn = null; this.portMagneticRadius = 30; this.smartRouting = false; this.actionStartedFiring = new EventEmitter(); this.actionStillFiring = new EventEmitter(); this.actionStoppedFiring = new EventEmitter(); this.action$ = new BehaviorSubject(null); this.nodesRendered$ = new BehaviorSubject(false); this.destroyed$ = new ReplaySubject(1); } get host() { return this.elRef.nativeElement; } // TODO: handle destruction of container, resetting all observables to avoid memory leaks! ngAfterViewInit() { if (this.diagramModel) { this.initNodes(); this.initLinks(); this.initSubs(); } } ngOnDestroy() { this.destroyed$.next(true); this.destroyed$.complete(); } /** * fire the action registered and notify subscribers */ fireAction() { if (this.action$.value) { this.actionStillFiring.emit(this.action$.value); } } /** * Unregister the action, post firing and notify subscribers */ stopFiringAction(shouldSkipEvent) { if (!shouldSkipEvent) { this.actionStoppedFiring.emit(this.action$.value); } this.action$.next(null); } /** * Register the new action, pre firing and notify subscribers */ startFiringAction(action) { this.action$.next(action); this.actionStartedFiring.emit(action); } selectAction() { return this.action$; } shouldDrawSelectionBox() { const action = this.action$.getValue(); if (action instanceof SelectingAction) { action.getBoxDimensions(); return true; } return false; } getMouseElement(event) { const target = event.target; // is it a port? let element = target.closest('[data-portid]'); if (element) { // get the relevant node and return the port. const nodeEl = target.closest('[data-nodeid]'); return { model: this.diagramModel .getNode(nodeEl.getAttribute('data-nodeid')) .getPort(element.getAttribute('data-portid')), element }; } // look for a point element = target.closest('[data-pointid]'); if (element) { return { model: this.diagramModel .getLink(element.getAttribute('data-linkid')) .getPointModel(element.getAttribute('data-pointid')), element }; } // look for a link element = target.closest('[data-linkid]'); if (element) { return { model: this.diagramModel.getLink(element.getAttribute('data-linkid')), element }; } // a node maybe element = target.closest('[data-nodeid]'); if (element) { return { model: this.diagramModel.getNode(element.getAttribute('data-nodeid')), element }; } // just the canvas return null; } onMouseUp(event) { const diagramEngine = this.diagramModel.getDiagramEngine(); const action = this.action$.getValue(); // are we going to connect a link to something? if (action instanceof MoveItemsAction) { const element = this.getMouseElement(event); action.selectionModels.forEach((model) => { // only care about points connecting to things if (!model || !(model.model instanceof PointModel)) { return; } let el; if (model.magnet) { el = model.magnet; } else if (element && element.model) { el = element.model; } if (el instanceof PortModel && !diagramEngine.isModelLocked(el)) { const link = model.model.getLink(); if (link.getTargetPort() !== null) { // if this was a valid link already and we are adding a node in the middle, create 2 links from the original if (link.getTargetPort() !== el && link.getSourcePort() !== el) { const targetPort = link.getTargetPort(); const newLink = link.clone({}); newLink.setSourcePort(el); newLink.setTargetPort(targetPort); link.setTargetPort(el); targetPort.removeLink(link); newLink.removePointsBefore(newLink.getPoints()[link.getPointIndex(model.model)]); link.removePointsAfter(model.model); diagramEngine.getDiagramModel().addLink(newLink); // if we are connecting to the same target or source, destroy tweener points } else if (link.getTargetPort() === el) { link.removePointsAfter(model.model); } else if (link.getSourcePort() === el) { link.removePointsBefore(model.model); } } else { link.setTargetPort(el); const targetPort = link.getTargetPort(); const srcPort = link.getSourcePort(); if (targetPort.id !== srcPort.id && srcPort.canLinkToPort(targetPort)) { // link is valid, fire the event this.startFiringAction(new LinkCreatedAction(event.clientX, event.clientY, link)); } } } // reset current magent model.magnet = undefined; }); // check for / destroy any loose links in any models which have been moved if (!this.allowLooseLinks) { action.selectionModels.forEach((model) => { // only care about points connecting to things if (!model || !(model.model instanceof PointModel)) { return; } const selectedPoint = model.model; const link = selectedPoint.getLink(); if (link.getSourcePort() === null || link.getTargetPort() === null) { link.destroy(); this.startFiringAction(new LooseLinkDestroyed(event.clientX, event.clientY, link)); } }); } // destroy any invalid links action.selectionModels.forEach((model) => { // only care about points connecting to things if (!model || !(model.model instanceof PointModel)) { return; } const link = model.model.getLink(); const sourcePort = link.getSourcePort(); const targetPort = link.getTargetPort(); if (sourcePort !== null && targetPort !== null) { if (!sourcePort.canLinkToPort(targetPort)) { // link not allowed link.destroy(); this.startFiringAction(new InvalidLinkDestroyed(event.clientX, event.clientY, link)); } else if (targetPort .getLinksArray() .some((link) => link !== link && (link.getSourcePort() === sourcePort || link.getTargetPort() === sourcePort))) { // link is a duplicate link.destroy(); } } }); this.stopFiringAction(); } else { this.stopFiringAction(); } this.action$.next(null); } /** * @description Mouse Move Event Handler * @param event MouseEvent */ onMouseMove(event) { const action = this.action$.getValue(); if (action === null || action === undefined) { return; } if (action instanceof SelectingAction) { const relative = this.diagramModel.getDiagramEngine().getRelativePoint(event.clientX, event.clientY); this.diagramModel.getNodes().forEach((node) => { if (action.containsElement(node.getCoords(), this.diagramModel)) { node.setSelected(); } else { node.setSelected(false); } }); this.diagramModel.getLinks().forEach((link) => { let allSelected = true; link.getPoints().forEach((point) => { if (action.containsElement(point.getCoords(), this.diagramModel)) { point.setSelected(); } else { point.setSelected(false); allSelected = false; } }); if (allSelected) { link.setSelected(); } }); action.mouseX2 = relative.x; action.mouseY2 = relative.y; this.fireAction(); this.action$.next(action); return; } else if (action instanceof MoveItemsAction) { const coords = { x: event.clientX - action.mouseX, y: event.clientY - action.mouseY }; const amountZoom = this.diagramModel.getZoomLevel() / 100; action.selectionModels.forEach((selectionModel) => { // reset all previous magnets if any selectionModel.magnet = undefined; // in this case we need to also work out the relative grid position if (selectionModel.model instanceof NodeModel || (selectionModel.model instanceof PointModel && !selectionModel.model.isConnectedToPort())) { const newCoords = { x: selectionModel.initialX + coords.x / amountZoom, y: selectionModel.initialY + coords.y / amountZoom }; const gridRelativeCoords = this.diagramModel.getGridPosition(newCoords); // magnetic inputs handling