@swimlane/ngx-graph
Version:
Graph visualization for angular
1,162 lines • 182 kB
JavaScript
import { __decorate } from "tslib";
// rename transition due to conflict with d3 transition
import { animate, style, transition as ngTransition, trigger } from '@angular/animations';
import { ChangeDetectionStrategy, Component, ContentChild, EventEmitter, HostListener, Input, Output, ViewChildren, ViewEncapsulation } from '@angular/core';
import { select } from 'd3-selection';
import * as shape from 'd3-shape';
import * as ease from 'd3-ease';
import 'd3-transition';
import { Observable, Subscription, of, fromEvent as observableFromEvent, Subject } from 'rxjs';
import { debounceTime, takeUntil } from 'rxjs/operators';
import { identity, scale, smoothMatrix, toSVG, transform, translate } from 'transformation-matrix';
import { id } from '../utils/id';
import { PanningAxis } from '../enums/panning.enum';
import { MiniMapPosition } from '../enums/mini-map-position.enum';
import { throttleable } from '../utils/throttle';
import { ColorHelper } from '../utils/color.helper';
import { calculateViewDimensions } from '../utils/view-dimensions.helper';
import { VisibilityObserver } from '../utils/visibility-observer';
import * as i0 from "@angular/core";
import * as i1 from "./layouts/layout.service";
import * as i2 from "@angular/common";
import * as i3 from "./mouse-wheel.directive";
export var NgxGraphStates;
(function (NgxGraphStates) {
NgxGraphStates["Init"] = "init";
NgxGraphStates["Subscribe"] = "subscribe";
NgxGraphStates["Transform"] = "transform";
/* eslint-disable @typescript-eslint/no-shadow */
NgxGraphStates["Output"] = "output";
})(NgxGraphStates || (NgxGraphStates = {}));
export class GraphComponent {
el;
zone;
cd;
layoutService;
nodes = [];
clusters = [];
compoundNodes = [];
links = [];
activeEntries = [];
curve;
draggingEnabled = true;
nodeHeight;
nodeMaxHeight;
nodeMinHeight;
nodeWidth;
nodeMinWidth;
nodeMaxWidth;
panningEnabled = true;
panningAxis = PanningAxis.Both;
enableZoom = true;
zoomSpeed = 0.1;
minZoomLevel = 0.1;
maxZoomLevel = 4.0;
autoZoom = false;
panOnZoom = true;
animate = false;
autoCenter = false;
update$;
center$;
zoomToFit$;
panToNode$;
layout;
layoutSettings;
enableTrackpadSupport = false;
showMiniMap = false;
miniMapMaxWidth = 100;
miniMapMaxHeight;
miniMapPosition = MiniMapPosition.UpperRight;
view;
scheme = 'cool';
customColors;
deferDisplayUntilPosition = false;
centerNodesOnPositionChange = true;
enablePreUpdateTransform = true;
select = new EventEmitter();
activate = new EventEmitter();
deactivate = new EventEmitter();
zoomChange = new EventEmitter();
clickHandler = new EventEmitter();
stateChange = new EventEmitter();
linkTemplate;
nodeTemplate;
clusterTemplate;
defsTemplate;
miniMapNodeTemplate;
nodeElements;
linkElements;
chartWidth;
isMouseMoveCalled = false;
graphSubscription = new Subscription();
colors;
dims;
seriesDomain;
transform;
isPanning = false;
isDragging = false;
draggingNode;
initialized = false;
graph;
graphDims = { width: 0, height: 0 };
_oldLinks = [];
oldNodes = new Set();
oldClusters = new Set();
oldCompoundNodes = new Set();
transformationMatrix = identity();
_touchLastX = null;
_touchLastY = null;
minimapScaleCoefficient = 3;
minimapTransform;
minimapOffsetX = 0;
minimapOffsetY = 0;
isMinimapPanning = false;
minimapClipPathId;
width;
height;
resizeSubscription;
visibilityObserver;
destroy$ = new Subject();
constructor(el, zone, cd, layoutService) {
this.el = el;
this.zone = zone;
this.cd = cd;
this.layoutService = layoutService;
}
groupResultsBy = node => node.label;
/**
* Get the current zoom level
*/
get zoomLevel() {
return this.transformationMatrix.a;
}
/**
* Set the current zoom level
*/
set zoomLevel(level) {
this.zoomTo(Number(level));
}
/**
* Get the current `x` position of the graph
*/
get panOffsetX() {
return this.transformationMatrix.e;
}
/**
* Set the current `x` position of the graph
*/
set panOffsetX(x) {
this.panTo(Number(x), null);
}
/**
* Get the current `y` position of the graph
*/
get panOffsetY() {
return this.transformationMatrix.f;
}
/**
* Set the current `y` position of the graph
*/
set panOffsetY(y) {
this.panTo(null, Number(y));
}
/**
* Angular lifecycle event
*
*
* @memberOf GraphComponent
*/
ngOnInit() {
if (this.update$) {
this.update$.pipe(takeUntil(this.destroy$)).subscribe(() => {
this.update();
});
}
if (this.center$) {
this.center$.pipe(takeUntil(this.destroy$)).subscribe(() => {
this.center();
});
}
if (this.zoomToFit$) {
this.zoomToFit$.pipe(takeUntil(this.destroy$)).subscribe(options => {
this.zoomToFit(options ? options : {});
});
}
if (this.panToNode$) {
this.panToNode$.pipe(takeUntil(this.destroy$)).subscribe((nodeId) => {
this.panToNodeId(nodeId);
});
}
this.minimapClipPathId = `minimapClip${id()}`;
this.stateChange.emit({ state: NgxGraphStates.Subscribe });
}
ngOnChanges(changes) {
this.basicUpdate();
const { layoutSettings } = changes;
this.setLayout(this.layout);
if (layoutSettings) {
this.setLayoutSettings(this.layoutSettings);
}
if (this.layout && this.nodes.length && this.links.length) {
this.update();
}
}
setLayout(layout) {
this.initialized = false;
if (!layout) {
layout = 'dagre';
}
if (typeof layout === 'string') {
this.layout = this.layoutService.getLayout(layout);
this.setLayoutSettings(this.layoutSettings);
}
}
setLayoutSettings(settings) {
if (this.layout && typeof this.layout !== 'string') {
this.layout.settings = settings;
}
}
/**
* Angular lifecycle event
*
*
* @memberOf GraphComponent
*/
ngOnDestroy() {
this.unbindEvents();
if (this.visibilityObserver) {
this.visibilityObserver.visible.unsubscribe();
this.visibilityObserver.destroy();
}
this.destroy$.next();
this.destroy$.complete();
}
/**
* Angular lifecycle event
*
*
* @memberOf GraphComponent
*/
ngAfterViewInit() {
this.bindWindowResizeEvent();
// listen for visibility of the element for hidden by default scenario
this.visibilityObserver = new VisibilityObserver(this.el, this.zone);
this.visibilityObserver.visible.subscribe(this.update.bind(this));
setTimeout(() => this.update());
}
/**
* Base class update implementation for the dag graph
*
* @memberOf GraphComponent
*/
update() {
this.basicUpdate();
if (!this.curve) {
this.curve = shape.curveBundle.beta(1);
}
this.zone.run(() => {
this.dims = calculateViewDimensions({
width: this.width,
height: this.height
});
this.seriesDomain = this.getSeriesDomain();
this.setColors();
this.createGraph();
this.updateTransform();
if (!this.initialized) {
this.stateChange.emit({ state: NgxGraphStates.Init });
}
this.initialized = true;
});
}
/**
* Creates the dagre graph engine
*
* @memberOf GraphComponent
*/
createGraph() {
this.graphSubscription.unsubscribe();
this.graphSubscription = new Subscription();
const initializeNode = (n) => {
if (!n.meta) {
n.meta = {};
}
if (!n.id) {
n.id = id();
}
if (!n.dimension) {
n.dimension = {
width: this.nodeWidth ? this.nodeWidth : 30,
height: this.nodeHeight ? this.nodeHeight : 30
};
n.meta.forceDimensions = false;
}
else {
n.meta.forceDimensions = n.meta.forceDimensions === undefined ? true : n.meta.forceDimensions;
}
if (!n.position) {
n.position = {
x: 0,
y: 0
};
if (this.deferDisplayUntilPosition) {
n.hidden = true;
}
}
n.data = n.data ? n.data : {};
return n;
};
const initializeEdge = (e) => {
if (!e.id) {
e.id = id();
}
return e;
};
this.graph = {
nodes: this.nodes.map(n => initializeNode(n)),
clusters: this.clusters.map(n => initializeNode(n)),
compoundNodes: this.compoundNodes.map(n => initializeNode(n)),
edges: this.links.map(e => initializeEdge(e))
};
requestAnimationFrame(() => this.draw());
}
/**
* Draws the graph using dagre layouts
*
*
* @memberOf GraphComponent
*/
draw() {
// Recalculate the layout
const result = this.layout.run(this.graph);
const result$ = result instanceof Observable ? result : of(result);
this.graphSubscription.add(result$.subscribe(graph => {
this.graph = graph;
this.tick();
}));
}
tick() {
// Transposes view options to the node
const oldNodes = new Set();
const oldClusters = new Set();
const oldCompoundNodes = new Set();
this.graph.nodes.forEach(n => {
n.transform = `translate(${n.position.x - (this.centerNodesOnPositionChange ? n.dimension.width / 2 : 0) || 0}, ${n.position.y - (this.centerNodesOnPositionChange ? n.dimension.height / 2 : 0) || 0})`;
if (!n.data) {
n.data = {};
}
n.data.color = this.colors.getColor(this.groupResultsBy(n));
if (this.deferDisplayUntilPosition) {
n.hidden = false;
}
oldNodes.add(n.id);
});
(this.graph.clusters || []).forEach(n => {
n.transform = `translate(${n.position.x - (this.centerNodesOnPositionChange ? n.dimension.width / 2 : 0) || 0}, ${n.position.y - (this.centerNodesOnPositionChange ? n.dimension.height / 2 : 0) || 0})`;
if (!n.data) {
n.data = {};
}
n.data.color = this.colors.getColor(this.groupResultsBy(n));
if (this.deferDisplayUntilPosition) {
n.hidden = false;
}
oldClusters.add(n.id);
});
(this.graph.compoundNodes || []).forEach(n => {
n.transform = `translate(${n.position.x - (this.centerNodesOnPositionChange ? n.dimension.width / 2 : 0) || 0}, ${n.position.y - (this.centerNodesOnPositionChange ? n.dimension.height / 2 : 0) || 0})`;
if (!n.data) {
n.data = {};
}
n.data.color = this.colors.getColor(this.groupResultsBy(n));
if (this.deferDisplayUntilPosition) {
n.hidden = false;
}
oldCompoundNodes.add(n.id);
});
// Prevent animations on new nodes
setTimeout(() => {
this.oldNodes = oldNodes;
this.oldClusters = oldClusters;
this.oldCompoundNodes = oldCompoundNodes;
}, 500);
// Update the labels to the new positions
const newLinks = [];
for (const edgeLabelId in this.graph.edgeLabels) {
const edgeLabel = this.graph.edgeLabels[edgeLabelId];
const normKey = edgeLabelId.replace(/[^\w-]*/g, '');
const isMultigraph = this.layout && typeof this.layout !== 'string' && this.layout.settings && this.layout.settings.multigraph;
let oldLink = isMultigraph
? this._oldLinks.find(ol => `${ol.source}${ol.target}${ol.id}` === normKey)
: this._oldLinks.find(ol => `${ol.source}${ol.target}` === normKey);
const linkFromGraph = isMultigraph
? this.graph.edges.find(nl => `${nl.source}${nl.target}${nl.id}` === normKey)
: this.graph.edges.find(nl => `${nl.source}${nl.target}` === normKey);
if (!oldLink) {
oldLink = linkFromGraph || edgeLabel;
}
else if (oldLink.data &&
linkFromGraph &&
linkFromGraph.data &&
JSON.stringify(oldLink.data) !== JSON.stringify(linkFromGraph.data)) {
// Compare old link to new link and replace if not equal
oldLink.data = linkFromGraph.data;
}
oldLink.oldLine = oldLink.line;
const points = edgeLabel.points;
const line = this.generateLine(points);
const newLink = Object.assign({}, oldLink);
newLink.line = line;
newLink.points = points;
this.updateMidpointOnEdge(newLink, points);
const textPos = points[Math.floor(points.length / 2)];
if (textPos) {
newLink.textTransform = `translate(${textPos.x || 0},${textPos.y || 0})`;
}
newLink.textAngle = 0;
if (!newLink.oldLine) {
newLink.oldLine = newLink.line;
}
this.calcDominantBaseline(newLink);
newLinks.push(newLink);
}
this.graph.edges = newLinks;
// Map the old links for animations
if (this.graph.edges) {
this._oldLinks = this.graph.edges.map(l => {
const newL = Object.assign({}, l);
newL.oldLine = l.line;
return newL;
});
}
this.applyNodeDimensions();
this.redrawLines();
this.updateMinimap();
requestAnimationFrame(() => {
this.applyNodeDimensions();
this.redrawLines();
this.updateMinimap();
if (this.autoZoom) {
this.zoomToFit({ autoCenter: this.autoCenter ? this.autoCenter : false });
}
else if (this.autoCenter) {
// Auto-center when rendering
this.center();
}
this.stateChange.emit({ state: NgxGraphStates.Output });
});
this.cd.markForCheck();
}
getMinimapTransform() {
switch (this.miniMapPosition) {
case MiniMapPosition.UpperLeft: {
return '';
}
case MiniMapPosition.UpperRight: {
return 'translate(' + (this.dims.width - this.graphDims.width / this.minimapScaleCoefficient) + ',' + 0 + ')';
}
default: {
return '';
}
}
}
updateGraphDims() {
let minX = +Infinity;
let maxX = -Infinity;
let minY = +Infinity;
let maxY = -Infinity;
for (let i = 0; i < this.graph.nodes.length; i++) {
const node = this.graph.nodes[i];
minX = node.position.x < minX ? node.position.x : minX;
minY = node.position.y < minY ? node.position.y : minY;
maxX = node.position.x + node.dimension.width > maxX ? node.position.x + node.dimension.width : maxX;
maxY = node.position.y + node.dimension.height > maxY ? node.position.y + node.dimension.height : maxY;
}
minX -= 100;
minY -= 100;
maxX += 100;
maxY += 100;
this.graphDims.width = maxX - minX;
this.graphDims.height = maxY - minY;
this.minimapOffsetX = minX;
this.minimapOffsetY = minY;
}
updateMinimap() {
// Calculate the height/width total, but only if we have any nodes
if (this.graph.nodes && this.graph.nodes.length) {
this.updateGraphDims();
if (this.miniMapMaxWidth) {
this.minimapScaleCoefficient = this.graphDims.width / this.miniMapMaxWidth;
}
if (this.miniMapMaxHeight) {
this.minimapScaleCoefficient = Math.max(this.minimapScaleCoefficient, this.graphDims.height / this.miniMapMaxHeight);
}
this.minimapTransform = this.getMinimapTransform();
}
}
/**
* Measures the node element and applies the dimensions
*
* @memberOf GraphComponent
*/
applyNodeDimensions() {
if (this.nodeElements && this.nodeElements.length) {
this.nodeElements.forEach(elem => {
const nativeElement = elem.nativeElement;
const node = this.graph.nodes.find(n => n.id === nativeElement.id);
if (!node) {
return;
}
// calculate the height
let dims;
try {
dims = nativeElement.getBBox();
if (!dims.width || !dims.height) {
return;
}
}
catch (ex) {
// Skip drawing if element is not displayed - Firefox would throw an error here
return;
}
if (this.nodeHeight) {
node.dimension.height =
node.dimension.height && node.meta.forceDimensions ? node.dimension.height : this.nodeHeight;
}
else {
node.dimension.height =
node.dimension.height && node.meta.forceDimensions ? node.dimension.height : dims.height;
}
if (this.nodeMaxHeight) {
node.dimension.height = Math.max(node.dimension.height, this.nodeMaxHeight);
}
if (this.nodeMinHeight) {
node.dimension.height = Math.min(node.dimension.height, this.nodeMinHeight);
}
if (this.nodeWidth) {
node.dimension.width =
node.dimension.width && node.meta.forceDimensions ? node.dimension.width : this.nodeWidth;
}
else {
// calculate the width
if (nativeElement.getElementsByTagName('text').length) {
let maxTextDims;
try {
for (const textElem of nativeElement.getElementsByTagName('text')) {
const currentBBox = textElem.getBBox();
if (!maxTextDims) {
maxTextDims = currentBBox;
}
else {
if (currentBBox.width > maxTextDims.width) {
maxTextDims.width = currentBBox.width;
}
if (currentBBox.height > maxTextDims.height) {
maxTextDims.height = currentBBox.height;
}
}
}
}
catch (ex) {
// Skip drawing if element is not displayed - Firefox would throw an error here
return;
}
node.dimension.width =
node.dimension.width && node.meta.forceDimensions ? node.dimension.width : maxTextDims.width + 20;
}
else {
node.dimension.width =
node.dimension.width && node.meta.forceDimensions ? node.dimension.width : dims.width;
}
}
if (this.nodeMaxWidth) {
node.dimension.width = Math.max(node.dimension.width, this.nodeMaxWidth);
}
if (this.nodeMinWidth) {
node.dimension.width = Math.min(node.dimension.width, this.nodeMinWidth);
}
});
}
}
/**
* Redraws the lines when dragged or viewport updated
*
* @memberOf GraphComponent
*/
redrawLines(_animate = this.animate) {
this.linkElements.forEach(linkEl => {
const edge = this.graph.edges.find(lin => lin.id === linkEl.nativeElement.id);
if (edge) {
const linkSelection = select(linkEl.nativeElement).select('.line');
linkSelection
.attr('d', edge.oldLine)
.transition()
.ease(ease.easeSinInOut)
.duration(_animate ? 500 : 0)
.attr('d', edge.line);
const textPathSelection = select(this.el.nativeElement).select(`#${edge.id}`);
textPathSelection
.attr('d', edge.oldTextPath)
.transition()
.ease(ease.easeSinInOut)
.duration(_animate ? 500 : 0)
.attr('d', edge.textPath);
this.updateMidpointOnEdge(edge, edge.points);
}
});
}
/**
* Calculate the text directions / flipping
*
* @memberOf GraphComponent
*/
calcDominantBaseline(link) {
const firstPoint = link.points[0];
const lastPoint = link.points[link.points.length - 1];
link.oldTextPath = link.textPath;
if (lastPoint.x < firstPoint.x) {
link.dominantBaseline = 'text-before-edge';
// reverse text path for when its flipped upside down
link.textPath = this.generateLine([...link.points].reverse());
}
else {
link.dominantBaseline = 'text-after-edge';
link.textPath = link.line;
}
}
/**
* Generate the new line path
*
* @memberOf GraphComponent
*/
generateLine(points) {
const lineFunction = shape
.line()
.x(d => d.x)
.y(d => d.y)
.curve(this.curve);
return lineFunction(points);
}
/**
* Zoom was invoked from event
*
* @memberOf GraphComponent
*/
onZoom($event, direction) {
if (this.enableTrackpadSupport && !$event.ctrlKey) {
this.pan($event.deltaX * -1, $event.deltaY * -1);
return;
}
const zoomFactor = 1 + (direction === 'in' ? this.zoomSpeed : -this.zoomSpeed);
// Check that zooming wouldn't put us out of bounds
const newZoomLevel = this.zoomLevel * zoomFactor;
if (newZoomLevel <= this.minZoomLevel || newZoomLevel >= this.maxZoomLevel) {
return;
}
// Check if zooming is enabled or not
if (!this.enableZoom) {
return;
}
if (this.panOnZoom === true && $event) {
// Absolute mouse X/Y on the screen
const mouseX = $event.clientX;
const mouseY = $event.clientY;
// Transform the mouse X/Y into a SVG X/Y
const svg = this.el.nativeElement.querySelector('svg');
const svgGroup = svg.querySelector('g.chart');
const point = svg.createSVGPoint();
point.x = mouseX;
point.y = mouseY;
const svgPoint = point.matrixTransform(svgGroup.getScreenCTM().inverse());
// Panzoom
this.pan(svgPoint.x, svgPoint.y, true);
this.zoom(zoomFactor);
this.pan(-svgPoint.x, -svgPoint.y, true);
}
else {
this.zoom(zoomFactor);
}
}
/**
* Pan by x/y
*
* @param x
* @param y
*/
pan(x, y, ignoreZoomLevel = false) {
const zoomLevel = ignoreZoomLevel ? 1 : this.zoomLevel;
this.transformationMatrix = transform(this.transformationMatrix, translate(x / zoomLevel, y / zoomLevel));
this.updateTransform();
}
/**
* Pan to a fixed x/y
*
*/
panTo(x, y) {
if (x === null || x === undefined || isNaN(x) || y === null || y === undefined || isNaN(y)) {
return;
}
const panX = -this.panOffsetX - x * this.zoomLevel + this.dims.width / 2;
const panY = -this.panOffsetY - y * this.zoomLevel + this.dims.height / 2;
this.transformationMatrix = transform(this.transformationMatrix, translate(panX / this.zoomLevel, panY / this.zoomLevel));
this.updateTransform();
}
/**
* Zoom by a factor
*
*/
zoom(factor) {
this.transformationMatrix = transform(this.transformationMatrix, scale(factor, factor));
this.zoomChange.emit(this.zoomLevel);
this.updateTransform();
}
/**
* Zoom to a fixed level
*
*/
zoomTo(level) {
this.transformationMatrix.a = isNaN(level) ? this.transformationMatrix.a : Number(level);
this.transformationMatrix.d = isNaN(level) ? this.transformationMatrix.d : Number(level);
this.zoomChange.emit(this.zoomLevel);
if (this.enablePreUpdateTransform) {
this.updateTransform();
}
this.update();
}
/**
* Drag was invoked from an event
*
* @memberOf GraphComponent
*/
onDrag(event) {
if (!this.draggingEnabled) {
return;
}
const node = this.draggingNode;
if (this.layout && typeof this.layout !== 'string' && this.layout.onDrag) {
this.layout.onDrag(node, event);
}
node.position.x += event.movementX / this.zoomLevel;
node.position.y += event.movementY / this.zoomLevel;
// move the node
const x = node.position.x - (this.centerNodesOnPositionChange ? node.dimension.width / 2 : 0);
const y = node.position.y - (this.centerNodesOnPositionChange ? node.dimension.height / 2 : 0);
node.transform = `translate(${x}, ${y})`;
for (const link of this.graph.edges) {
if (link.target === node.id ||
link.source === node.id ||
link.target.id === node.id ||
link.source.id === node.id) {
if (this.layout && typeof this.layout !== 'string') {
const result = this.layout.updateEdge(this.graph, link);
const result$ = result instanceof Observable ? result : of(result);
this.graphSubscription.add(result$.subscribe(graph => {
this.graph = graph;
this.redrawEdge(link);
}));
}
}
}
this.redrawLines(false);
this.updateMinimap();
}
redrawEdge(edge) {
const line = this.generateLine(edge.points);
this.calcDominantBaseline(edge);
edge.oldLine = edge.line;
edge.line = line;
}
/**
* Update the entire view for the new pan position
*
*
* @memberOf GraphComponent
*/
updateTransform() {
this.transform = toSVG(smoothMatrix(this.transformationMatrix, 100));
this.stateChange.emit({ state: NgxGraphStates.Transform });
}
/**
* Node was clicked
*
*
* @memberOf GraphComponent
*/
onClick(event) {
this.select.emit(event);
}
/**
* Node was focused
*
*
* @memberOf GraphComponent
*/
onActivate(event) {
if (this.activeEntries.indexOf(event) > -1) {
return;
}
this.activeEntries = [event, ...this.activeEntries];
this.activate.emit({ value: event, entries: this.activeEntries });
}
/**
* Node was defocused
*
* @memberOf GraphComponent
*/
onDeactivate(event) {
const idx = this.activeEntries.indexOf(event);
this.activeEntries.splice(idx, 1);
this.activeEntries = [...this.activeEntries];
this.deactivate.emit({ value: event, entries: this.activeEntries });
}
/**
* Get the domain series for the nodes
*
* @memberOf GraphComponent
*/
getSeriesDomain() {
return this.nodes
.map(d => this.groupResultsBy(d))
.reduce((nodes, node) => (nodes.indexOf(node) !== -1 ? nodes : nodes.concat([node])), [])
.sort();
}
/**
* Tracking for the link
*
*
* @memberOf GraphComponent
*/
trackLinkBy(index, link) {
return link.id;
}
/**
* Tracking for the node
*
*
* @memberOf GraphComponent
*/
trackNodeBy(index, node) {
return node.id;
}
/**
* Sets the colors the nodes
*
*
* @memberOf GraphComponent
*/
setColors() {
this.colors = new ColorHelper(this.scheme, this.seriesDomain, this.customColors);
}
/**
* On mouse move event, used for panning and dragging.
*
* @memberOf GraphComponent
*/
onMouseMove($event) {
this.isMouseMoveCalled = true;
if ((this.isPanning || this.isMinimapPanning) && this.panningEnabled) {
this.panWithConstraints(this.panningAxis, $event);
}
else if (this.isDragging && this.draggingEnabled) {
this.onDrag($event);
}
}
onMouseDown(event) {
this.isMouseMoveCalled = false;
}
graphClick(event) {
if (!this.isMouseMoveCalled)
this.clickHandler.emit(event);
}
/**
* On touch start event to enable panning.
*
* @memberOf GraphComponent
*/
onTouchStart(event) {
this._touchLastX = event.changedTouches[0].clientX;
this._touchLastY = event.changedTouches[0].clientY;
this.isPanning = true;
}
/**
* On touch move event, used for panning.
*
*/
onTouchMove($event) {
if (this.isPanning && this.panningEnabled) {
const clientX = $event.changedTouches[0].clientX;
const clientY = $event.changedTouches[0].clientY;
const movementX = clientX - this._touchLastX;
const movementY = clientY - this._touchLastY;
this._touchLastX = clientX;
this._touchLastY = clientY;
this.pan(movementX, movementY);
}
}
/**
* On touch end event to disable panning.
*
* @memberOf GraphComponent
*/
onTouchEnd() {
this.isPanning = false;
}
/**
* On mouse up event to disable panning/dragging.
*
* @memberOf GraphComponent
*/
onMouseUp(event) {
this.isDragging = false;
this.isPanning = false;
this.isMinimapPanning = false;
if (this.layout && typeof this.layout !== 'string' && this.layout.onDragEnd) {
this.layout.onDragEnd(this.draggingNode, event);
}
}
/**
* On node mouse down to kick off dragging
*
* @memberOf GraphComponent
*/
onNodeMouseDown(event, node) {
if (!this.draggingEnabled) {
return;
}
this.isDragging = true;
this.draggingNode = node;
if (this.layout && typeof this.layout !== 'string' && this.layout.onDragStart) {
this.layout.onDragStart(node, event);
}
}
/**
* On minimap drag mouse down to kick off minimap panning
*
* @memberOf GraphComponent
*/
onMinimapDragMouseDown() {
this.isMinimapPanning = true;
}
/**
* On minimap pan event. Pans the graph to the clicked position
*
* @memberOf GraphComponent
*/
onMinimapPanTo(event) {
const x = event.offsetX - (this.dims.width - (this.graphDims.width + this.minimapOffsetX) / this.minimapScaleCoefficient);
const y = event.offsetY + this.minimapOffsetY / this.minimapScaleCoefficient;
this.panTo(x * this.minimapScaleCoefficient, y * this.minimapScaleCoefficient);
this.isMinimapPanning = true;
}
/**
* Center the graph in the viewport
*/
center() {
this.panTo(this.graphDims.width / 2, this.graphDims.height / 2);
}
/**
* Zooms to fit the entire graph
*/
zoomToFit(zoomOptions) {
this.dims = calculateViewDimensions({
width: this.width,
height: this.height
});
this.updateGraphDims();
const heightZoom = this.dims.height / this.graphDims.height;
const widthZoom = this.dims.width / this.graphDims.width;
let zoomLevel = Math.min(heightZoom, widthZoom, 1);
if (zoomLevel < this.minZoomLevel) {
zoomLevel = this.minZoomLevel;
}
if (zoomLevel > this.maxZoomLevel) {
zoomLevel = this.maxZoomLevel;
}
if (zoomOptions?.force === true || zoomLevel !== this.zoomLevel) {
this.zoomLevel = zoomLevel;
if (zoomOptions?.autoCenter !== true) {
this.updateTransform();
}
if (zoomOptions?.autoCenter === true) {
this.center();
}
this.zoomChange.emit(this.zoomLevel);
}
}
/**
* Pans to the node
* @param nodeId
*/
panToNodeId(nodeId) {
const node = this.graph.nodes.find(n => n.id === nodeId);
if (!node) {
return;
}
this.panTo(node.position.x, node.position.y);
}
getCompoundNodeChildren(ids) {
return this.nodes.filter(node => ids.includes(node.id));
}
panWithConstraints(key, event) {
let x = event.movementX;
let y = event.movementY;
if (this.isMinimapPanning) {
x = -this.minimapScaleCoefficient * x * this.zoomLevel;
y = -this.minimapScaleCoefficient * y * this.zoomLevel;
}
switch (key) {
case PanningAxis.Horizontal:
this.pan(x, 0);
break;
case PanningAxis.Vertical:
this.pan(0, y);
break;
default:
this.pan(x, y);
break;
}
}
updateMidpointOnEdge(edge, points) {
if (!edge || !points) {
return;
}
if (points.length % 2 === 1) {
edge.midPoint = points[Math.floor(points.length / 2)];
}
else {
// Checking if the current layout is Elk
if (this.layout?.settings?.properties?.['elk.direction']) {
this._calcMidPointElk(edge, points);
}
else {
const _first = points[points.length / 2];
const _second = points[points.length / 2 - 1];
edge.midPoint = {
x: (_first.x + _second.x) / 2,
y: (_first.y + _second.y) / 2
};
}
}
}
_calcMidPointElk(edge, points) {
let _firstX = null;
let _secondX = null;
let _firstY = null;
let _secondY = null;
const orientation = this.layout.settings?.properties['elk.direction'];
const hasBend = orientation === 'RIGHT' ? points.some(p => p.y !== points[0].y) : points.some(p => p.x !== points[0].x);
if (hasBend) {
// getting the last two points
_firstX = points[points.length - 1];
_secondX = points[points.length - 2];
_firstY = points[points.length - 1];
_secondY = points[points.length - 2];
}
else {
if (orientation === 'RIGHT') {
_firstX = points[0];
_secondX = points[points.length - 1];
_firstY = points[points.length / 2];
_secondY = points[points.length / 2 - 1];
}
else {
_firstX = points[points.length / 2];
_secondX = points[points.length / 2 - 1];
_firstY = points[0];
_secondY = points[points.length - 1];
}
}
edge.midPoint = {
x: (_firstX.x + _secondX.x) / 2,
y: (_firstY.y + _secondY.y) / 2
};
}
basicUpdate() {
if (this.view) {
this.width = this.view[0];
this.height = this.view[1];
}
else {
const dims = this.getContainerDims();
if (dims) {
this.width = dims.width;
this.height = dims.height;
}
}
// default values if width or height are 0 or undefined
if (!this.width) {
this.width = 600;
}
if (!this.height) {
this.height = 400;
}
this.width = Math.floor(this.width);
this.height = Math.floor(this.height);
if (this.cd) {
this.cd.markForCheck();
}
}
getContainerDims() {
let width;
let height;
const hostElem = this.el.nativeElement;
if (hostElem.parentNode !== null) {
// Get the container dimensions
const dims = hostElem.parentNode.getBoundingClientRect();
width = dims.width;
height = dims.height;
}
if (width && height) {
return { width, height };
}
return null;
}
/**
* Checks if the graph has dimensions
*/
hasGraphDims() {
return this.graphDims.width > 0 && this.graphDims.height > 0;
}
/**
* Checks if all nodes have dimension
*/
hasNodeDims() {
return this.graph.nodes?.every(node => node.dimension.width > 0 && node.dimension.height > 0);
}
/**
* Checks if all compound nodes have dimension
*/
hasCompoundNodeDims() {
return this.graph.compoundNodes?.every(node => node.dimension.width > 0 && node.dimension.height > 0);
}
/**
* Checks if all clusters have dimension
*/
hasClusterDims() {
return this.graph.clusters?.every(node => node.dimension.width > 0 && node.dimension.height > 0);
}
/**
* Checks if the graph and all nodes have dimension.
*/
hasDims() {
return (this.hasGraphDims() &&
this.hasNodeDims() &&
((this.compoundNodes?.length ? this.hasCompoundNodeDims() : true) ||
(this.clusters?.length ? this.hasClusterDims() : true)));
}
unbindEvents() {
if (this.resizeSubscription) {
this.resizeSubscription.unsubscribe();
}
}
bindWindowResizeEvent() {
const source = observableFromEvent(window, 'resize');
const subscription = source.pipe(debounceTime(200)).subscribe(e => {
this.update();
if (this.cd) {
this.cd.markForCheck();
}
});
this.resizeSubscription = subscription;
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.1.4", ngImport: i0, type: GraphComponent, deps: [{ token: i0.ElementRef }, { token: i0.NgZone }, { token: i0.ChangeDetectorRef }, { token: i1.LayoutService }], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.1.4", type: GraphComponent, isStandalone: false, selector: "ngx-graph", inputs: { nodes: "nodes", clusters: "clusters", compoundNodes: "compoundNodes", links: "links", activeEntries: "activeEntries", curve: "curve", draggingEnabled: "draggingEnabled", nodeHeight: "nodeHeight", nodeMaxHeight: "nodeMaxHeight", nodeMinHeight: "nodeMinHeight", nodeWidth: "nodeWidth", nodeMinWidth: "nodeMinWidth", nodeMaxWidth: "nodeMaxWidth", panningEnabled: "panningEnabled", panningAxis: "panningAxis", enableZoom: "enableZoom", zoomSpeed: "zoomSpeed", minZoomLevel: "minZoomLevel", maxZoomLevel: "maxZoomLevel", autoZoom: "autoZoom", panOnZoom: "panOnZoom", animate: "animate", autoCenter: "autoCenter", update$: "update$", center$: "center$", zoomToFit$: "zoomToFit$", panToNode$: "panToNode$", layout: "layout", layoutSettings: "layoutSettings", enableTrackpadSupport: "enableTrackpadSupport", showMiniMap: "showMiniMap", miniMapMaxWidth: "miniMapMaxWidth", miniMapMaxHeight: "miniMapMaxHeight", miniMapPosition: "miniMapPosition", view: "view", scheme: "scheme", customColors: "customColors", deferDisplayUntilPosition: "deferDisplayUntilPosition", centerNodesOnPositionChange: "centerNodesOnPositionChange", enablePreUpdateTransform: "enablePreUpdateTransform", groupResultsBy: "groupResultsBy", zoomLevel: "zoomLevel", panOffsetX: "panOffsetX", panOffsetY: "panOffsetY" }, outputs: { select: "select", activate: "activate", deactivate: "deactivate", zoomChange: "zoomChange", clickHandler: "clickHandler", stateChange: "stateChange" }, host: { listeners: { "document:mousemove": "onMouseMove($event)", "document:mousedown": "onMouseDown($event)", "document:click": "graphClick($event)", "document:touchmove": "onTouchMove($event)", "document:mouseup": "onMouseUp($event)" } }, queries: [{ propertyName: "linkTemplate", first: true, predicate: ["linkTemplate"], descendants: true }, { propertyName: "nodeTemplate", first: true, predicate: ["nodeTemplate"], descendants: true }, { propertyName: "clusterTemplate", first: true, predicate: ["clusterTemplate"], descendants: true }, { propertyName: "defsTemplate", first: true, predicate: ["defsTemplate"], descendants: true }, { propertyName: "miniMapNodeTemplate", first: true, predicate: ["miniMapNodeTemplate"], descendants: true }], viewQueries: [{ propertyName: "nodeElements", predicate: ["nodeElement"], descendants: true }, { propertyName: "linkElements", predicate: ["linkElement"], descendants: true }], usesOnChanges: true, ngImport: i0, template: "<div\n class=\"ngx-graph-outer\"\n [style.width.px]=\"width\"\n [@animationState]=\"'active'\"\n [@.disabled]=\"!animate\"\n (mouseWheelUp)=\"onZoom($event, 'in')\"\n (mouseWheelDown)=\"onZoom($event, 'out')\"\n mouseWheel\n>\n <svg:svg class=\"ngx-graph\" [attr.width]=\"width\" [attr.height]=\"height\">\n <svg:g\n *ngIf=\"initialized && graph\"\n [attr.transform]=\"transform\"\n (touchstart)=\"onTouchStart($event)\"\n (touchend)=\"onTouchEnd()\"\n class=\"graph chart\"\n >\n <defs>\n <ng-container *ngIf=\"defsTemplate\" [ngTemplateOutlet]=\"defsTemplate\"></ng-container>\n <svg:path\n class=\"text-path\"\n *ngFor=\"let link of graph.edges\"\n [attr.d]=\"link.textPath\"\n [attr.id]=\"link.id\"\n ></svg:path>\n </defs>\n\n <svg:rect\n class=\"panning-rect\"\n [attr.width]=\"dims.width * 100\"\n [attr.height]=\"dims.height * 100\"\n [attr.transform]=\"'translate(' + (-dims.width || 0) * 50 + ',' + (-dims.height || 0) * 50 + ')'\"\n (mousedown)=\"isPanning = true\"\n />\n\n <ng-content></ng-content>\n\n <svg:g class=\"clusters\">\n <svg:g\n #clusterElement\n *ngFor=\"let node of graph.clusters; trackBy: trackNodeBy\"\n class=\"node-group\"\n [class.old-node]=\"animate && oldClusters.has(node.id)\"\n [id]=\"node.id\"\n [attr.transform]=\"node.transform\"\n (click)=\"onClick(node)\"\n >\n <ng-container\n *ngIf=\"clusterTemplate && !node.hidden\"\n [ngTemplateOutlet]=\"clusterTemplate\"\n [ngTemplateOutletContext]=\"{ $implicit: node }\"\n ></ng-container>\n <svg:g *ngIf=\"!clusterTemplate\" class=\"node cluster\">\n <svg:rect\n [attr.width]=\"node.dimension.width\"\n [attr.height]=\"node.dimension.height\"\n [attr.fill]=\"node.data?.color\"\n />\n <svg:text alignment-baseline=\"central\" [attr.x]=\"10\" [attr.y]=\"node.dimension.height / 2\">\n {{ node.label }}\n </svg:text>\n </svg:g>\n </svg:g>\n </svg:g>\n\n <svg:g class=\"compound-nodes\">\n <svg:g\n #nodeElement\n *ngFor=\"let node of graph.compoundNodes; trackBy: trackNodeBy\"\n class=\"node-group\"\n [class.old-node]=\"animate && oldCompoundNodes.has(node.id)\"\n [id]=\"node.id\"\n [attr.transform]=\"node.transform\"\n (click)=\"onClick(node)\"\n (mousedown)=\"onNodeMouseDown($event, node)\"\n >\n <ng-container\n *ngIf=\"nodeTemplate && !node.hidden\"\n [ngTemplateOutlet]=\"nodeTemplate\"\n [ngTemplateOutletContext]=\"{ $implicit: node }\"\n ></ng-container>\n <svg:g *ngIf=\"!nodeTemplate\" class=\"node compound-node\">\n <svg:rect\n [attr.width]=\"node.dimension.width\"\n [attr.height]=\"node.dimension.height\"\n [attr.fill]=\"node.data?.color\"\n />\n <svg:text alignment-baseline=\"central\" [attr.x]=\"10\" [attr.y]=\"node.dimension.height / 2\">\n {{ node.label }}\n </svg:text>\n </svg:g>\n </svg:g>\n </svg:g>\n\n <svg:g class=\"links\">\n <svg:g #linkElement *ngFor=\"let link of graph.edges; trackBy: trackLinkBy\" class=\"link-group\" [id]=\"link.id\">\n <ng-container\n *ngIf=\"linkTemplate\"\n [ngTemplateOutlet]=\"linkTemplate\"\n [ngTemplateOutletContext]=\"{ $implicit: link }\"\n ></ng-container>\n <svg:path *ngIf=\"!linkTemplate\" class=\"edge\" [attr.d]=\"link.line\" />\n </svg:g>\n </svg:g>\n\n <svg:g class=\"nodes\" #nodeGroup>\n <svg:g\n #nodeElement\n *ngFor=\"let node of graph.nodes; trackBy: trackNodeBy\"\n class=\"node-group\"\n [class.old-node]=\"animate && oldNodes.has(node.id)\"\n [id]=\"node.id\"\n [attr.transform]=\"node.transform\"\n (click)=\"onClick(node)\"\n (mousedown)=\"onNodeMouseDown($event, node)\"\n >\n <ng-container\n *ngIf=\"nodeTemplate && !node.hidden\"\n [ngTemplateOutlet]=\"nodeTemplate\"\n [ngTemplateOutletContext]=\"{ $implicit: node }\"\n ></ng-container>\n <svg:circle\n *ngIf=\"!nodeTemplate\"\n r=\"10\"\n [attr.cx]=\"node.dimension.width / 2\"\n [attr.cy]=\"node.dimension.height / 2\"\n [attr.fill]=\"node.data?.color\"\n />\n </svg:g>\n </svg:g>\n </svg:g>\n\n <svg:clipPath [attr.id]=\"minimapClipPathId\">\n <svg:rect\n [attr.width]=\"graphDims.width / minimapScaleCoefficient\"\n [attr.height]=\"graphDims.height / minimapScaleCoefficient\"\n ></svg:rect>\n </svg:clipPath>\n\n <svg:g\n class=\"minimap\"\n *ngIf=\"showMiniMap\"\n [attr.transform]=\"minimapTransform\"\n [attr.clip-path]=\"'url(#' + minimapClipPathId + ')'\"\n >\n <svg:rect\n class=\"minimap-background\"\n [attr.width]=\"graphDims.width / minimapScaleCoefficient\"\n [attr.height]=\"graphDims.height / minimapScaleCoefficient\"\n (mousedown)=\"onMinimapPanTo($event)\"\n ></svg:rect>\n\n <svg:g\n [style.transform]=\"\n 'translate(' +\n -minimapOffsetX / minimapScaleCoefficient +\n 'px,' +\n -minimapOffsetY / minimapScaleCoefficient +\n 'px)'\n \"\n >\n <svg:g class=\"minimap-nodes\" [style.transform]=\"'scale(' + 1 / minimapScaleCoefficient + ')'\">\n <svg:g\n #nodeElement\n *ngFor=\"let node of graph.nodes; trackBy: trackNodeBy\"\n class=\"node-group\"\n [class.old-node]=\"animate && oldNodes.has(node.id)\"\n [id]=\"node.id\"\n [attr.transform]=\"node.transform\"\n >\n <ng-container\n *ngIf=\"miniMapNodeTemplate\"\n [ngTemplateOutlet]=\"miniMapNodeTemplate\"\n [ngTemplateOutletContext]=\"{ $implicit: node }\"\n ></ng-container>\n <ng-container\n *ngIf=\"!miniMapNodeTemplate && nodeTemplate\"\n [ngTemplateOutlet]=\"nodeTemplate\"\n [ngTemplateOutletContext]=\"{ $implicit: node }\"\n ></ng-container>\n <svg:circle\n *ngIf=\"!nodeTemplate && !miniMapNodeTemplate\"\n r=\"10\"\n [attr.cx]=\"node.dimension.width / 2 / minimapScaleCoefficient\"\n [attr.cy]=\"node.dimension.height / 2 / minimapScaleCoefficient\"\n [attr.fill]=\"node.data?.color\"\n />\n </svg:g>\n </svg:g>\n\n <svg:rect\n [attr.transform]=\"\n 'translate(' +\n panOffsetX / zoomLevel / -minimapScaleCoefficient +\n ',' +\n panOffsetY / zoomLevel / -minimapScaleCoefficient +\n ')'\n \"\n class=\"minimap-drag\"\n [class.panning]=\"isMinimapPanning\"\n [attr.width]=\"width / minimapScaleCoefficient / zoomLevel\"\n [attr.height]=\"height / minimapScaleCoefficient / zoomLevel\"\n (mousedown)=\"onMinimapDragMouseDown()\"\n ></svg:rect>\n </svg:g>\n </svg:g>