'use strict'; var d3Force = require('d3-force'); var interpolateNumber = require('d3-interpolate').interpolateNumber; var d3 = require('@plotly/d3'); var d3Sankey = require('@plotly/d3-sankey'); var d3SankeyCircular = require('@plotly/d3-sankey-circular'); var c = require('./constants'); var tinycolor = require('tinycolor2'); var Color = require('../../components/color'); var Drawing = require('../../components/drawing'); var Lib = require('../../lib'); var strTranslate = Lib.strTranslate; var strRotate = Lib.strRotate; var gup = require('../../lib/gup'); var keyFun = gup.keyFun; var repeat = gup.repeat; var unwrap = gup.unwrap; var svgTextUtils = require('../../lib/svg_text_utils'); var Registry = require('../../registry'); var alignmentConstants = require('../../constants/alignment'); var CAP_SHIFT = alignmentConstants.CAP_SHIFT; var LINE_SPACING = alignmentConstants.LINE_SPACING; var TEXTPAD = 3; // view models function sankeyModel(layout, d, traceIndex) { var calcData = unwrap(d); var trace = calcData.trace; var domain = trace.domain; var horizontal = trace.orientation === 'h'; var nodePad = trace.node.pad; var nodeThickness = trace.node.thickness; var nodeAlign = { justify: d3Sankey.sankeyJustify, left: d3Sankey.sankeyLeft, right: d3Sankey.sankeyRight, center: d3Sankey.sankeyCenter }[trace.node.align]; var width = layout.width * (domain.x[1] - domain.x[0]); var height = layout.height * (domain.y[1] - domain.y[0]); var nodes = calcData._nodes; var links = calcData._links; var circular = calcData.circular; // Select Sankey generator var sankey; if(circular) { sankey = d3SankeyCircular .sankeyCircular() .circularLinkGap(0); } else { sankey = d3Sankey.sankey(); } sankey .iterations(c.sankeyIterations) .size(horizontal ? [width, height] : [height, width]) .nodeWidth(nodeThickness) .nodePadding(nodePad) .nodeId(function(d) { return d.pointNumber; }) .nodeAlign(nodeAlign) .nodes(nodes) .links(links); var graph = sankey(); if(sankey.nodePadding() < nodePad) { Lib.warn('node.pad was reduced to ', sankey.nodePadding(), ' to fit within the figure.'); } // Counters for nested loops var i, j, k; // Create transient nodes for animations for(var nodePointNumber in calcData._groupLookup) { var groupIndex = parseInt(calcData._groupLookup[nodePointNumber]); // Find node representing groupIndex var groupingNode; for(i = 0; i < graph.nodes.length; i++) { if(graph.nodes[i].pointNumber === groupIndex) { groupingNode = graph.nodes[i]; break; } } // If groupinNode is undefined, no links are targeting this group if(!groupingNode) continue; var child = { pointNumber: parseInt(nodePointNumber), x0: groupingNode.x0, x1: groupingNode.x1, y0: groupingNode.y0, y1: groupingNode.y1, partOfGroup: true, sourceLinks: [], targetLinks: [] }; graph.nodes.unshift(child); groupingNode.childrenNodes.unshift(child); } function computeLinkConcentrations() { for(i = 0; i < graph.nodes.length; i++) { var node = graph.nodes[i]; // Links connecting the same two nodes are part of a flow var flows = {}; var flowKey; var link; for(j = 0; j < node.targetLinks.length; j++) { link = node.targetLinks[j]; flowKey = link.source.pointNumber + ':' + link.target.pointNumber; if(!flows.hasOwnProperty(flowKey)) flows[flowKey] = []; flows[flowKey].push(link); } // Compute statistics for each flow var keys = Object.keys(flows); for(j = 0; j < keys.length; j++) { flowKey = keys[j]; var flowLinks = flows[flowKey]; // Find the total size of the flow and total size per label var total = 0; var totalPerLabel = {}; for(k = 0; k < flowLinks.length; k++) { link = flowLinks[k]; if(!totalPerLabel[link.label]) totalPerLabel[link.label] = 0; totalPerLabel[link.label] += link.value; total += link.value; } // Find the ratio of the link's value and the size of the flow for(k = 0; k < flowLinks.length; k++) { link = flowLinks[k]; link.flow = { value: total, labelConcentration: totalPerLabel[link.label] / total, concentration: link.value / total, links: flowLinks }; if(link.concentrationscale) { link.color = tinycolor(link.concentrationscale(link.flow.labelConcentration)); } } } // Gather statistics of all links at current node var totalOutflow = 0; for(j = 0; j < node.sourceLinks.length; j++) { totalOutflow += node.sourceLinks[j].value; } for(j = 0; j < node.sourceLinks.length; j++) { link = node.sourceLinks[j]; link.concentrationOut = link.value / totalOutflow; } var totalInflow = 0; for(j = 0; j < node.targetLinks.length; j++) { totalInflow += node.targetLinks[j].value; } for(j = 0; j < node.targetLinks.length; j++) { link = node.targetLinks[j]; link.concenrationIn = link.value / totalInflow; } } } computeLinkConcentrations(); // Push any overlapping nodes down. function resolveCollisionsTopToBottom(columns) { columns.forEach(function(nodes) { var node; var dy; var y = 0; var n = nodes.length; var i; nodes.sort(function(a, b) { return a.y0 - b.y0; }); for(i = 0; i < n; ++i) { node = nodes[i]; if(node.y0 >= y) { // No overlap } else { dy = (y - node.y0); if(dy > 1e-6) node.y0 += dy, node.y1 += dy; } y = node.y1 + nodePad; } }); } // Group nodes into columns based on their x position function snapToColumns(nodes) { // Sort nodes by x position var orderedNodes = nodes.map(function(n, i) { return { x0: n.x0, index: i }; }) .sort(function(a, b) { return a.x0 - b.x0; }); var columns = []; var colNumber = -1; var colX; // Position of column var lastX = -Infinity; // Position of last node var dx; for(i = 0; i < orderedNodes.length; i++) { var node = nodes[orderedNodes[i].index]; // If the node does not overlap with the last one if(node.x0 > lastX + nodeThickness) { // Start a new column colNumber += 1; colX = node.x0; } lastX = node.x0; // Add node to its associated column if(!columns[colNumber]) columns[colNumber] = []; columns[colNumber].push(node); // Change node's x position to align it with its column dx = colX - node.x0; node.x0 += dx, node.x1 += dx; } return columns; } // Force node position if(trace.node.x.length && trace.node.y.length) { for(i = 0; i < Math.min(trace.node.x.length, trace.node.y.length, graph.nodes.length); i++) { if(trace.node.x[i] && trace.node.y[i]) { var pos = [trace.node.x[i] * width, trace.node.y[i] * height]; graph.nodes[i].x0 = pos[0] - nodeThickness / 2; graph.nodes[i].x1 = pos[0] + nodeThickness / 2; var nodeHeight = graph.nodes[i].y1 - graph.nodes[i].y0; graph.nodes[i].y0 = pos[1] - nodeHeight / 2; graph.nodes[i].y1 = pos[1] + nodeHeight / 2; } } if(trace.arrangement === 'snap') { nodes = graph.nodes; var columns = snapToColumns(nodes); resolveCollisionsTopToBottom(columns); } // Update links sankey.update(graph); } return { circular: circular, key: traceIndex, trace: trace, guid: Lib.randstr(), horizontal: horizontal, width: width, height: height, nodePad: trace.node.pad, nodeLineColor: trace.node.line.color, nodeLineWidth: trace.node.line.width, linkLineColor: trace.link.line.color, linkLineWidth: trace.link.line.width, linkArrowLength: trace.link.arrowlen, valueFormat: trace.valueformat, valueSuffix: trace.valuesuffix, textFont: trace.textfont, translateX: domain.x[0] * layout.width + layout.margin.l, translateY: layout.height - domain.y[1] * layout.height + layout.margin.t, dragParallel: horizontal ? height : width, dragPerpendicular: horizontal ? width : height, arrangement: trace.arrangement, sankey: sankey, graph: graph, forceLayouts: {}, interactionState: { dragInProgress: false, hovered: false } }; } function linkModel(d, l, i) { var tc = tinycolor(l.color); var htc = tinycolor(l.hovercolor); var basicKey = l.source.label + '|' + l.target.label; var key = basicKey + '__' + i; // for event data l.trace = d.trace; l.curveNumber = d.trace.index; return { circular: d.circular, key: key, traceId: d.key, pointNumber: l.pointNumber, link: l, tinyColorHue: Color.tinyRGB(tc), tinyColorAlpha: tc.getAlpha(), tinyColorHoverHue: Color.tinyRGB(htc), tinyColorHoverAlpha: htc.getAlpha(), linkPath: linkPath, linkLineColor: d.linkLineColor, linkLineWidth: d.linkLineWidth, linkArrowLength: d.linkArrowLength, valueFormat: d.valueFormat, valueSuffix: d.valueSuffix, sankey: d.sankey, parent: d, interactionState: d.interactionState, flow: l.flow }; } function createCircularClosedPathString(link, arrowLen) { // Using coordinates computed by d3-sankey-circular var pathString = ''; var offset = link.width / 2; var coords = link.circularPathData; if(link.circularLinkType === 'top') { // Top path pathString = // start at the left of the target node 'M ' + (coords.targetX - arrowLen) + ' ' + (coords.targetY + offset) + ' ' + 'L' + (coords.rightInnerExtent - arrowLen) + ' ' + (coords.targetY + offset) + 'A' + (coords.rightLargeArcRadius + offset) + ' ' + (coords.rightSmallArcRadius + offset) + ' 0 0 1 ' + (coords.rightFullExtent - offset - arrowLen) + ' ' + (coords.targetY - coords.rightSmallArcRadius) + 'L' + (coords.rightFullExtent - offset - arrowLen) + ' ' + coords.verticalRightInnerExtent + 'A' + (coords.rightLargeArcRadius + offset) + ' ' + (coords.rightLargeArcRadius + offset) + ' 0 0 1 ' + (coords.rightInnerExtent - arrowLen) + ' ' + (coords.verticalFullExtent - offset) + 'L' + coords.leftInnerExtent + ' ' + (coords.verticalFullExtent - offset) + 'A' + (coords.leftLargeArcRadius + offset) + ' ' + (coords.leftLargeArcRadius + offset) + ' 0 0 1 ' + (coords.leftFullExtent + offset) + ' ' + coords.verticalLeftInnerExtent + 'L' + (coords.leftFullExtent + offset) + ' ' + (coords.sourceY - coords.leftSmallArcRadius) + 'A' + (coords.leftLargeArcRadius + offset) + ' ' + (coords.leftSmallArcRadius + offset) + ' 0 0 1 ' + coords.leftInnerExtent + ' ' + (coords.sourceY + offset) + 'L' + coords.sourceX + ' ' + (coords.sourceY + offset) + // Walking back 'L' + coords.sourceX + ' ' + (coords.sourceY - offset) + 'L' + coords.leftInnerExtent + ' ' + (coords.sourceY - offset) + 'A' + (coords.leftLargeArcRadius - offset) + ' ' + (coords.leftSmallArcRadius - offset) + ' 0 0 0 ' + (coords.leftFullExtent - offset) + ' ' + (coords.sourceY - coords.leftSmallArcRadius) + 'L' + (coords.leftFullExtent - offset) + ' ' + coords.verticalLeftInnerExtent + 'A' + (coords.leftLargeArcRadius - offset) + ' ' + (coords.leftLargeArcRadius - offset) + ' 0 0 0 ' + coords.leftInnerExtent + ' ' + (coords.verticalFullExtent + offset) + 'L' + (coords.rightInnerExtent - arrowLen) + ' ' + (coords.verticalFullExtent + offset) + 'A' + (coords.rightLargeArcRadius - offset) + ' ' + (coords.rightLargeArcRadius - offset) + ' 0 0 0 ' + (coords.rightFullExtent + offset - arrowLen) + ' ' + coords.verticalRightInnerExtent + 'L' + (coords.rightFullExtent + offset - arrowLen) + ' ' + (coords.targetY - coords.rightSmallArcRadius) + 'A' + (coords.rightLargeArcRadius - offset) + ' ' + (coords.rightSmallArcRadius - offset) + ' 0 0 0 ' + (coords.rightInnerExtent - arrowLen) + ' ' + (coords.targetY - offset) + 'L' + (coords.targetX - arrowLen) + ' ' + (coords.targetY - offset) + (arrowLen > 0 ? 'L' + coords.targetX + ' ' + (coords.targetY) : '') + 'Z'; } else { // Bottom path pathString = // start at the left of the target node 'M ' + (coords.targetX - arrowLen) + ' ' + (coords.targetY - offset) + ' ' + 'L' + (coords.rightInnerExtent - arrowLen) + ' ' + (coords.targetY - offset) + 'A' + (coords.rightLargeArcRadius + offset) + ' ' + (coords.rightSmallArcRadius + offset) + ' 0 0 0 ' + (coords.rightFullExtent - offset - arrowLen) + ' ' + (coords.targetY + coords.rightSmallArcRadius) + 'L' + (coords.rightFullExtent - offset - arrowLen) + ' ' + coords.verticalRightInnerExtent + 'A' + (coords.rightLargeArcRadius + offset) + ' ' + (coords.rightLargeArcRadius + offset) + ' 0 0 0 ' + (coords.rightInnerExtent - arrowLen) + ' ' + (coords.verticalFullExtent + offset) + 'L' + coords.leftInnerExtent + ' ' + (coords.verticalFullExtent + offset) + 'A' + (coords.leftLargeArcRadius + offset) + ' ' + (coords.leftLargeArcRadius + offset) + ' 0 0 0 ' + (coords.leftFullExtent + offset) + ' ' + coords.verticalLeftInnerExtent + 'L' + (coords.leftFullExtent + offset) + ' ' + (coords.sourceY + coords.leftSmallArcRadius) + 'A' + (coords.leftLargeArcRadius + offset) + ' ' + (coords.leftSmallArcRadius + offset) + ' 0 0 0 ' + coords.leftInnerExtent + ' ' + (coords.sourceY - offset) + 'L' + coords.sourceX + ' ' + (coords.sourceY - offset) + // Walking back 'L' + coords.sourceX + ' ' + (coords.sourceY + offset) + 'L' + coords.leftInnerExtent + ' ' + (coords.sourceY + offset) + 'A' + (coords.leftLargeArcRadius - offset) + ' ' + (coords.leftSmallArcRadius - offset) + ' 0 0 1 ' + (coords.leftFullExtent - offset) + ' ' + (coords.sourceY + coords.leftSmallArcRadius) + 'L' + (coords.leftFullExtent - offset) + ' ' + coords.verticalLeftInnerExtent + 'A' + (coords.leftLargeArcRadius - offset) + ' ' + (coords.leftLargeArcRadius - offset) + ' 0 0 1 ' + coords.leftInnerExtent + ' ' + (coords.verticalFullExtent - offset) + 'L' + (coords.rightInnerExtent - arrowLen) + ' ' + (coords.verticalFullExtent - offset) + 'A' + (coords.rightLargeArcRadius - offset) + ' ' + (coords.rightLargeArcRadius - offset) + ' 0 0 1 ' + (coords.rightFullExtent + offset - arrowLen) + ' ' + coords.verticalRightInnerExtent + 'L' + (coords.rightFullExtent + offset - arrowLen) + ' ' + (coords.targetY + coords.rightSmallArcRadius) + 'A' + (coords.rightLargeArcRadius - offset) + ' ' + (coords.rightSmallArcRadius - offset) + ' 0 0 1 ' + (coords.rightInnerExtent - arrowLen) + ' ' + (coords.targetY + offset) + 'L' + (coords.targetX - arrowLen) + ' ' + (coords.targetY + offset) + (arrowLen > 0 ? 'L' + coords.targetX + ' ' + (coords.targetY) : '') + 'Z'; } return pathString; } function linkPath() { var curvature = 0.5; function path(d) { var arrowLen = d.linkArrowLength; if(d.link.circular) { return createCircularClosedPathString(d.link, arrowLen); } else { var maxArrowLength = Math.abs((d.link.target.x0 - d.link.source.x1) / 2); if(arrowLen > maxArrowLength) { arrowLen = maxArrowLength; } var x0 = d.link.source.x1; var x1 = d.link.target.x0 - arrowLen; var xi = interpolateNumber(x0, x1); var x2 = xi(curvature); var x3 = xi(1 - curvature); var y0a = d.link.y0 - d.link.width / 2; var y0b = d.link.y0 + d.link.width / 2; var y1a = d.link.y1 - d.link.width / 2; var y1b = d.link.y1 + d.link.width / 2; var start = 'M' + x0 + ',' + y0a; var upperCurve = 'C' + x2 + ',' + y0a + ' ' + x3 + ',' + y1a + ' ' + x1 + ',' + y1a; var lowerCurve = 'C' + x3 + ',' + y1b + ' ' + x2 + ',' + y0b + ' ' + x0 + ',' + y0b; var rightEnd = arrowLen > 0 ? 'L' + (x1 + arrowLen) + ',' + (y1a + d.link.width / 2) : ''; rightEnd += 'L' + x1 + ',' + y1b; return start + upperCurve + rightEnd + lowerCurve + 'Z'; } } return path; } function nodeModel(d, n) { var tc = tinycolor(n.color); var zoneThicknessPad = c.nodePadAcross; var zoneLengthPad = d.nodePad / 2; n.dx = n.x1 - n.x0; n.dy = n.y1 - n.y0; var visibleThickness = n.dx; var visibleLength = Math.max(0.5, n.dy); var key = 'node_' + n.pointNumber; // If it's a group, it's mutable and should be unique if(n.group) { key = Lib.randstr(); } // for event data n.trace = d.trace; n.curveNumber = d.trace.index; return { index: n.pointNumber, key: key, partOfGroup: n.partOfGroup || false, group: n.group, traceId: d.key, trace: d.trace, node: n, nodePad: d.nodePad, nodeLineColor: d.nodeLineColor, nodeLineWidth: d.nodeLineWidth, textFont: d.textFont, size: d.horizontal ? d.height : d.width, visibleWidth: Math.ceil(visibleThickness), visibleHeight: visibleLength, zoneX: -zoneThicknessPad, zoneY: -zoneLengthPad, zoneWidth: visibleThickness + 2 * zoneThicknessPad, zoneHeight: visibleLength + 2 * zoneLengthPad, labelY: d.horizontal ? n.dy / 2 + 1 : n.dx / 2 + 1, left: n.originalLayer === 1, sizeAcross: d.width, forceLayouts: d.forceLayouts, horizontal: d.horizontal, darkBackground: tc.getBrightness() <= 128, tinyColorHue: Color.tinyRGB(tc), tinyColorAlpha: tc.getAlpha(), valueFormat: d.valueFormat, valueSuffix: d.valueSuffix, sankey: d.sankey, graph: d.graph, arrangement: d.arrangement, uniqueNodeLabelPathId: [d.guid, d.key, key].join('_'), interactionState: d.interactionState, figure: d }; } // rendering snippets function updateNodePositions(sankeyNode) { sankeyNode .attr('transform', function(d) { return strTranslate(d.node.x0.toFixed(3), (d.node.y0).toFixed(3)); }); } function updateNodeShapes(sankeyNode) { sankeyNode.call(updateNodePositions); } function updateShapes(sankeyNode, sankeyLink) { sankeyNode.call(updateNodeShapes); sankeyLink.attr('d', linkPath()); } function sizeNode(rect) { rect .attr('width', function(d) {return d.node.x1 - d.node.x0;}) .attr('height', function(d) {return d.visibleHeight;}); } function salientEnough(d) {return (d.link.width > 1 || d.linkLineWidth > 0);} function sankeyTransform(d) { var offset = strTranslate(d.translateX, d.translateY); return offset + (d.horizontal ? 'matrix(1 0 0 1 0 0)' : 'matrix(0 1 1 0 0 0)'); } // event handling function attachPointerEvents(selection, sankey, eventSet) { selection .on('.basic', null) // remove any preexisting handlers .on('mouseover.basic', function(d) { if(!d.interactionState.dragInProgress && !d.partOfGroup) { eventSet.hover(this, d, sankey); d.interactionState.hovered = [this, d]; } }) .on('mousemove.basic', function(d) { if(!d.interactionState.dragInProgress && !d.partOfGroup) { eventSet.follow(this, d); d.interactionState.hovered = [this, d]; } }) .on('mouseout.basic', function(d) { if(!d.interactionState.dragInProgress && !d.partOfGroup) { eventSet.unhover(this, d, sankey); d.interactionState.hovered = false; } }) .on('click.basic', function(d) { if(d.interactionState.hovered) { eventSet.unhover(this, d, sankey); d.interactionState.hovered = false; } if(!d.interactionState.dragInProgress && !d.partOfGroup) { eventSet.select(this, d, sankey); } }); } function attachDragHandler(sankeyNode, sankeyLink, callbacks, gd) { var dragBehavior = d3.behavior.drag() .origin(function(d) { return { x: d.node.x0 + d.visibleWidth / 2, y: d.node.y0 + d.visibleHeight / 2 }; }) .on('dragstart', function(d) { if(d.arrangement === 'fixed') return; Lib.ensureSingle(gd._fullLayout._infolayer, 'g', 'dragcover', function(s) { gd._fullLayout._dragCover = s; }); Lib.raiseToTop(this); d.interactionState.dragInProgress = d.node; saveCurrentDragPosition(d.node); if(d.interactionState.hovered) { callbacks.nodeEvents.unhover.apply(0, d.interactionState.hovered); d.interactionState.hovered = false; } if(d.arrangement === 'snap') { var forceKey = d.traceId + '|' + d.key; if(d.forceLayouts[forceKey]) { d.forceLayouts[forceKey].alpha(1); } else { // make a forceLayout if needed attachForce(sankeyNode, forceKey, d, gd); } startForce(sankeyNode, sankeyLink, d, forceKey, gd); } }) .on('drag', function(d) { if(d.arrangement === 'fixed') return; var x = d3.event.x; var y = d3.event.y; if(d.arrangement === 'snap') { d.node.x0 = x - d.visibleWidth / 2; d.node.x1 = x + d.visibleWidth / 2; d.node.y0 = y - d.visibleHeight / 2; d.node.y1 = y + d.visibleHeight / 2; } else { if(d.arrangement === 'freeform') { d.node.x0 = x - d.visibleWidth / 2; d.node.x1 = x + d.visibleWidth / 2; } y = Math.max(0, Math.min(d.size - d.visibleHeight / 2, y)); d.node.y0 = y - d.visibleHeight / 2; d.node.y1 = y + d.visibleHeight / 2; } saveCurrentDragPosition(d.node); if(d.arrangement !== 'snap') { d.sankey.update(d.graph); updateShapes(sankeyNode.filter(sameLayer(d)), sankeyLink); } }) .on('dragend', function(d) { if(d.arrangement === 'fixed') return; d.interactionState.dragInProgress = false; for(var i = 0; i < d.node.childrenNodes.length; i++) { d.node.childrenNodes[i].x = d.node.x; d.node.childrenNodes[i].y = d.node.y; } if(d.arrangement !== 'snap') persistFinalNodePositions(d, gd); }); sankeyNode .on('.drag', null) // remove possible previous handlers .call(dragBehavior); } function attachForce(sankeyNode, forceKey, d, gd) { // Attach force to nodes in the same column (same x coordinate) switchToForceFormat(d.graph.nodes); var nodes = d.graph.nodes .filter(function(n) {return n.originalX === d.node.originalX;}) // Filter out children .filter(function(n) {return !n.partOfGroup;}); d.forceLayouts[forceKey] = d3Force.forceSimulation(nodes) .alphaDecay(0) .force('collide', d3Force.forceCollide() .radius(function(n) {return n.dy / 2 + d.nodePad / 2;}) .strength(1) .iterations(c.forceIterations)) .force('constrain', snappingForce(sankeyNode, forceKey, nodes, d, gd)) .stop(); } function startForce(sankeyNode, sankeyLink, d, forceKey, gd) { window.requestAnimationFrame(function faster() { var i; for(i = 0; i < c.forceTicksPerFrame; i++) { d.forceLayouts[forceKey].tick(); } var nodes = d.graph.nodes; switchToSankeyFormat(nodes); d.sankey.update(d.graph); updateShapes(sankeyNode.filter(sameLayer(d)), sankeyLink); if(d.forceLayouts[forceKey].alpha() > 0) { window.requestAnimationFrame(faster); } else { // Make sure the final x position is equal to its original value // because the force simulation will have numerical error var x = d.node.originalX; d.node.x0 = x - d.visibleWidth / 2; d.node.x1 = x + d.visibleWidth / 2; persistFinalNodePositions(d, gd); } }); } function snappingForce(sankeyNode, forceKey, nodes, d) { return function _snappingForce() { var maxVelocity = 0; for(var i = 0; i < nodes.length; i++) { var n = nodes[i]; if(n === d.interactionState.dragInProgress) { // constrain node position to the dragging pointer n.x = n.lastDraggedX; n.y = n.lastDraggedY; } else { n.vx = (n.originalX - n.x) / c.forceTicksPerFrame; // snap to layer n.y = Math.min(d.size - n.dy / 2, Math.max(n.dy / 2, n.y)); // constrain to extent } maxVelocity = Math.max(maxVelocity, Math.abs(n.vx), Math.abs(n.vy)); } if(!d.interactionState.dragInProgress && maxVelocity < 0.1 && d.forceLayouts[forceKey].alpha() > 0) { d.forceLayouts[forceKey].alpha(0); // This will stop the animation loop } }; } // basic data utilities function persistFinalNodePositions(d, gd) { var x = []; var y = []; for(var i = 0; i < d.graph.nodes.length; i++) { var nodeX = (d.graph.nodes[i].x0 + d.graph.nodes[i].x1) / 2; var nodeY = (d.graph.nodes[i].y0 + d.graph.nodes[i].y1) / 2; x.push(nodeX / d.figure.width); y.push(nodeY / d.figure.height); } Registry.call('_guiRestyle', gd, { 'node.x': [x], 'node.y': [y] }, d.trace.index) .then(function() { if(gd._fullLayout._dragCover) gd._fullLayout._dragCover.remove(); }); } function persistOriginalPlace(nodes) { var distinctLayerPositions = []; var i; for(i = 0; i < nodes.length; i++) { nodes[i].originalX = (nodes[i].x0 + nodes[i].x1) / 2; nodes[i].originalY = (nodes[i].y0 + nodes[i].y1) / 2; if(distinctLayerPositions.indexOf(nodes[i].originalX) === -1) { distinctLayerPositions.push(nodes[i].originalX); } } distinctLayerPositions.sort(function(a, b) {return a - b;}); for(i = 0; i < nodes.length; i++) { nodes[i].originalLayerIndex = distinctLayerPositions.indexOf(nodes[i].originalX); nodes[i].originalLayer = nodes[i].originalLayerIndex / (distinctLayerPositions.length - 1); } } function saveCurrentDragPosition(d) { d.lastDraggedX = d.x0 + d.dx / 2; d.lastDraggedY = d.y0 + d.dy / 2; } function sameLayer(d) { return function(n) {return n.node.originalX === d.node.originalX;}; } function switchToForceFormat(nodes) { // force uses x, y as centers for(var i = 0; i < nodes.length; i++) { nodes[i].y = (nodes[i].y0 + nodes[i].y1) / 2; nodes[i].x = (nodes[i].x0 + nodes[i].x1) / 2; } } function switchToSankeyFormat(nodes) { // sankey uses x0, x1, y0, y1 for(var i = 0; i < nodes.length; i++) { nodes[i].y0 = nodes[i].y - nodes[i].dy / 2; nodes[i].y1 = nodes[i].y0 + nodes[i].dy; nodes[i].x0 = nodes[i].x - nodes[i].dx / 2; nodes[i].x1 = nodes[i].x0 + nodes[i].dx; } } // scene graph module.exports = function(gd, svg, calcData, layout, callbacks) { var isStatic = gd._context.staticPlot; // To prevent animation on first render var firstRender = false; Lib.ensureSingle(gd._fullLayout._infolayer, 'g', 'first-render', function() { firstRender = true; }); // To prevent animation on dragging var dragcover = gd._fullLayout._dragCover; var styledData = calcData .filter(function(d) {return unwrap(d).trace.visible;}) .map(sankeyModel.bind(null, layout)); var sankey = svg.selectAll('.' + c.cn.sankey) .data(styledData, keyFun); sankey.exit() .remove(); sankey.enter() .append('g') .classed(c.cn.sankey, true) .style('box-sizing', 'content-box') .style('position', 'absolute') .style('left', 0) .style('shape-rendering', 'geometricPrecision') .style('pointer-events', isStatic ? 'none' : 'auto') .attr('transform', sankeyTransform); sankey.each(function(d, i) { gd._fullData[i]._sankey = d; // Create dragbox if missing var dragboxClassName = 'bgsankey-' + d.trace.uid + '-' + i; Lib.ensureSingle(gd._fullLayout._draggers, 'rect', dragboxClassName); gd._fullData[i]._bgRect = d3.select('.' + dragboxClassName); // Style dragbox gd._fullData[i]._bgRect .style('pointer-events', isStatic ? 'none' : 'all') .attr('width', d.width) .attr('height', d.height) .attr('x', d.translateX) .attr('y', d.translateY) .classed('bgsankey', true) .style({fill: 'transparent', 'stroke-width': 0}); }); sankey.transition() .ease(c.ease).duration(c.duration) .attr('transform', sankeyTransform); var sankeyLinks = sankey.selectAll('.' + c.cn.sankeyLinks) .data(repeat, keyFun); sankeyLinks.enter() .append('g') .classed(c.cn.sankeyLinks, true) .style('fill', 'none'); var sankeyLink = sankeyLinks.selectAll('.' + c.cn.sankeyLink) .data(function(d) { var links = d.graph.links; return links .filter(function(l) {return l.value;}) .map(linkModel.bind(null, d)); }, keyFun); sankeyLink .enter().append('path') .classed(c.cn.sankeyLink, true) .call(attachPointerEvents, sankey, callbacks.linkEvents); sankeyLink .style('stroke', function(d) { return salientEnough(d) ? Color.tinyRGB(tinycolor(d.linkLineColor)) : d.tinyColorHue; }) .style('stroke-opacity', function(d) { return salientEnough(d) ? Color.opacity(d.linkLineColor) : d.tinyColorAlpha; }) .style('fill', function(d) { return d.tinyColorHue; }) .style('fill-opacity', function(d) { return d.tinyColorAlpha; }) .style('stroke-width', function(d) { return salientEnough(d) ? d.linkLineWidth : 1; }) .attr('d', linkPath()); sankeyLink .style('opacity', function() { return (gd._context.staticPlot || firstRender || dragcover) ? 1 : 0;}) .transition() .ease(c.ease).duration(c.duration) .style('opacity', 1); sankeyLink.exit() .transition() .ease(c.ease).duration(c.duration) .style('opacity', 0) .remove(); var sankeyNodeSet = sankey.selectAll('.' + c.cn.sankeyNodeSet) .data(repeat, keyFun); sankeyNodeSet.enter() .append('g') .classed(c.cn.sankeyNodeSet, true); sankeyNodeSet .style('cursor', function(d) { switch(d.arrangement) { case 'fixed': return 'default'; case 'perpendicular': return 'ns-resize'; default: return 'move'; } }); var sankeyNode = sankeyNodeSet.selectAll('.' + c.cn.sankeyNode) .data(function(d) { var nodes = d.graph.nodes; persistOriginalPlace(nodes); return nodes .map(nodeModel.bind(null, d)); }, keyFun); sankeyNode.enter() .append('g') .classed(c.cn.sankeyNode, true) .call(updateNodePositions) .style('opacity', function(n) { return ((gd._context.staticPlot || firstRender) && !n.partOfGroup) ? 1 : 0;}); sankeyNode .call(attachPointerEvents, sankey, callbacks.nodeEvents) .call(attachDragHandler, sankeyLink, callbacks, gd); // has to be here as it binds sankeyLink sankeyNode .transition() .ease(c.ease).duration(c.duration) .call(updateNodePositions) .style('opacity', function(n) { return n.partOfGroup ? 0 : 1;}); sankeyNode.exit() .transition() .ease(c.ease).duration(c.duration) .style('opacity', 0) .remove(); var nodeRect = sankeyNode.selectAll('.' + c.cn.nodeRect) .data(repeat); nodeRect.enter() .append('rect') .classed(c.cn.nodeRect, true) .call(sizeNode); nodeRect .style('stroke-width', function(d) {return d.nodeLineWidth;}) .style('stroke', function(d) {return Color.tinyRGB(tinycolor(d.nodeLineColor));}) .style('stroke-opacity', function(d) {return Color.opacity(d.nodeLineColor);}) .style('fill', function(d) {return d.tinyColorHue;}) .style('fill-opacity', function(d) {return d.tinyColorAlpha;}); nodeRect.transition() .ease(c.ease).duration(c.duration) .call(sizeNode); var nodeLabel = sankeyNode.selectAll('.' + c.cn.nodeLabel) .data(repeat); nodeLabel.enter() .append('text') .classed(c.cn.nodeLabel, true) .style('cursor', 'default'); nodeLabel .attr('data-notex', 1) // prohibit tex interpretation until we can handle tex and regular text together .text(function(d) { return d.node.label; }) .each(function(d) { var e = d3.select(this); Drawing.font(e, d.textFont); svgTextUtils.convertToTspans(e, gd); }) .attr('text-anchor', function(d) { return (d.horizontal && d.left) ? 'end' : 'start'; }) .attr('transform', function(d) { var e = d3.select(this); // how much to shift a multi-line label to center it vertically. var nLines = svgTextUtils.lineCount(e); var blockHeight = d.textFont.size * ( (nLines - 1) * LINE_SPACING - CAP_SHIFT ); var posX = d.nodeLineWidth / 2 + TEXTPAD; var posY = ((d.horizontal ? d.visibleHeight : d.visibleWidth) - blockHeight) / 2; if(d.horizontal) { if(d.left) { posX = -posX; } else { posX += d.visibleWidth; } } var flipText = d.horizontal ? '' : ( 'scale(-1,1)' + strRotate(90) ); return strTranslate( d.horizontal ? posX : posY, d.horizontal ? posY : posX ) + flipText; }); nodeLabel .transition() .ease(c.ease).duration(c.duration); };