d3-org-chart-jc
Version:
Highly customizable org chart, created with d3
1,065 lines (942 loc) • 71.5 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('d3-array'), require('d3-flextree'), require('d3-hierarchy'), require('d3-selection'), require('d3-shape'), require('d3-zoom')) :
typeof define === 'function' && define.amd ? define(['exports', 'd3-array', 'd3-flextree', 'd3-hierarchy', 'd3-selection', 'd3-shape', 'd3-zoom'], factory) :
(factory((global.d3 = global.d3 || {}),global.d3,global.d3,global.d3,global.d3,global.d3,global.d3));
}(this, function (exports,d3Array,d3Flextree,d3Hierarchy,d3Selection,d3Shape,d3Zoom) { 'use strict';
const d3 = {
selection: d3Selection.selection,
select: d3Selection.select,
max: d3Array.max,
min: d3Array.min,
sum: d3Array.sum,
cumsum: d3Array.cumsum,
tree: d3Hierarchy.tree,
stratify: d3Hierarchy.stratify,
zoom: d3Zoom.zoom,
zoomIdentity: d3Zoom.zoomIdentity,
linkHorizontal: d3Shape.linkHorizontal,
}
class OrgChart {
constructor() {
// Exposed variables
const attrs = {
id: `ID${Math.floor(Math.random() * 1000000)}`, // Id for event handlings
firstDraw: true,
svgWidth: 800,
svgHeight: window.innerHeight - 100,
scaleExtent:[0.001, 20],
container: "body",
defaultTextFill: "#2C3E50",
defaultFont: "Helvetica",
ctx: document.createElement('canvas').getContext('2d'),
data: null,
duration: 400,
setActiveNodeCentered: true,
expandLevel: 1,
compact: true,
rootMargin: 40,
nodeDefaultBackground: 'none',
connections: [],
lastTransform: { x: 0, y: 0, k: 1 },
nodeId: d => d.nodeId || d.id,
parentNodeId: d => d.parentNodeId || d.parentId,
backgroundColor: 'none',
zoomBehavior: null,
defs: function (state, visibleConnections) {
return `<defs>
${visibleConnections.map(conn => {
const labelWidth = this.getTextWidth(conn.label, { ctx: state.ctx, fontSize: 2, defaultFont: state.defaultFont });
return `
<marker id="${conn.from + "_" + conn.to}" refX="${conn._source.x < conn._target.x ? -7 : 7}" refY="5" markerWidth="500" markerHeight="500" orient="${conn._source.x < conn._target.x ? "auto" : "auto-start-reverse"}" >
<rect rx=0.5 width=${conn.label ? labelWidth + 3 : 0} height=3 y=1 fill="#152785"></rect>
<text font-size="2px" x=1 fill="white" y=3>${conn.label || ''}</text>
</marker>
<marker id="arrow-${conn.from + "_" + conn.to}" markerWidth="500" markerHeight="500" refY="2" refX="1" orient="${conn._source.x < conn._target.x ? "auto" : "auto-start-reverse"}" >
<path transform="translate(0)" d='M0,0 V4 L2,2 Z' fill='#152785' />
</marker>
`}).join("")}
</defs>
`},
connectionsUpdate: function (d, i, arr) {
d3.select(this)
.attr("stroke", d => '#152785')
.attr('stroke-linecap', 'round')
.attr("stroke-width", d => '5')
.attr('pointer-events', 'none')
.attr("marker-start", d => `url(#${d.from + "_" + d.to})`)
.attr("marker-end", d => `url(#arrow-${d.from + "_" + d.to})`)
},
linkUpdate: function (d, i, arr) {
d3.select(this)
.attr("stroke", d => d.data._upToTheRootHighlighted ? '#152785' : 'lightgray')
.attr("stroke-width", d => d.data._upToTheRootHighlighted ? 5 : 2)
if (d.data._upToTheRootHighlighted) {
d3.select(this).raise()
}
},
nodeUpdate: function (d, i, arr) {
d3.select(this)
.select('.node-rect')
.attr("stroke", d => d.data._highlighted || d.data._upToTheRootHighlighted ? '#152785' : 'none')
.attr("stroke-width", d.data._highlighted || d.data._upToTheRootHighlighted ? 10 : 1)
},
nodeWidth: d3Node => 250,
nodeHeight: d => 150,
siblingsMargin: d3Node => 20,
childrenMargin: d => 60,
neightbourMargin: (n1, n2) => 80,
compactMarginPair: d => 100,
compactMarginBetween: (d3Node => 20),
onNodeClick: (d) => d,
linkGroupArc: d3.linkHorizontal().x(d => d.x).y(d => d.y),
// ({ source, target }) => {
// return
// return `M ${source.x} , ${source.y} Q ${(source.x + target.x) / 2 + 100},${source.y-100} ${target.x}, ${target.y}`;
// },
nodeContent: d => `<div style="padding:5px;font-size:10px;">Sample Node(id=${d.id}), override using <br/> <br/>
<code>chart<br/>
.nodeContent({data}=>{ <br/>
return '' // Custom HTML <br/>
})</code>
<br/> <br/>
Or check different <a href="https://github.com/bumbeishvili/org-chart#jump-to-examples" target="_blank">layout examples</a>
</div>`,
layout: "top",// top, left,right, bottom
buttonContent: ({ node, state }) => {
const icons = {
"left": d => d ? `<div style="margin-top:-10px;line-height:1.2;font-size:25px;height:22px">‹</div>` : `<div style="margin-top:-10px;font-size:25px;height:23px">›</div>`,
"bottom": d => d ? `<div style="margin-top:-20px;font-size:25px">ˬ</div>` : `<div style="margin-top:0px;line-height:1.2;height:11px;font-size:25px">ˆ</div>`,
"right": d => d ? `<div style="margin-top:-10px;font-size:25px;height:23px">›</div>` : `<div style="margin-top:-10px;line-height:1.2;font-size:25px;height:22px">‹</div>`,
"top": d => d ? `<div style="margin-top:0px;line-height:1.2;height:11px;font-size:25px">ˆ</div>` : `<div style="margin-top:-20px;font-size:25px">ˬ</div>`,
}
return `<div style="border-radius:3px;padding:3px;font-size:10px;margin:auto auto;background-color:lightgray"> ${icons[state.layout](node.children)} </div>`
},
layoutBindings: {
"left": {
"nodeLeftX": node => 0,
"nodeRightX": node => node.width,
"nodeTopY": node => - node.height / 2,
"nodeBottomY": node => node.height / 2,
"nodeJoinX": node => node.x + node.width,
"nodeJoinY": node => node.y - node.height / 2,
"linkJoinX": node => node.x + node.width,
"linkJoinY": node => node.y,
"linkX": node => node.x,
"linkY": node => node.y,
"linkCompactXStart": node => node.x + node.width / 2,//node.x + (node.compactEven ? node.width / 2 : -node.width / 2),
"linkCompactYStart": node => node.y + (node.compactEven ? node.height / 2 : -node.height / 2),
"compactLinkMidX": (node, state) => node.firstCompactNode.x,// node.firstCompactNode.x + node.firstCompactNode.flexCompactDim[0] / 4 + state.compactMarginPair(node) / 4,
"compactLinkMidY": (node, state) => node.firstCompactNode.y + node.firstCompactNode.flexCompactDim[0] / 4 + state.compactMarginPair(node) / 4,
"linkParentX": node => node.parent.x + node.parent.width,
"linkParentY": node => node.parent.y,
"buttonX": node => node.width,
"buttonY": node => node.height / 2,
"centerTransform": ({ root, rootMargin, centerY, scale, centerX }) => `translate(${rootMargin},${centerY}) scale(${scale})`,
"compactDimension": {
sizeColumn: node => node.height,
sizeRow: node => node.width,
reverse: arr => arr.slice().reverse()
},
"nodeFlexSize": ({ height, width, siblingsMargin, childrenMargin, state, node }) => {
if (state.compact && node.flexCompactDim) {
const result = [node.flexCompactDim[0], node.flexCompactDim[1]]
return result;
};
return [height + siblingsMargin, width + childrenMargin]
},
"zoomTransform": ({ centerY, scale }) => `translate(${0},${centerY}) scale(${scale})`,
"diagonal": this.hdiagonal.bind(this),
"swap": d => { const x = d.x; d.x = d.y; d.y = x; },
"nodeUpdateTransform": ({ x, y, width, height }) => `translate(${x},${y - height / 2})`,
},
"top": {
"nodeLeftX": node => -node.width / 2,
"nodeRightX": node => node.width / 2,
"nodeTopY": node => 0,
"nodeBottomY": node => node.height,
"nodeJoinX": node => node.x - node.width / 2,
"nodeJoinY": node => node.y + node.height,
"linkJoinX": node => node.x,
"linkJoinY": node => node.y + node.height,
"linkCompactXStart": node => node.x + (node.compactEven ? node.width / 2 : -node.width / 2),
"linkCompactYStart": node => node.y + node.height / 2,
"compactLinkMidX": (node, state) => node.firstCompactNode.x + node.firstCompactNode.flexCompactDim[0] / 4 + state.compactMarginPair(node) / 4,
"compactLinkMidY": node => node.firstCompactNode.y,
"compactDimension": {
sizeColumn: node => node.width,
sizeRow: node => node.height,
reverse: arr => arr,
},
"linkX": node => node.x,
"linkY": node => node.y,
"linkParentX": node => node.parent.x,
"linkParentY": node => node.parent.y + node.parent.height,
"buttonX": node => node.width / 2,
"buttonY": node => node.height,
"centerTransform": ({ root, rootMargin, centerY, scale, centerX }) => `translate(${centerX},${rootMargin}) scale(${scale})`,
"nodeFlexSize": ({ height, width, siblingsMargin, childrenMargin, state, node, compactViewIndex }) => {
if (state.compact && node.flexCompactDim) {
const result = [node.flexCompactDim[0], node.flexCompactDim[1]]
return result;
};
return [width + siblingsMargin, height + childrenMargin];
},
"zoomTransform": ({ centerX, scale }) => `translate(${centerX},0}) scale(${scale})`,
"diagonal": this.diagonal.bind(this),
"swap": d => { },
"nodeUpdateTransform": ({ x, y, width, height }) => `translate(${x - width / 2},${y})`,
},
"bottom": {
"nodeLeftX": node => -node.width / 2,
"nodeRightX": node => node.width / 2,
"nodeTopY": node => -node.height,
"nodeBottomY": node => 0,
"nodeJoinX": node => node.x - node.width / 2,
"nodeJoinY": node => node.y - node.height - node.height,
"linkJoinX": node => node.x,
"linkJoinY": node => node.y - node.height,
"linkCompactXStart": node => node.x + (node.compactEven ? node.width / 2 : -node.width / 2),
"linkCompactYStart": node => node.y - node.height / 2,
"compactLinkMidX": (node, state) => node.firstCompactNode.x + node.firstCompactNode.flexCompactDim[0] / 4 + state.compactMarginPair(node) / 4,
"compactLinkMidY": node => node.firstCompactNode.y,
"linkX": node => node.x,
"linkY": node => node.y,
"compactDimension": {
sizeColumn: node => node.width,
sizeRow: node => node.height,
reverse: arr => arr,
},
"linkParentX": node => node.parent.x,
"linkParentY": node => node.parent.y - node.parent.height,
"buttonX": node => node.width / 2,
"buttonY": node => 0,
"centerTransform": ({ root, rootMargin, centerY, scale, centerX, chartHeight }) => `translate(${centerX},${chartHeight - rootMargin}) scale(${scale})`,
"nodeFlexSize": ({ height, width, siblingsMargin, childrenMargin, state, node }) => {
if (state.compact && node.flexCompactDim) {
const result = [node.flexCompactDim[0], node.flexCompactDim[1]]
return result;
};
return [width + siblingsMargin, height + childrenMargin]
},
"zoomTransform": ({ centerX, scale }) => `translate(${centerX},0}) scale(${scale})`,
"diagonal": this.diagonal.bind(this),
"swap": d => { d.y = -d.y; },
"nodeUpdateTransform": ({ x, y, width, height }) => `translate(${x - width / 2},${y - height})`,
},
"right": {
"nodeLeftX": node => -node.width,
"nodeRightX": node => 0,
"nodeTopY": node => - node.height / 2,
"nodeBottomY": node => node.height / 2,
"nodeJoinX": node => node.x - node.width - node.width,
"nodeJoinY": node => node.y - node.height / 2,
"linkJoinX": node => node.x - node.width,
"linkJoinY": node => node.y,
"linkX": node => node.x,
"linkY": node => node.y,
"linkParentX": node => node.parent.x - node.parent.width,
"linkParentY": node => node.parent.y,
"buttonX": node => 0,
"buttonY": node => node.height / 2,
"linkCompactXStart": node => node.x - node.width / 2,//node.x + (node.compactEven ? node.width / 2 : -node.width / 2),
"linkCompactYStart": node => node.y + (node.compactEven ? node.height / 2 : -node.height / 2),
"compactLinkMidX": (node, state) => node.firstCompactNode.x,// node.firstCompactNode.x + node.firstCompactNode.flexCompactDim[0] / 4 + state.compactMarginPair(node) / 4,
"compactLinkMidY": (node, state) => node.firstCompactNode.y + node.firstCompactNode.flexCompactDim[0] / 4 + state.compactMarginPair(node) / 4,
"centerTransform": ({ root, rootMargin, centerY, scale, centerX, chartWidth }) => `translate(${chartWidth - rootMargin},${centerY}) scale(${scale})`,
"nodeFlexSize": ({ height, width, siblingsMargin, childrenMargin, state, node }) => {
if (state.compact && node.flexCompactDim) {
const result = [node.flexCompactDim[0], node.flexCompactDim[1]]
return result;
};
return [height + siblingsMargin, width + childrenMargin]
},
"compactDimension": {
sizeColumn: node => node.height,
sizeRow: node => node.width,
reverse: arr => arr.slice().reverse()
},
"zoomTransform": ({ centerY, scale }) => `translate(${0},${centerY}) scale(${scale})`,
"diagonal": this.hdiagonal.bind(this),
"swap": d => { const x = d.x; d.x = -d.y; d.y = x; },
"nodeUpdateTransform": ({ x, y, width, height }) => `translate(${x - width},${y - height / 2})`,
},
}
};
this.getChartState = () => attrs;
// Dynamically set getter and setter functions for Chart class
Object.keys(attrs).forEach((key) => {
//@ts-ignore
this[key] = function (_) {
if (!arguments.length) {
return attrs[key];
} else {
attrs[key] = _;
}
return this;
};
});
this.initializeEnterExitUpdatePattern();
}
actions(actions) {
/*
interface action {
selector: string; // css selector for the element within nodeContent
onClick?: (event, node, state) => void // on Click Callback
onHover?: (event, node, state) => void // on Hover Callback
}
*/
const attrs = this.getChartState();
const actionsMap = {};
attrs.actions = [];
actions.forEach((action, i) => {
if (!action.onClick && !action.onHover) {
throw new Error('Action requiers an event handler. Either [onClick] or [onHover]');
}
if (!action.selector) {
throw new Error('Action requiers a selector');
}
if (actionsMap[action.selector]) {
throw new Error(`Action with selector [${action.selector}] already exists`);
}
attrs.actions.push(action);
});
return this;
}
initializeEnterExitUpdatePattern() {
d3.selection.prototype.patternify = function (params) {
var container = this;
var selector = params.selector;
var elementTag = params.tag;
var data = params.data || [selector];
// Pattern in action
var selection = container.selectAll("." + selector).data(data, (d, i) => {
if (typeof d === "object") {
if (d.id) { return d.id; }
}
return i;
});
selection.exit().remove();
selection = selection.enter().append(elementTag).merge(selection);
selection.attr("class", selector);
return selection;
};
}
// This method retrieves passed node's children IDs (including node)
getNodeChildren({ data, children, _children }, nodeStore) {
// Store current node ID
nodeStore.push(data);
// Loop over children and recursively store descendants id (expanded nodes)
if (children) {
children.forEach((d) => {
this.getNodeChildren(d, nodeStore);
});
}
// Loop over _children and recursively store descendants id (collapsed nodes)
if (_children) {
_children.forEach((d) => {
this.getNodeChildren(d, nodeStore);
});
}
// Return result
return nodeStore;
}
// This method can be invoked via chart.setZoomFactor API, it zooms to particulat scale
initialZoom(zoomLevel) {
const attrs = this.getChartState();
attrs.lastTransform.k = zoomLevel;
return this;
}
render() {
//InnerFunctions which will update visuals
const attrs = this.getChartState();
if (!attrs.data || attrs.data.length == 0) {
console.log('ORG CHART - Data is empty')
return this;
}
//Drawing containers
const container = d3.select(attrs.container);
const containerRect = container.node().getBoundingClientRect();
if (containerRect.width > 0) attrs.svgWidth = containerRect.width;
//Calculated properties
const calc = {
id: `ID${Math.floor(Math.random() * 1000000)}`, // id for event handlings,
chartWidth: attrs.svgWidth,
chartHeight: attrs.svgHeight
};
attrs.calc = calc;
// Calculate max node depth (it's needed for layout heights calculation)
calc.centerX = calc.chartWidth / 2;
calc.centerY = calc.chartHeight / 2;
// ******************* BEHAVIORS **********************
if (attrs.firstDraw) {
const behaviors = {
zoom: null
};
// Get zooming function
behaviors.zoom = d3.zoom().on("zoom", (event, d) => this.zoomed(event, d)).scaleExtent(attrs.scaleExtent)
attrs.zoomBehavior = behaviors.zoom;
}
//****************** ROOT node work ************************
attrs.flexTreeLayout = d3Flextree.flextree({
nodeSize: node => {
const width = attrs.nodeWidth(node);;
const height = attrs.nodeHeight(node);
const siblingsMargin = attrs.siblingsMargin(node)
const childrenMargin = attrs.childrenMargin(node);
return attrs.layoutBindings[attrs.layout].nodeFlexSize({
state: attrs,
node: node,
width,
height,
siblingsMargin,
childrenMargin
});
}
})
.spacing((nodeA, nodeB) => nodeA.parent == nodeB.parent ? 0 : attrs.neightbourMargin(nodeA, nodeB));
this.setLayouts({ expandNodesFirst: false });
// ************************* DRAWING **************************
//Add svg
const svg = container
.patternify({
tag: "svg",
selector: "svg-chart-container"
})
.style('background-color', attrs.backgroundColor)
.attr("width", attrs.svgWidth)
.attr("height", attrs.svgHeight)
.attr("font-family", attrs.defaultFont)
if (attrs.firstDraw) {
svg.call(attrs.zoomBehavior)
.on("dblclick.zoom", null)
.attr("cursor", "move")
}
attrs.svg = svg;
//Add container g element
const chart = svg
.patternify({
tag: "g",
selector: "chart"
})
// Add one more container g element, for better positioning controls
attrs.centerG = chart
.patternify({
tag: "g",
selector: "center-group"
})
attrs.linksWrapper = attrs.centerG.patternify({
tag: "g",
selector: "links-wrapper"
})
attrs.nodesWrapper = attrs.centerG.patternify({
tag: "g",
selector: "nodes-wrapper"
})
attrs.connectionsWrapper = attrs.centerG.patternify({
tag: "g",
selector: "connections-wrapper"
})
attrs.defsWrapper = svg.patternify({
tag: "g",
selector: "defs-wrapper"
})
if (attrs.firstDraw) {
attrs.centerG.attr("transform", () => {
return attrs.layoutBindings[attrs.layout].centerTransform({
centerX: calc.centerX,
centerY: calc.centerY,
scale: attrs.lastTransform.k,
rootMargin: attrs.rootMargin,
root: attrs.root,
chartHeight: calc.chartHeight,
chartWidth: calc.chartWidth
})
});
}
attrs.chart = chart;
// Display tree contenrs
this.update(attrs.root);
//######################################### UTIL FUNCS ##################################
// This function restyles foreign object elements ()
d3.select(window).on(`resize.${attrs.id}`, () => {
const containerRect = d3.select(attrs.container).node().getBoundingClientRect();
attrs.svg.attr('width', containerRect.width)
});
if (attrs.firstDraw) {
attrs.firstDraw = false;
}
return this;
}
// This function can be invoked via chart.addNode API, and it adds node in tree at runtime
addNode(obj) {
const attrs = this.getChartState();
const nodeFound = attrs.allNodes.filter(({ data }) => attrs.nodeId(data) === attrs.nodeId(obj))[0];
const parentFound = attrs.allNodes.filter(({ data }) => attrs.nodeId(data) === attrs.parentNodeId(obj))[0];
if (nodeFound) {
console.log(`ORG CHART - ADD - Node with id "${attrs.nodeId(obj)}" already exists in tree`)
return this;
}
if (!parentFound) {
console.log(`ORG CHART - ADD - Parent node with id "${attrs.parentNodeId(obj)}" not found in the tree`)
return this;
}
if (obj._centered && !obj._expanded) obj._expanded = true;
attrs.data.push(obj);
// Update state of nodes and redraw graph
this.updateNodesState();
return this;
}
// This function can be invoked via chart.removeNode API, and it removes node from tree at runtime
removeNode(nodeId) {
const attrs = this.getChartState();
const node = attrs.allNodes.filter(({ data }) => attrs.nodeId(data) == nodeId)[0];
if (!node) {
console.log(`ORG CHART - REMOVE - Node with id "${nodeId}" not found in the tree`);
return this;
}
// Remove all node childs
// Retrieve all children nodes ids (including current node itself)
node.descendants()
.forEach(d => d.data._filteredOut = true)
const descendants = this.getNodeChildren(node, [], attrs.nodeId);
descendants.forEach(d => d._filtered = true)
// Filter out retrieved nodes and reassign data
attrs.data = attrs.data.filter(d => !d._filtered);
const updateNodesState = this.updateNodesState.bind(this);
// Update state of nodes and redraw graph
updateNodesState();
return this;
}
groupBy(array, accessor, aggegator) {
const grouped = {}
array.forEach(item => {
const key = accessor(item)
if (!grouped[key]) {
grouped[key] = []
}
grouped[key].push(item)
})
Object.keys(grouped).forEach(key => {
grouped[key] = aggegator(grouped[key])
})
return Object.entries(grouped);
}
calculateCompactFlexDimensions(root) {
const attrs = this.getChartState();
root.eachBefore(node => {
node.firstCompact = null;
node.compactEven = null;
node.flexCompactDim = null;
node.firstCompactNode = null;
})
root.eachBefore(node => {
if (node.children && node.children.length > 1) {
const compactChildren = node.children.filter(d => !d.children);
if (compactChildren.length < 2) return;
compactChildren.forEach((child, i) => {
if (!i) child.firstCompact = true;
if (i % 2) child.compactEven = false;
else child.compactEven = true;
child.row = Math.floor(i / 2);
})
const evenMaxColumnDimension = d3.max(compactChildren.filter(d => d.compactEven), attrs.layoutBindings[attrs.layout].compactDimension.sizeColumn);
const oddMaxColumnDimension = d3.max(compactChildren.filter(d => !d.compactEven), attrs.layoutBindings[attrs.layout].compactDimension.sizeColumn);
const columnSize = Math.max(evenMaxColumnDimension, oddMaxColumnDimension) * 2;
const rowsMapNew = this.groupBy(compactChildren, d => d.row, reducedGroup => d3.max(reducedGroup, d => attrs.layoutBindings[attrs.layout].compactDimension.sizeRow(d) + attrs.compactMarginBetween(d)));
const rowSize = d3.sum(rowsMapNew.map(v => v[1]))
compactChildren.forEach(node => {
node.firstCompactNode = compactChildren[0];
if (node.firstCompact) {
node.flexCompactDim = [
columnSize + attrs.compactMarginPair(node),
rowSize - attrs.compactMarginBetween(node)
];
} else {
node.flexCompactDim = [0, 0];
}
})
node.flexCompactDim = null;
}
})
}
calculateCompactFlexPositions(root) {
const attrs = this.getChartState();
root.eachBefore(node => {
if (node.children) {
const compactChildren = node.children.filter(d => d.flexCompactDim);
const fch = compactChildren[0];
if (!fch) return;
compactChildren.forEach((child, i, arr) => {
if (i == 0) fch.x -= fch.flexCompactDim[0] / 2;
if (i & i % 2 - 1) child.x = fch.x + fch.flexCompactDim[0] * 0.25 - attrs.compactMarginPair(child) / 4;
else if (i) child.x = fch.x + fch.flexCompactDim[0] * 0.75 + attrs.compactMarginPair(child) / 4;
})
const centerX = fch.x + fch.flexCompactDim[0] * 0.5;
fch.x = fch.x + fch.flexCompactDim[0] * 0.25 - attrs.compactMarginPair(fch) / 4;
const offsetX = node.x - centerX;
if (Math.abs(offsetX) < 10) {
compactChildren.forEach(d => d.x += offsetX);
}
const rowsMapNew = this.groupBy(compactChildren, d => d.row, reducedGroup => d3.max(reducedGroup, d => attrs.layoutBindings[attrs.layout].compactDimension.sizeRow(d)));
const cumSum = d3.cumsum(rowsMapNew.map(d => d[1] + attrs.compactMarginBetween(d)));
compactChildren
.forEach((node, i) => {
if (node.row) {
node.y = fch.y + cumSum[node.row - 1]
} else {
node.y = fch.y;
}
})
}
})
}
// This function basically redraws visible graph, based on nodes state
update({ x0, y0, x = 0, y = 0, width, height }) {
const attrs = this.getChartState();
const calc = attrs.calc;
if (attrs.compact) {
this.calculateCompactFlexDimensions(attrs.root);
}
// Assigns the x and y position for the nodes
const treeData = attrs.flexTreeLayout(attrs.root);
// Reassigns the x and y position for the based on the compact layout
if (attrs.compact) {
this.calculateCompactFlexPositions(attrs.root);
}
const nodes = treeData.descendants();
// console.table(nodes.map(d => ({ x: d.x, y: d.y, width: d.width, height: d.height, flexCompactDim: d.flexCompactDim + "" })))
// Get all links
const links = treeData.descendants().slice(1);
nodes.forEach(attrs.layoutBindings[attrs.layout].swap)
// Connections
const connections = attrs.connections;
const allNodesMap = {};
attrs.allNodes.forEach(d => allNodesMap[attrs.nodeId(d.data)] = d);
const visibleNodesMap = {}
nodes.forEach(d => visibleNodesMap[attrs.nodeId(d.data)] = d);
connections.forEach(connection => {
const source = allNodesMap[connection.from];
const target = allNodesMap[connection.to];
connection._source = source;
connection._target = target;
})
const visibleConnections = connections.filter(d => visibleNodesMap[d.from] && visibleNodesMap[d.to]);
const defsString = attrs.defs.bind(this)(attrs, visibleConnections);
const existingString = attrs.defsWrapper.html();
if (defsString !== existingString) {
attrs.defsWrapper.html(defsString)
}
// -------------------------- LINKS ----------------------
// Get links selection
const linkSelection = attrs.linksWrapper
.selectAll("path.link")
.data(links, (d) => attrs.nodeId(d.data));
// Enter any new links at the parent's previous position.
const linkEnter = linkSelection
.enter()
.insert("path", "g")
.attr("class", "link")
.attr("d", (d) => {
const xo = attrs.layoutBindings[attrs.layout].linkJoinX({ x: x0, y: y0, width, height });
const yo = attrs.layoutBindings[attrs.layout].linkJoinY({ x: x0, y: y0, width, height });
const o = { x: xo, y: yo };
return attrs.layoutBindings[attrs.layout].diagonal(o, o, o);
});
// Get links update selection
const linkUpdate = linkEnter.merge(linkSelection);
// Styling links
linkUpdate
.attr("fill", "none")
// Allow external modifications
linkUpdate.each(attrs.linkUpdate);
// Transition back to the parent element position
linkUpdate
.transition()
.duration(attrs.duration)
.attr("d", (d) => {
const n = attrs.compact && d.flexCompactDim ?
{
x: attrs.layoutBindings[attrs.layout].compactLinkMidX(d, attrs),
y: attrs.layoutBindings[attrs.layout].compactLinkMidY(d, attrs)
} :
{
x: attrs.layoutBindings[attrs.layout].linkX(d),
y: attrs.layoutBindings[attrs.layout].linkY(d)
};
const p = {
x: attrs.layoutBindings[attrs.layout].linkParentX(d),
y: attrs.layoutBindings[attrs.layout].linkParentY(d),
};
const m = attrs.compact && d.flexCompactDim ? {
x: attrs.layoutBindings[attrs.layout].linkCompactXStart(d),
y: attrs.layoutBindings[attrs.layout].linkCompactYStart(d),
} : n;
return attrs.layoutBindings[attrs.layout].diagonal(n, p, m);
});
// Remove any links which is exiting after animation
const linkExit = linkSelection
.exit()
.transition()
.duration(attrs.duration)
.attr("d", (d) => {
const xo = attrs.layoutBindings[attrs.layout].linkJoinX({ x, y, width, height });
const yo = attrs.layoutBindings[attrs.layout].linkJoinY({ x, y, width, height });
const o = { x: xo, y: yo };
return attrs.layoutBindings[attrs.layout].diagonal(o, o);
})
.remove();
// -------------------------- CONNECTIONS ----------------------
const connectionsSel = attrs.connectionsWrapper
.selectAll("path.connection")
.data(visibleConnections)
// Enter any new connections at the parent's previous position.
const connEnter = connectionsSel
.enter()
.insert("path", "g")
.attr("class", "connection")
.attr("d", (d) => {
const xo = attrs.layoutBindings[attrs.layout].linkJoinX({ x: x0, y: y0, width, height });
const yo = attrs.layoutBindings[attrs.layout].linkJoinY({ x: x0, y: y0, width, height });
const o = { x: xo, y: yo };
return attrs.layoutBindings[attrs.layout].diagonal(o, o);
});
// Get connections update selection
const connUpdate = connEnter.merge(connectionsSel);
// Styling connections
connUpdate.attr("fill", "none")
// Transition back to the parent element position
connUpdate
.transition()
.duration(attrs.duration)
.attr('d', (d) => {
const xs = attrs.layoutBindings[attrs.layout].linkX({ x: d._source.x, y: d._source.y, width: d._source.width, height: d._source.height });
const ys = attrs.layoutBindings[attrs.layout].linkY({ x: d._source.x, y: d._source.y, width: d._source.width, height: d._source.height });
const xt = attrs.layoutBindings[attrs.layout].linkJoinX({ x: d._target.x, y: d._target.y, width: d._target.width, height: d._target.height });
const yt = attrs.layoutBindings[attrs.layout].linkJoinY({ x: d._target.x, y: d._target.y, width: d._target.width, height: d._target.height });
return attrs.linkGroupArc({ source: { x: xs, y: ys }, target: { x: xt, y: yt } })
})
// Allow external modifications
connUpdate.each(attrs.connectionsUpdate);
// Remove any links which is exiting after animation
const connExit = connectionsSel
.exit()
.transition()
.duration(attrs.duration)
.attr('opacity', 0)
.remove();
// -------------------------- NODES ----------------------
// Get nodes selection
const nodesSelection = attrs.nodesWrapper
.selectAll("g.node")
.data(nodes, ({ data }) => attrs.nodeId(data));
// Enter any new nodes at the parent's previous position.
const nodeEnter = nodesSelection
.enter()
.append("g")
.attr("class", "node")
.attr("transform", (d) => {
if (d == attrs.root) return `translate(${x0},${y0})`
const xj = attrs.layoutBindings[attrs.layout].nodeJoinX({ x: x0, y: y0, width, height });
const yj = attrs.layoutBindings[attrs.layout].nodeJoinY({ x: x0, y: y0, width, height });
return `translate(${xj},${yj})`
})
.attr("cursor", "pointer")
.on("click", (event, { data }) => {
if ([...event.srcElement.classList].includes("node-button-foreign-object")) {
return;
}
attrs.onNodeClick(attrs.nodeId(data));
});
// Add background rectangle for the nodes
nodeEnter
.patternify({
tag: "rect",
selector: "node-rect",
data: (d) => [d]
})
// Node update styles
const nodeUpdate = nodeEnter
.merge(nodesSelection)
.style("font", "12px sans-serif");
// Add foreignObject element inside rectangle
const fo = nodeUpdate.patternify({
tag: "foreignObject",
selector: "node-foreign-object",
data: (d) => [d]
})
.style('overflow', 'visible')
// Add foreign object
fo.patternify({
tag: "xhtml:div",
selector: "node-foreign-object-div",
data: (d) => [d]
})
this.restyleForeignObjectElements();
// Add Node button circle's group (expand-collapse button)
const nodeButtonGroups = nodeEnter
.patternify({
tag: "g",
selector: "node-button-g",
data: (d) => [d]
})
.on("click", (event, d) => this.onButtonClick(event, d));
if (attrs.actions) {
attrs.actions.forEach(action => {
const selected = nodeEnter.select(action.selector);
if (action.onClick) {
selected.on('click', (event, d) => {
event.stopPropagation();
action.onClick(event, d, attrs);
});
}
if (action.onHover) {
selected.on('mouseenter', (event, d) => {
event.stopPropagation();
action.onHover(event, d, attrs);
});
}
})
}
nodeButtonGroups.patternify({
tag: 'rect',
selector: 'node-button-rect',
data: (d) => [d]
})
.attr('opacity', 0)
.attr('pointer-events', 'all')
.attr('width', 40)
.attr('height', 40)
.attr('x', -20)
.attr('y', -20)
// Add expand collapse button content
const nodeFo = nodeButtonGroups
.patternify({
tag: "foreignObject",
selector: "node-button-foreign-object",
data: (d) => [d]
})
.attr('width', 40)
.attr('height', 40)
.attr('x', -20)
.attr('y', -20)
.style('overflow', 'visible')
.patternify({
tag: "xhtml:div",
selector: "node-button-div",
data: (d) => [d]
})
.style('pointer-events', 'none')
.style('display', 'flex')
.style('width', '100%')
.style('height', '100%')
// Transition to the proper position for the node
nodeUpdate
.transition()
.attr("opacity", 0)
.duration(attrs.duration)
.attr("transform", ({ x, y, width, height }) => {
return attrs.layoutBindings[attrs.layout].nodeUpdateTransform({ x, y, width, height });
})
.attr("opacity", 1);
// Style node rectangles
nodeUpdate
.select(".node-rect")
.attr("width", ({ width }) => width)
.attr("height", ({ height }) => height)
.attr("x", ({ width }) => 0)
.attr("y", ({ height }) => 0)
.attr("cursor", "pointer")
.attr('rx', 3)
.attr("fill", attrs.nodeDefaultBackground)
// Move node button group to the desired position
nodeUpdate
.select(".node-button-g")
.attr("transform", ({ data, width, height }) => {
const x = attrs.layoutBindings[attrs.layout].buttonX({ width, height });
const y = attrs.layoutBindings[attrs.layout].buttonY({ width, height });
return `translate(${x},${y})`
})
.attr("display", ({ data }) => {
return data._directSubordinates > 0 ? null : 'none';
})
.attr("opacity", ({ children, _children }) => {
if (children || _children) {
return 1;
}
return 0;
});
// Restyle node button circle
nodeUpdate
.select(".node-button-foreign-object .node-button-div")
.html((node) => {
return attrs.buttonContent({ node, state: attrs })
})
// Restyle button texts
nodeUpdate
.select(".node-button-text")
.attr("text-anchor", "middle")
.attr("alignment-baseline", "middle")
.attr("fill", attrs.defaultTextFill)
.attr("font-size", ({ children }) => {
if (children) return 40;
return 26;
})
.text(({ children }) => {
if (children) return "-";
return "+";
})
.attr("y", this.isEdge() ? 10 : 0);
nodeUpdate.each(attrs.nodeUpdate)
// Remove any exiting nodes after transition
const nodeExitTransition = nodesSelection
.exit()
.attr("opacity", 1)
.transition()
.duration(attrs.duration)
.attr("transform", (d) => {
const ex = attrs.layoutBindings[attrs.layout].nodeJoinX({ x, y, width, height });
const ey = attrs.layoutBindings[attrs.layout].nodeJoinY({ x, y, width, height });
return `translate(${ex},${ey})`
})
.on("end", function () {
d3.select(this).remove();
})
.attr("opacity", 0);
// Store the old positions for transition.
nodes.forEach((d) => {
d.x0 = d.x;
d.y0 = d.y;
});
// CHECK FOR CENTERING
const centeredNode = attrs.allNodes.filter(d => d.data._centered)[0]
if (centeredNode) {
const centeredNodes = centeredNode.data._centeredWithDescendants ? centeredNode.descendants().filter((d, i) => i < 7) : [centeredNode]
centeredNode.data._centeredWithDescendants = null;
centeredNode.data._centered = null;
this.fit({
animate: true,
scale: false,
nodes: centeredNodes
})
}
}
// This function detects whether current browser is edge
isEdge() {
return window.navigator.userAgent.includes("Edge");
}
// Generate horizontal diagonal - play with it here - https://observablehq.com/@bumbeishvili/curved-edges-horizontal-d3-v3-v4-v5-v6
hdiagonal(s, t, m) {
// Define source and target x,y coordinates
const x = s.x;
const y = s.y;
const ex = t.x;
const ey = t.y;
let mx = m && m.x || x;
let my = m && m.y || y;
// Values in case of top reversed and left reversed diagonals
let xrvs = ex - x < 0 ? -1 : 1;
let yrvs = ey - y < 0 ? -1 : 1;
// Define preferred curve radius
let rdef = 35;
// Reduce curve radius, if source-target x space is smaller
let r = Math.abs(ex - x) / 2 < rdef ? Math.abs(ex - x) /