UNPKG

ngx-relationship-visualiser

Version:

A D3 force-directed-graph, implemented in Typescript for Angular, generates a visualisation graph with customisable link lengths and multiple labels between nodes. The graph can handle new data that will update lines, nodes, links, and path labels. Whenev

1,194 lines (1,189 loc) 51 kB
import * as i0 from '@angular/core'; import { Injectable, EventEmitter, Component, ViewChild, Output, Input, NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import Swal from 'sweetalert2'; import * as d3 from 'd3'; import { ReplaySubject } from 'rxjs'; import Dexie from 'dexie'; import * as dagre from '@dagrejs/dagre'; import * as i4 from '@angular/common'; class DexieService extends Dexie { graphData; constructor() { super('GraphDatabase'); this.version(1).stores({ graphData: 'dataId' }); this.graphData = this.table('graphData'); } async saveGraphData(data) { await this.graphData.put(data); } async getGraphData(dataId) { return await this.graphData.get(dataId); } async deleteGraphData(dataId) { await this.graphData.delete(dataId); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DexieService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DexieService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DexieService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [] }); class VisualiserGraphService { dexieService; constructor(dexieService) { this.dexieService = dexieService; } links = []; nodes = []; gBrush = null; brushMode = false; brushing = false; shiftKey; extent = null; zoom = false; zoomToFit = false; saveGraphData = new ReplaySubject(); update(data, element, zoom, zoomToFit) { const svg = d3.select(element); this.zoom = zoom; this.zoomToFit = zoomToFit; return this._update(d3, svg, data); } ticked(link, node, edgepaths) { link.each(function (d, i, n) { // Total difference in x and y from source to target let diffX = d.target.x - d.source.x; let diffY = d.target.y - d.source.y; // Length of path from center of source node to center of target node let pathLength = Math.sqrt(diffX * diffX + diffY * diffY); // x and y distances from center to outside edge of target node let offsetX = (diffX * 40) / pathLength; let offsetY = (diffY * 40) / pathLength; d3.select(n[i]) .attr('x1', d.source.x + offsetX) .attr('y1', d.source.y + offsetY) .attr('x2', d.target.x - offsetX) .attr('y2', d.target.y - offsetY); }); node.attr('transform', function (d) { return `translate(${d.x}, ${d.y + 50})`; }); // Sets a boundry for the nodes // node.attr('cx', function (d) { // return (d.x = Math.max(40, Math.min(900 - 15, d.x))); // }); // node.attr('cy', function (d) { // return (d.y = Math.max(50, Math.min(600 - 40, d.y))); // }); edgepaths.attr('d', function (d) { return `M ${d.source.x} ${d.source.y} L ${d.target.x} ${d.target.y}`; }); edgepaths.attr('transform', function (d) { if (d.target.x < d.source.x) { const bbox = this.getBBox(); const rx = bbox.x + bbox.width / 2; const ry = bbox.y + bbox.height / 2; return `rotate(180 ${rx} ${ry})`; } else { return 'rotate(0)'; } }); } initDefinitions(svg) { const defs = svg.append('defs'); function createMarker(id, refX, path) { defs .append('marker') .attr('id', id) .attr('viewBox', '-0 -5 10 10') .attr('refX', refX) .attr('refY', 0) .attr('orient', 'auto') .attr('markerWidth', 8) .attr('markerHeight', 8) .attr('xoverflow', 'visible') .append('svg:path') .attr('d', path) .attr('fill', '#b4b4b4') .style('stroke', 'none'); } createMarker('arrowheadSource', 0, 'M 0,-5 L 10 ,0 L 0,5'); createMarker('arrowheadTarget', 2, 'M 10 -5 L 0 0 L 10 5'); return svg; } forceSimulation(_d3, { width, height }) { return _d3 .forceSimulation() .velocityDecay(0.1) .force('link', _d3 .forceLink() .id(function (d) { return d.id; }) .distance(500) .strength(1)) .force('charge', _d3.forceManyBody().strength(0.1)) .force('center', _d3.forceCenter(width / 2, height / 2)) .force('collision', _d3.forceCollide().radius(15)); } compareAndMarkNodesNew(nodes, old_nodes) { // Create a map of ids to node objects for the old_nodes array const oldMap = old_nodes.reduce((map, node) => { map[node.id] = node; return map; }, {}); // Check each node in the nodes array to see if it's new or not nodes.forEach((node) => { if (!oldMap[node.id]) { // Node is new, mark it with the newItem property node.newItem = true; // Remove the dagre coordinates from new nodes so we can set a random one in view node.fx = null; node.fy = null; } }); return nodes; } removeNewItem(nodes) { for (const node of nodes) { if (node.hasOwnProperty('newItem')) { delete node.newItem; } } return nodes; } randomiseNodePositions(nodeData, width, height) { let minDistance = 100; const availableSpace = width * height; let adjustedRequiredSpace = nodeData.length * minDistance * minDistance; if (adjustedRequiredSpace > availableSpace) { while (adjustedRequiredSpace > availableSpace && minDistance > 0) { minDistance -= 10; adjustedRequiredSpace = nodeData.length * minDistance * minDistance; } if (adjustedRequiredSpace > availableSpace) { throw new Error('Not enough space to accommodate all nodes without a fixed position.'); } } nodeData.forEach((node) => { if (node.fx === null && node.fy === null) { let currentMinDistance = minDistance; let canPlaceNode = false; while (!canPlaceNode && currentMinDistance > 0) { node.fx = crypto.getRandomValues(new Uint32Array(1))[0] % width; node.fy = crypto.getRandomValues(new Uint32Array(1))[0] % height; canPlaceNode = !nodeData.some((otherNode) => { if (otherNode.fx === null || otherNode.fy === null || otherNode === node) { return false; } const dx = otherNode.fx - node.fx; const dy = otherNode.fy - node.fy; return Math.sqrt(dx * dx + dy * dy) < currentMinDistance; }); if (!canPlaceNode) { currentMinDistance--; } } if (!canPlaceNode) { throw new Error('Not enough space to accommodate all nodes without a fixed position.'); } } }); return nodeData; } async _update(_d3, svg, data) { const { nodes, links } = data; this.nodes = nodes || []; this.links = links || []; // Disable the reset btn let saveBtn = document.getElementById('save_graph'); saveBtn.setAttribute('disabled', 'true'); // Width/Height of canvas const parentWidth = _d3.select('svg').node().parentNode.clientWidth; const parentHeight = _d3.select('svg').node().parentNode.clientHeight; // Check to see if nodes are in Dexie const oldData = await this.dexieService.getGraphData('nodes'); const oldNodes = oldData ? oldData.nodes : []; if (Array.isArray(oldNodes)) { // Compare and set property for new nodes this.nodes = this.compareAndMarkNodesNew(nodes, oldNodes); // Remove old nodes from Dexie await this.dexieService.deleteGraphData('nodes'); // Add new nodes to Dexie await this.dexieService.saveGraphData({ dataId: 'nodes', nodes: data.nodes, links: data.links }); } else { // Add first set of nodes to Dexie await this.dexieService.saveGraphData({ dataId: 'nodes', nodes: data.nodes, links: data.links }); } // If nodes don't have a fx/fy coordinate we generate a random one - dagre nodes without links and new nodes added to canvas have null coordinates by design this.nodes = this.randomiseNodePositions(this.nodes, parentWidth, parentHeight); // Getting parents lineStyle and adding it to child objects const relationshipsArray = this.links.map(({ lineStyle, targetArrow, sourceArrow, relationships }) => relationships.map((r) => ({ parentLineStyle: lineStyle, parentSourceArrow: sourceArrow, parentTargetArrow: targetArrow, ...r, }))); // Adding dy value based on link number and position in parent relationshipsArray.map((linkRelationship) => { linkRelationship.map((linkObject, i) => { // dy increments of 15px linkObject['dy'] = 20 + i * 15; }); }); // IE11 does not like .flat this.links = relationshipsArray.reduce((acc, val) => acc.concat(val), []); d3.select('svg').append('g'); // Zoom Start const zoomContainer = _d3.select('svg g'); let currentZoom = d3.zoomTransform(d3.select('svg').node()); const updateZoomLevel = () => { const currentScale = currentZoom.k; const maxScale = zoom.scaleExtent()[1]; const zoomPercentage = ((currentScale - 0.5) / (maxScale - 0.5)) * 200; const zoomLevelDisplay = document.getElementById('zoom_level'); const zoomLevelText = `Zoom: ${zoomPercentage.toFixed(0)}%`; const zoomInBtn = document.getElementById('zoom_in'); const zoomOutBtn = document.getElementById('zoom_out'); const zoomResetBtn = document.getElementById('zoom_reset'); // It might not exist depending on the this.zoom boolean if (zoomResetBtn) { zoomResetBtn.setAttribute('disabled', 'true'); } // Check if the zoom level has changed before updating the display / allows for panning without showing the zoom percentage if (zoomLevelDisplay && zoomLevelDisplay.innerHTML !== zoomLevelText) { zoomLevelDisplay.innerHTML = zoomLevelText; zoomLevelDisplay.style.opacity = '1'; setTimeout(() => { if (zoomLevelDisplay) { zoomLevelDisplay.style.opacity = '0'; } }, 2000); } // Disable the zoomInBtn if the zoom level is at 200% if (zoomInBtn) { if (zoomPercentage === 200) { zoomInBtn.setAttribute('disabled', 'true'); } else { zoomInBtn.removeAttribute('disabled'); } } // Disable the zoomOutBtn if the zoom level is at 0% if (zoomOutBtn) { if (zoomPercentage === 0) { zoomOutBtn.setAttribute('disabled', 'true'); } else { zoomOutBtn.removeAttribute('disabled'); } } // Disable the zoomResetBtn if the zoom level is at 100% if (zoomResetBtn) { if (zoomPercentage === 100) { zoomResetBtn.setAttribute('disabled', 'true'); } else { zoomResetBtn.removeAttribute('disabled'); } } }; let zoomedInit; const zoomed = () => { const transform = d3.event.transform; zoomContainer.attr('transform', `translate(${transform.x}, ${transform.y}) scale(${transform.k})`); currentZoom = transform; zoomedInit = true; updateZoomLevel(); }; const zoom = d3 .zoom() .scaleExtent([0.5, 1.5]) .on('start', function () { d3.select(this).style('cursor', this.zoom ? null : 'grabbing'); }) .on('zoom', this.zoom ? zoomed : null) .on('end', function () { d3.select(this).style('cursor', 'grab'); }); svg .call(zoom) .style('cursor', 'grab') .on(this.zoom ? null : 'wheel.zoom', null) .on('dblclick.zoom', null); zoom.filter(() => !d3.event.shiftKey); // Zoom button controls d3.select('#zoom_in').on('click', function () { zoom.scaleBy(svg.transition().duration(750), 1.2); updateZoomLevel(); }); d3.select('#zoom_out').on('click', function () { zoom.scaleBy(svg.transition().duration(750), 0.8); updateZoomLevel(); }); d3.select('#zoom_reset').on('click', function () { zoom.scaleTo(svg.transition().duration(750), 1); updateZoomLevel(); }); // Zoom to fit function and Button const handleZoomToFit = () => { const nodeBBox = zoomContainer.node().getBBox(); // Calculate scale and translate values to fit all nodes const padding = 30; const scaleX = (parentWidth - padding * 2) / nodeBBox.width; const scaleY = (parentHeight - padding * 2) / nodeBBox.height; const scale = Math.min(scaleX, scaleY, 1.0); // Restrict scale to a maximum of 1.0 const translateX = -nodeBBox.x * scale + (parentWidth - nodeBBox.width * scale) / 2; const translateY = -nodeBBox.y * scale + (parentHeight - nodeBBox.height * scale) / 2; // Get the bounding box of all nodes const allNodes = zoomContainer.selectAll('.node-wrapper'); const allNodesBBox = allNodes.nodes().reduce((acc, node) => { const nodeBBox = node.getBBox(); acc.x = Math.min(acc.x, nodeBBox.x); acc.y = Math.min(acc.y, nodeBBox.y); acc.width = Math.max(acc.width, nodeBBox.x + nodeBBox.width); acc.height = Math.max(acc.height, nodeBBox.y + nodeBBox.height); return acc; }, { x: Infinity, y: Infinity, width: -Infinity, height: -Infinity }); // Check if all nodes are within the viewable container if (allNodesBBox.x * scale >= 0 && allNodesBBox.y * scale >= 0 && allNodesBBox.width * scale <= parentWidth && allNodesBBox.height * scale <= parentHeight) { // All nodes are within the viewable container, no need to apply zoom transform return; } // Manually reset the zoom transform zoomContainer .transition() .duration(750) .attr('transform', 'translate(0, 0) scale(1)'); // Apply zoom transform to zoomContainer zoomContainer .transition() .duration(750) .attr('transform', `translate(${translateX}, ${translateY}) scale(${scale})`); // Update the currentZoom variable with the new transform // zoomedInit - created because if zoomToFit is called before anything else it screws up the base transform - e.g. showCurrentMatch if (zoomedInit) { currentZoom.x = translateX; currentZoom.y = translateY; currentZoom.k = scale; } updateZoomLevel(); }; d3.select('#zoom_to_fit').on('click', handleZoomToFit); // Check if zoom level is at 0% or 100% before allowing mousewheel zoom - this stabilises the canvas when the limit is reached svg.on('wheel', () => { const currentScale = currentZoom.k; const maxScale = zoom.scaleExtent()[1]; const minScale = zoom.scaleExtent()[0]; if (currentScale === maxScale || currentScale === minScale) { d3.event.preventDefault(); } }); // Zoom End // For arrows this.initDefinitions(svg); const simulation = this.forceSimulation(_d3, { width: +svg.attr('width'), height: +svg.attr('height'), }); // Brush Start let gBrushHolder = svg.append('g'); let brush = d3 .brush() .on('start', () => { this.brushing = true; nodeEnter.each((d) => { d.previouslySelected = this.shiftKey && d.selected; }); _d3 .selectAll('.edgelabel') .style('font-weight', 400) .style('fill', '#212529'); // Apply styling for the rect.selection d3.selectAll('rect.selection') .style('stroke', 'none') .style('fill', 'steelblue') .style('fill-opacity', 0.4); }) .on('brush', () => { this.extent = d3.event.selection; if (!d3.event.sourceEvent || !this.extent || !this.brushMode) return; if (!currentZoom) return; nodeEnter .classed('selected', (d) => { return (d.selected = d.previouslySelected ^ ((d3.event.selection[0][0] <= d.x * currentZoom.k + currentZoom.x && d.x * currentZoom.k + currentZoom.x < d3.event.selection[1][0] && d3.event.selection[0][1] <= d.y * currentZoom.k + currentZoom.y && d.y * currentZoom.k + currentZoom.y < d3.event.selection[1][1]))); }) .select('.nodeText') .classed('selected', (d) => d.selected) .style('fill', (d) => (d.selected ? 'blue' : '#999')) .style('font-weight', (d) => (d.selected ? 700 : 400)); this.extent = d3.event.selection; }) .on('end', () => { if (!d3.event.sourceEvent || !this.extent || !this.gBrush) return; this.gBrush.call(brush.move, null); if (!this.brushMode) { // the shift key has been release before we ended our brushing this.gBrush.remove(); this.gBrush = null; } this.brushing = false; nodeEnter .select('.nodeText') .filter(function () { return !d3.select(this.parentNode).classed('selected'); }) .style('fill', '#212529') .style('font-weight', 400); }); let keyup = () => { this.shiftKey = false; this.brushMode = false; if (this.gBrush && !this.brushing) { // only remove the brush if we're not actively brushing // otherwise it'll be removed when the brushing ends this.gBrush.remove(); this.gBrush = null; } }; let keydown = () => { // Allows us to turn off default listeners for keyModifiers(shift) brush.filter(() => d3.event.shiftKey); brush.keyModifiers(false); // holding shift key if (d3.event.keyCode === 16) { this.shiftKey = true; if (!this.gBrush) { this.brushMode = true; this.gBrush = gBrushHolder.append('g').attr('class', 'brush'); this.gBrush.call(brush); } } }; d3.select('body').on('keydown', keydown).on('keyup', keyup); // Brush End const filteredLine = this.links.filter(({ source, target }, index, linksArray) => { // Filter out any objects that have matching source and target property values // To display only one line (parentLineStyle) - removes html bloat and a darkened line return (index === linksArray.findIndex((obj) => obj.source === source && obj.target === target)); }); const link = zoomContainer.selectAll().data(filteredLine, function (d) { return d.id; }); zoomContainer.selectAll('line').data(link).exit().remove(); const linkEnter = link .join('line') .style('stroke', function (d) { if (d.parentLineStyle === 'Solid') { return '#777'; } else { return '#b4b4b4'; } }) .style('stroke-opacity', '.6') .style('stroke-dasharray', function (d) { if (d.parentLineStyle === 'Dotted') { return '8,5'; } return null; }) .style('stroke-width', '2px') .attr('class', 'link') .attr('id', function (d) { const suffix = '_line'; const source = d.source ? d.source : ''; const target = d.target ? d.target : ''; return `${source}_${target}${suffix}`; }) .attr('marker-end', function (d) { if (d.parentTargetArrow === true) { return 'url(#arrowheadSource)'; } return null; }) .attr('marker-start', function (d) { if (d.parentSourceArrow === true) { return 'url(#arrowheadTarget)'; } return null; }); link.append('title').text(function (d) { return d.label; }); const edgepaths = zoomContainer.selectAll().data(this.links, function (d) { return d.id; }); zoomContainer.selectAll('path').data(edgepaths).exit().remove(); const edgepathsEnter = edgepaths .join('svg:path') .attr('class', 'edgepath') .attr('fill-opacity', 0) .attr('stroke-opacity', 0) .attr('id', function (d, i) { return 'edgepath' + i; }); const edgelabels = zoomContainer.selectAll().data(this.links, function (d) { return d.id; }); zoomContainer.selectAll('text').data(edgelabels).exit().remove(); const edgelabelsEnter = edgelabels .enter() .append('text') .attr('class', 'edgelabel') .attr('id', function (d) { const suffix = '_text'; const source = d.source ? d.source : ''; const target = d.target ? d.target : ''; return `${source}_${target}${suffix}`; }) .style('text-anchor', 'middle') .attr('font-size', 14) .attr('dy', function (d) { return d.dy; }); edgelabelsEnter .append('textPath') .attr('xlink:href', function (d, i) { return '#edgepath' + i; }) .style('cursor', 'pointer') .attr('dominant-baseline', 'bottom') .attr('startOffset', '50%') .text(function (d) { return d.label; }); edgelabelsEnter .selectAll('textPath') .filter(function (d) { return d.linkIcon; }) .append('tspan') .style('fill', '#856404') .style('font-weight', '700') .attr('class', 'fa') .text(' \uf0c1'); // on normal label link click - highlight labels svg.selectAll('.edgelabel').on('click', function (d) { _d3.event.stopPropagation(); nodeEnter.each(function (d) { d.selected = false; d.previouslySelected = false; }); node.classed('selected', false); _d3 .selectAll('.nodeText') .style('fill', '#212529') .style('font-weight', 400); _d3 .selectAll('.edgelabel') .style('font-weight', 400) .style('fill', '#212529'); _d3.select(this).style('fill', 'blue').style('font-weight', 700); }); const node = zoomContainer.selectAll().data(this.nodes, function (d) { return d.id; }); zoomContainer.selectAll('g').data(node).exit().remove(); const nodeEnter = node .join('g') .call(_d3 .drag() .on('start', function dragstarted(d) { // Enable the save & reset btn document.getElementById('save_graph').removeAttribute('disabled'); if (!_d3.event.active) simulation.alphaTarget(0.9).restart(); if (!d.selected && !this.shiftKey) { // if this node isn't selected, then we have to unselect every other node nodeEnter.classed('selected', function (p) { return (p.selected = p.previouslySelected = false); }); // remove the selected styling on other nodes and labels when we drag a non-selected node _d3 .selectAll('.edgelabel') .style('fill', '#212529') .style('font-weight', 400); _d3 .selectAll('.nodeText') .style('font-weight', 400) .style('fill', '#212529'); } _d3.select(this).classed('selected', function (p) { d.previouslySelected = d.selected; return (d.selected = true); }); nodeEnter .filter(function (d) { return d.selected; }) .each(function (d) { d.fx = d.x; d.fy = d.y; }); }) .on('drag', function dragged(d) { nodeEnter .filter(function (d) { return d.selected; }) .each(function (d) { d.fx += _d3.event.dx; d.fy += _d3.event.dy; }); }) .on('end', function dragended(d) { if (!_d3.event.active) { simulation.alphaTarget(0.3).restart(); } d.fx = d.x; d.fy = d.y; // Subscribes to updated graph positions for save self.saveGraphData.next(data); })) .attr('class', 'node-wrapper') .attr('id', function (d) { return d.id; }); // no collision - already using this in statement const self = this; // node click and ctrl + click svg.selectAll('.node-wrapper').on('click', function (d) { // so we don't activate the canvas .click event _d3.event.stopPropagation(); // setting the select attribute to the object on single select so we can drag them d.selected = true; svg.selectAll('.node-wrapper').classed('selected', false); d3.select(this).classed('selected', true); nodeEnter.each(function (d) { d.selected = false; d.previouslySelected = false; }); // remove style from selected node before the class is removed _d3 .selectAll('.selected') .selectAll('.nodeText') .style('fill', '#212529'); // Remove styles from all other nodes and labels on single left click _d3.selectAll('.edgelabel').style('fill', '#212529'); _d3 .selectAll('.nodeText') .style('fill', '#212529') .style('font-weight', 400); // Add style on single left click _d3 .select(this) .select('.nodeText') .style('fill', 'blue') .style('font-weight', 700); return null; }); //click on canvas to remove selected nodes _d3.select('svg').on('click', () => { nodeEnter.each(function (d) { d.selected = false; d.previouslySelected = false; }); node.classed('selected', false); _d3 .selectAll('.selected') .selectAll('.nodeText') .style('fill', '#212529') .style('font-weight', 400); _d3.selectAll('.selected').classed('selected', false); _d3 .selectAll('.edgelabel') .style('fill', '#212529') .style('font-weight', 400); }); nodeEnter .filter(function (d) { if (!d.imageUrl || d.imageUrl === '') { return null; } return true; }) .append('image') .attr('xlink:href', function (d) { return d.imageUrl; }) .attr('x', -15) .attr('y', -60) .attr('class', function (d) { const suffix = 'image'; const id = d.id ? d.id : ''; return `${id}_${suffix}`; }) .attr('id', function (d) { const id = d.id ? d.id : ''; return `${id}`; }) .attr('width', 32) .attr('height', 32) .attr('class', 'image') .style('cursor', 'pointer'); nodeEnter .filter(function (d) { if (!d.icon || d.icon === '') { return null; } return true; }) .append('text') .text(function (d) { return d.icon; }) .attr('x', -18) .attr('y', -30) .attr('class', function (d) { const suffix = 'icon'; const id = d.id ? d.id : ''; return `${id}_${suffix} fa`; }) .attr('id', function (d) { const id = d.id ? d.id : ''; return `${id}`; }) .style('font-size', '35px') .style('cursor', 'pointer'); const nodeText = nodeEnter .append('text') .attr('id', 'nodeText') .attr('class', 'nodeText') .style('text-anchor', 'middle') .style('cursor', 'pointer') .attr('dy', -3) .attr('y', -25) .attr('testhook', function (d) { const suffix = 'text'; const id = d.id ? d.id : ''; return `${id}_${suffix}`; }); nodeText .selectAll('tspan.text') .data((d) => d.label) .enter() .append('tspan') .attr('class', 'nodeTextTspan') .text((d) => d) .style('font-size', '14px') .attr('x', -10) .attr('dx', 10) .attr('dy', 15); nodeEnter .filter(function (d) { if (!d.additionalIcon) { return null; } return true; }) .append('text') .attr('id', function (d) { const suffix = 'additionalIcon'; const id = d.id ? d.id : ''; return `${id}_${suffix}`; }) .attr('width', 100) .attr('height', 25) .attr('x', 30) .attr('y', -50) .attr('class', 'fa') .style('fill', '#856404') .text(function (d) { return d.additionalIcon; }); // transition effects for new pulsating nodes nodeEnter .filter(function (d) { if (!d.newItem) { return null; } return true; }) .select('text') .transition() .duration(1000) .attr('fill', 'blue') .attr('font-weight', 700) .attr('fill-opacity', 1) .transition() .duration(1000) .attr('fill', 'blue') .attr('font-weight', 700) .attr('fill-opacity', 0.1) .transition() .duration(1000) .attr('fill', 'blue') .attr('font-weight', 700) .attr('fill-opacity', 1) .transition() .duration(1000) .attr('fill', 'blue') .attr('font-weight', 700) .attr('fill-opacity', 0.1) .transition() .duration(1000) .attr('fill', 'blue') .attr('font-weight', 700) .attr('fill-opacity', 1) .transition() .duration(1000) .attr('fill', '#212529') .attr('font-weight', 400) .attr('fill-opacity', 1) .on('end', function () { return d3.select(this).call(_d3.transition); }); nodeEnter .filter(function (d) { if (!d.newItem) { return null; } return true; }) .select('image') .transition() .duration(1000) .attr('width', 45) .attr('height', 45) .transition() .duration(1000) .attr('width', 32) .attr('height', 32) .transition() .duration(1000) .attr('width', 45) .attr('height', 45) .transition() .duration(1000) .attr('width', 32) .attr('height', 32) .transition() .duration(1000) .attr('width', 45) .attr('height', 45) .transition() .duration(1000) .attr('width', 32) .attr('height', 32) .on('end', function () { return d3.select(this).call(d3.transition); }); // Remove the newClass so they don't animate next time this.nodes = this.removeNewItem(this.nodes); nodeEnter.append('title').text(function (d) { return d.label; }); const maxTicks = 30; let tickCount = 0; let zoomToFitCalled = false; simulation.nodes(this.nodes).on('tick', () => { if (this.zoomToFit && tickCount >= maxTicks && !zoomToFitCalled) { simulation.stop(); handleZoomToFit(); zoomToFitCalled = true; } else { this.ticked(linkEnter, nodeEnter, edgepathsEnter); tickCount++; } }); simulation.force('link').links(this.links); self.saveGraphData.next(data); } resetGraph(initialData, element, zoom, zoomToFit) { // Reset the data to its initial state this.nodes = []; this.links = []; // Call the update method again to re-simulate the graph with the new data this.update(initialData, element, zoom, zoomToFit); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: VisualiserGraphService, deps: [{ token: DexieService }], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: VisualiserGraphService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: VisualiserGraphService, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }], ctorParameters: () => [{ type: DexieService }] }); var Orientation; (function (Orientation) { Orientation["LEFT_TO_RIGHT"] = "LR"; Orientation["RIGHT_TO_LEFT"] = "RL"; Orientation["TOP_TO_BOTTOM"] = "TB"; Orientation["BOTTOM_TO_TOP"] = "BT"; })(Orientation || (Orientation = {})); var Alignment; (function (Alignment) { Alignment["CENTER"] = "C"; Alignment["UP_LEFT"] = "UL"; Alignment["UP_RIGHT"] = "UR"; Alignment["DOWN_LEFT"] = "DL"; Alignment["DOWN_RIGHT"] = "DR"; })(Alignment || (Alignment = {})); class DagreNodesOnlyLayout { defaultSettings = { orientation: Orientation.LEFT_TO_RIGHT, marginX: 70, marginY: 70, edgePadding: 200, rankPadding: 300, nodePadding: 100, curveDistance: 20, multigraph: true, compound: true, align: Alignment.UP_RIGHT, acyclicer: undefined, ranker: 'network-simplex', }; dagreGraph; dagreNodes; dagreEdges; renderLayout(graph) { this.createDagreGraph(graph); dagre.layout(this.dagreGraph); for (const dagreNodeId in this.dagreGraph._nodes) { const dagreNode = this.dagreGraph._nodes[dagreNodeId]; const node = graph.nodes.find((n) => n.id === dagreNode.id); if (node.fx === null && node.fy === null) { // Check if the node has any associated edges const hasAssociatedEdges = graph.links.some((link) => link.source === dagreNode.id || link.target === dagreNode.id); if (hasAssociatedEdges) { node.fx = dagreNode.x; node.fy = dagreNode.y; } } node.dimension = { width: dagreNode.width, height: dagreNode.height, }; } return graph; } initRenderLayout(graph) { this.createDagreGraph(graph); dagre.layout(this.dagreGraph); let minFy = Infinity; for (const dagreNodeId in this.dagreGraph._nodes) { const dagreNode = this.dagreGraph._nodes[dagreNodeId]; const node = graph.nodes.find((n) => n.id === dagreNode.id); // Check if the node has any associated edges const hasAssociatedEdges = graph.links.some((link) => link.source === dagreNode.id || link.target === dagreNode.id); if (hasAssociatedEdges) { node.fx = dagreNode.x; node.fy = dagreNode.y; minFy = Math.min(minFy, dagreNode.y - this.defaultSettings.marginY); } else { // Give them a null value to later random position them only when the layout button is pressed so they come into view node.fx = null; node.fy = null; } node.dimension = { width: dagreNode.width, height: dagreNode.height, }; } // Adjust the fy values to start from 0 if there are associated edges if (minFy !== Infinity) { for (const node of graph.nodes) { if (node.fy !== null) { node.fy -= minFy; } } } return graph; } createDagreGraph(graph) { const settings = Object.assign({}, this.defaultSettings); this.dagreGraph = new dagre.graphlib.Graph({ compound: settings.compound, multigraph: settings.multigraph, }); this.dagreGraph.setGraph({ rankdir: settings.orientation, marginx: settings.marginX, marginy: settings.marginY, edgesep: settings.edgePadding, ranksep: settings.rankPadding, nodesep: settings.nodePadding, align: settings.align, acyclicer: settings.acyclicer, ranker: settings.ranker, multigraph: settings.multigraph, compound: settings.compound, }); this.dagreNodes = graph.nodes.map((n) => { const node = Object.assign({}, n); node.width = 20; node.height = 20; node.x = n.fx; node.y = n.fy; return node; }); this.dagreEdges = graph.links.map((l) => { const newLink = Object.assign({}, l); return newLink; }); for (const node of this.dagreNodes) { if (!node.width) { node.width = 20; } if (!node.height) { node.height = 30; } // update dagre this.dagreGraph.setNode(node.id, node); } // update dagre for (const edge of this.dagreEdges) { if (settings.multigraph) { this.dagreGraph.setEdge(edge.source, edge.target, edge, edge.linkId); } else { this.dagreGraph.setEdge(edge.source, edge.target); } } return this.dagreGraph; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DagreNodesOnlyLayout, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DagreNodesOnlyLayout, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DagreNodesOnlyLayout, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }] }); class VisualiserGraphComponent { visualiserGraphService; dagreNodesOnlyLayout; dexieService; graphElement; saveGraphDataEvent = new EventEmitter(); saveGraphData; width; savedGraphData; buttonBarRightPosition; zoom = true; zoomToFit = false; constructor(visualiserGraphService, dagreNodesOnlyLayout, dexieService) { this.visualiserGraphService = visualiserGraphService; this.dagreNodesOnlyLayout = dagreNodesOnlyLayout; this.dexieService = dexieService; } set data(data) { if (!data || !data.dataId) { console.error('Invalid data input'); return; } this.savedGraphData = data; // Timeout: The input arrives before the svg is rendered, therefore the nativeElement does not exist setTimeout(async () => { this.dagreNodesOnlyLayout.renderLayout(data); // Take a copy of input for reset await this.dexieService.saveGraphData(data); this.visualiserGraphService.update(data, this.graphElement.nativeElement, this.zoom, this.zoomToFit); }, 500); } ngOnInit() { this.updateWidth(); this.buttonBarRightPosition = '0'; // Initialize with default empty data if no data is provided if (!this.savedGraphData) { console.warn('No data provided, using empty data set'); this.data = { dataId: '1', nodes: [], links: [], }; } } onResize(event) { this.updateWidth(); } updateWidth() { this.width = document.getElementById('pageId').offsetWidth; } async onConfirmSave() { if (!this.savedGraphData) { console.error('savedGraphData is not set'); return; } Swal.fire({ title: "Save Graph", text: "You won't be able to revert this!", icon: "warning", showCancelButton: true, confirmButtonColor: "#3085d6", cancelButtonColor: "#d33", confirmButtonText: "Save" }).then((result) => { if (result.isConfirmed) { this.visualiserGraphService.saveGraphData.subscribe((saveGraphData) => { this.saveGraphData = saveGraphData; }); this.saveGraphDataEvent.emit(this.saveGraphData); this.disableButtons(true); this.data = this.saveGraphData; Swal.fire({ title: "Saved!", text: "Your graph has been saved.", icon: "success" }); } }); } disableButtons(disabled) { document.querySelectorAll('#save_graph, #reset_graph').forEach(btn => { btn.setAttribute('disabled', String(disabled)); }); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: VisualiserGraphComponent, deps: [{ token: VisualiserGraphService }, { token: DagreNodesOnlyLayout }, { token: DexieService }], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: VisualiserGraphComponent, selector: "visualiser-graph", inputs: { zoom: "zoom", zoomToFit: "zoomToFit", data: "data" }, outputs: { saveGraphDataEvent: "saveGraphDataEvent" }, viewQueries: [{ propertyName: "graphElement", first: true, predicate: ["svgId"], descendants: true }], ngImport: i0, template: "<div class=\"page\" id=\"pageId\" (window:resize)=\"onResize($event)\">\n <div class=\"buttonBar\" [style.right]=\"buttonBarRightPosition\">\n <div class=\"d-flex justify-content-end\">\n <div class=\"btn-group\" role=\"group\" aria-label=\"Controls\">\n <button type=\"button\" id=\"save_graph\" class=\"m-3 btn btn-secondary\" data-toggle=\"tooltip\" data-placement=\"top\"\n title=\"Save data\" (click)=\"onConfirmSave()\">\n <i class=\"bi bi-save\"></i>\n </button>\n </div>\n </div>\n </div>\n <!-- Zoom indicator-->\n <div *ngIf=\"zoom\" class=\"zoomIndicator\">\n <span id=\"zoom_level\"></span>\n </div>\n <svg #svgId [attr.width]=\"width\" height=\"780\"></svg>\n</div>", styles: ["@import\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css\";@import\"https://cdnjs.cloudflare.com/ajax/libs/bootstrap/4.6.2/css/bootstrap.min.css\";@import\"https://cdn.jsdelivr.net/npm/bootstrap-icons@1.3.0/font/bootstrap-icons.css\";#zoom_level{position:relative;background-color:#000c;color:#fff;padding:5px;border-radius:5px;opacity:0;transition:opacity 1s ease-in-out}.zoomIndicator{position:absolute;left:0;padding:10px}.buttonBar{position:absolute;padding:10px}\n"], dependencies: [{ kind: "directive", type: i4.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }] }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: VisualiserGraphComponent, decorators: [{ type: Component, args: [{ selector: 'visualiser-graph', template: "<div class=\"page\" id=\"pageId\" (window:resize)=\"onResize($event)\">\n <div class=\"buttonBar\" [style.right]=\"buttonBarRightPosition\">\n <div class=\"d-flex justify-content-end\">\n <div class=\"btn-group\" role=\"group\" aria-label=\"Controls\">\n <button type=\"button\" id=\"save_graph\" class=\"m-3 btn btn-secondary\" data-toggle=\"tooltip\" data-placement=\"top\"\n title=\"Save data\" (click)=\"onConfirmSave()\">\n <i class=\"bi bi-save\"></i>\n </button>\n </div>\n </div>\n </div>\n <!-- Zoom indicator-->\n <div *ngIf=\"zoom\" class=\"zoomIndicator\">\n <span id=\"zoom_level\"></span>\n </div>\n <svg #svgId [attr.width]=\"width\" height=\"780\"></svg>\n</div>", styles: ["@import\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css\";@import\"https://cdnjs.cloudflare.com/ajax/libs/bootstrap/4.6.2/css/bootstrap.min.css\";@import\"https://cdn.jsdelivr.net/npm/bootstrap-icons@1.3.0/font/bootstrap-icons.css\";#zoom_level{position:relative;background-color:#000c;color:#fff;padding:5px;border-radius:5px;opacity:0;transition:opacity 1s ease-in-out}.zoomIndicator{position:absolute;left:0;padding:10px}.buttonBar{position:absolute;padding:10px}\n"] }] }], ctorParameters: () => [{ type: VisualiserGraphService }, { type: DagreNodesOnlyLayout }, { type: DexieService }], propDecorators: { graphElement: [{ type: ViewChild, args: ['svgId'] }], saveGraphDataEvent: [{ type: Output }], zoom: [{ type: Input }], zoomToFit: [{ type: Input }], data: [{ type: Input }] } }); class NgxRelationshipVisualiserModule { static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: NgxRelationshipVisualiserModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "17.3.12", ngImport: i0, type: NgxRe