ngx-diagrams
Version:
> Light Weight, Modular, Typed Diagram Engine for Angular
1,527 lines (1,507 loc) • 108 kB
JavaScript
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