Skip to content

Commit 8fd2133

Browse files
authored
Merge pull request #3712 from plotly/sankey-selection2
sankey: implement node grouping via mouse selection
2 parents e111300 + f8a4073 commit 8fd2133

File tree

11 files changed

+325
-1
lines changed

11 files changed

+325
-1
lines changed

src/components/modebar/buttons.js

+20
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,26 @@ function toggleHover(gd) {
513513
Registry.call('_guiRelayout', gd, 'hovermode', newHover);
514514
}
515515

516+
modeBarButtons.resetViewSankey = {
517+
name: 'resetSankeyGroup',
518+
title: function(gd) { return _(gd, 'Reset view'); },
519+
icon: Icons.home,
520+
click: function(gd) {
521+
var aObj = {
522+
'node.groups': [],
523+
'node.x': [],
524+
'node.y': []
525+
};
526+
for(var i = 0; i < gd._fullData.length; i++) {
527+
var viewInitial = gd._fullData[i]._viewInitial;
528+
aObj['node.groups'].push(viewInitial.node.groups.slice());
529+
aObj['node.x'].push(viewInitial.node.x.slice());
530+
aObj['node.y'].push(viewInitial.node.y.slice());
531+
}
532+
Registry.call('restyle', gd, aObj);
533+
}
534+
};
535+
516536
// buttons when more then one plot types are present
517537

518538
modeBarButtons.toggleHover = {

src/components/modebar/manage.js

+1
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ function getButtonGroups(gd, buttonsToRemove, buttonsToAdd, showSendToCloud) {
142142
}
143143
else if(hasSankey) {
144144
hoverGroup = ['hoverClosestCartesian', 'hoverCompareCartesian'];
145+
resetGroup = ['resetViewSankey'];
145146
}
146147
else { // hasPolar, hasTernary
147148
// always show at least one hover icon.

src/plots/cartesian/select.js

+9
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ function prepSelect(e, startX, startY, dragOptions, mode) {
4949
var allAxes = dragOptions.xaxes.concat(dragOptions.yaxes);
5050
var subtract = e.altKey;
5151

52+
var doneFnCompleted = dragOptions.doneFnCompleted;
53+
5254
var filterPoly, selectionTester, mergedPolygons, currentPolygon;
5355
var i, searchInfo, eventData;
5456

@@ -285,6 +287,8 @@ function prepSelect(e, startX, startY, dragOptions, mode) {
285287
dragOptions.mergedPolygons.length = 0;
286288
[].push.apply(dragOptions.mergedPolygons, mergedPolygons);
287289
}
290+
291+
doneFnCompleted(selection);
288292
});
289293
};
290294
}
@@ -520,6 +524,11 @@ function determineSearchTraces(gd, xAxes, yAxes, subplot) {
520524
var info = createSearchInfo(trace._module, cd, xAxes[0], yAxes[0]);
521525
info.scene = gd._fullLayout._splomScenes[trace.uid];
522526
searchTraces.push(info);
527+
} else if(
528+
trace.type === 'sankey'
529+
) {
530+
var sankeyInfo = createSearchInfo(trace._module, cd, xAxes[0], yAxes[0]);
531+
searchTraces.push(sankeyInfo);
523532
} else {
524533
if(xAxisIds.indexOf(trace.xaxis) === -1) continue;
525534
if(yAxisIds.indexOf(trace.yaxis) === -1) continue;

src/traces/sankey/base_plot.js

+101
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ var getModuleCalcData = require('../../plots/get_data').getModuleCalcData;
1313
var plot = require('./plot');
1414
var fxAttrs = require('../../components/fx/layout_attributes');
1515

16+
var setCursor = require('../../lib/setcursor');
17+
var dragElement = require('../../components/dragelement');
18+
var prepSelect = require('../../plots/cartesian/select').prepSelect;
19+
var Lib = require('../../lib');
20+
var Registry = require('../../registry');
21+
1622
var SANKEY = 'sankey';
1723

1824
exports.name = SANKEY;
@@ -24,6 +30,7 @@ exports.baseLayoutAttrOverrides = overrideAll({
2430
exports.plot = function(gd) {
2531
var calcData = getModuleCalcData(gd.calcdata, SANKEY)[0];
2632
plot(gd, calcData);
33+
exports.updateFx(gd);
2734
};
2835

2936
exports.clean = function(newFullData, newFullLayout, oldFullData, oldFullLayout) {
@@ -32,5 +39,99 @@ exports.clean = function(newFullData, newFullLayout, oldFullData, oldFullLayout)
3239

3340
if(hadPlot && !hasPlot) {
3441
oldFullLayout._paperdiv.selectAll('.sankey').remove();
42+
oldFullLayout._paperdiv.selectAll('.bgsankey').remove();
3543
}
3644
};
45+
46+
exports.updateFx = function(gd) {
47+
for(var i = 0; i < gd._fullData.length; i++) {
48+
subplotUpdateFx(gd, i);
49+
}
50+
};
51+
52+
function subplotUpdateFx(gd, index) {
53+
var trace = gd._fullData[index];
54+
var fullLayout = gd._fullLayout;
55+
56+
var dragMode = fullLayout.dragmode;
57+
var cursor = fullLayout.dragmode === 'pan' ? 'move' : 'crosshair';
58+
var bgRect = trace._bgRect;
59+
60+
if(dragMode === 'pan' || dragMode === 'zoom') return;
61+
62+
setCursor(bgRect, cursor);
63+
64+
var xaxis = {
65+
_id: 'x',
66+
c2p: Lib.identity,
67+
_offset: trace._sankey.translateX,
68+
_length: trace._sankey.width
69+
};
70+
var yaxis = {
71+
_id: 'y',
72+
c2p: Lib.identity,
73+
_offset: trace._sankey.translateY,
74+
_length: trace._sankey.height
75+
};
76+
77+
// Note: dragOptions is needed to be declared for all dragmodes because
78+
// it's the object that holds persistent selection state.
79+
var dragOptions = {
80+
gd: gd,
81+
element: bgRect.node(),
82+
plotinfo: {
83+
id: index,
84+
xaxis: xaxis,
85+
yaxis: yaxis,
86+
fillRangeItems: Lib.noop
87+
},
88+
subplot: index,
89+
// create mock x/y axes for hover routine
90+
xaxes: [xaxis],
91+
yaxes: [yaxis],
92+
doneFnCompleted: function(selection) {
93+
var traceNow = gd._fullData[index];
94+
var newGroups;
95+
var oldGroups = traceNow.node.groups.slice();
96+
var newGroup = [];
97+
98+
function findNode(pt) {
99+
var nodes = traceNow._sankey.graph.nodes;
100+
for(var i = 0; i < nodes.length; i++) {
101+
if(nodes[i].pointNumber === pt) return nodes[i];
102+
}
103+
}
104+
105+
for(var j = 0; j < selection.length; j++) {
106+
var node = findNode(selection[j].pointNumber);
107+
if(!node) continue;
108+
109+
// If the node represents a group
110+
if(node.group) {
111+
// Add all its children to the current selection
112+
for(var k = 0; k < node.childrenNodes.length; k++) {
113+
newGroup.push(node.childrenNodes[k].pointNumber);
114+
}
115+
// Flag group for removal from existing list of groups
116+
oldGroups[node.pointNumber - traceNow.node._count] = false;
117+
} else {
118+
newGroup.push(node.pointNumber);
119+
}
120+
}
121+
122+
newGroups = oldGroups
123+
.filter(Boolean)
124+
.concat([newGroup]);
125+
126+
Registry.call('_guiRestyle', gd, {
127+
'node.groups': [ newGroups ]
128+
}, index);
129+
}
130+
};
131+
132+
dragOptions.prepFn = function(e, startX, startY) {
133+
prepSelect(e, startX, startY, dragOptions, dragMode);
134+
};
135+
136+
dragElement.init(dragOptions);
137+
}

src/traces/sankey/calc.js

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ function convertToD3Sankey(trace) {
4040
if(linkSpec.target[i] > maxNodeId) maxNodeId = linkSpec.target[i];
4141
}
4242
var nodeCount = maxNodeId + 1;
43+
trace.node._count = nodeCount;
4344

4445
// Group nodes
4546
var j;

src/traces/sankey/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Plot.plot = require('./plot');
1818
Plot.moduleType = 'trace';
1919
Plot.name = 'sankey';
2020
Plot.basePlotModule = require('./base_plot');
21+
Plot.selectPoints = require('./select.js');
2122
Plot.categories = ['noOpacity'];
2223
Plot.meta = {
2324
description: [

src/traces/sankey/plot.js

+14
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,20 @@ module.exports = function plot(gd, calcData) {
127127
var svg = fullLayout._paper;
128128
var size = fullLayout._size;
129129

130+
// stash initial view
131+
for(var i = 0; i < calcData.length; i++) {
132+
if(!gd._fullData[i]._viewInitial) {
133+
var node = gd._fullData[i].node;
134+
gd._fullData[i]._viewInitial = {
135+
node: {
136+
groups: node.groups.slice(),
137+
x: node.x.slice(),
138+
y: node.y.slice()
139+
}
140+
};
141+
}
142+
}
143+
130144
var linkSelect = function(element, d) {
131145
var evt = d.link;
132146
evt.originalEvent = d3.event;

src/traces/sankey/render.js

+21-1
Original file line numberDiff line numberDiff line change
@@ -836,6 +836,25 @@ module.exports = function(gd, svg, calcData, layout, callbacks) {
836836
.style('pointer-events', 'auto')
837837
.attr('transform', sankeyTransform);
838838

839+
sankey.each(function(d, i) {
840+
gd._fullData[i]._sankey = d;
841+
// Create dragbox if missing
842+
var dragboxClassName = 'bgsankey-' + d.trace.uid + '-' + i;
843+
Lib.ensureSingle(gd._fullLayout._draggers, 'rect', dragboxClassName);
844+
845+
gd._fullData[i]._bgRect = d3.select('.' + dragboxClassName);
846+
847+
// Style dragbox
848+
gd._fullData[i]._bgRect
849+
.style('pointer-events', 'all')
850+
.attr('width', d.width)
851+
.attr('height', d.height)
852+
.attr('x', d.translateX)
853+
.attr('y', d.translateY)
854+
.classed('bgsankey', true)
855+
.style({fill: 'transparent', 'stroke-width': 0});
856+
});
857+
839858
sankey.transition()
840859
.ease(c.ease).duration(c.duration)
841860
.attr('transform', sankeyTransform);
@@ -925,7 +944,8 @@ module.exports = function(gd, svg, calcData, layout, callbacks) {
925944
.call(attachPointerEvents, sankey, callbacks.nodeEvents)
926945
.call(attachDragHandler, sankeyLink, callbacks, gd); // has to be here as it binds sankeyLink
927946

928-
sankeyNode.transition()
947+
sankeyNode
948+
.transition()
929949
.ease(c.ease).duration(c.duration)
930950
.call(updateNodePositions)
931951
.style('opacity', function(n) { return n.partOfGroup ? 0 : 1;});

src/traces/sankey/select.js

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Copyright 2012-2019, Plotly, Inc.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
'use strict';
10+
11+
module.exports = function selectPoints(searchInfo, selectionTester) {
12+
var cd = searchInfo.cd;
13+
var selection = [];
14+
var fullData = cd[0].trace;
15+
16+
var nodes = fullData._sankey.graph.nodes;
17+
18+
for(var i = 0; i < nodes.length; i++) {
19+
var node = nodes[i];
20+
if(node.partOfGroup) continue; // Those are invisible
21+
22+
// Position of node's centroid
23+
var pos = [(node.x0 + node.x1) / 2, (node.y0 + node.y1) / 2];
24+
25+
// Swap x and y if trace is vertical
26+
if(fullData.orientation === 'v') pos.reverse();
27+
28+
if(selectionTester && selectionTester.contains(pos, false, i, searchInfo)) {
29+
selection.push({
30+
pointNumber: node.pointNumber
31+
// TODO: add eventData
32+
});
33+
}
34+
}
35+
return selection;
36+
};

test/jasmine/tests/sankey_test.js

+61
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ var defaultColors = require('@src/components/color/attributes').defaults;
2323
var drag = require('../assets/drag');
2424
var checkOverlap = require('../assets/check_overlap');
2525
var delay = require('../assets/delay');
26+
var selectButton = require('../assets/modebar_button');
2627

2728
describe('sankey tests', function() {
2829
'use strict';
@@ -379,6 +380,20 @@ describe('sankey tests', function() {
379380
});
380381
});
381382

383+
it('Plotly.deleteTraces removes draggers', function(done) {
384+
var mockCopy = Lib.extendDeep({}, mock);
385+
Plotly.plot(gd, mockCopy)
386+
.then(function() {
387+
expect(document.getElementsByClassName('bgsankey').length).toBe(1);
388+
return Plotly.deleteTraces(gd, [0]);
389+
})
390+
.then(function() {
391+
expect(document.getElementsByClassName('bgsankey').length).toBe(0);
392+
})
393+
.catch(failTest)
394+
.then(done);
395+
});
396+
382397
it('Plotly.plot does not show Sankey if \'visible\' is false', function(done) {
383398
var mockCopy = Lib.extendDeep({}, mock);
384399

@@ -546,6 +561,52 @@ describe('sankey tests', function() {
546561
.catch(failTest)
547562
.then(done);
548563
});
564+
565+
it('resets each subplot to its initial view (ie. x, y groups) via modebar button', function(done) {
566+
var mockCopy = Lib.extendDeep({}, require('@mocks/sankey_subplots_circular'));
567+
568+
// Set initial view
569+
mockCopy.data[0].node.x = [0.25];
570+
mockCopy.data[0].node.y = [0.25];
571+
572+
mockCopy.data[0].node.groups = [];
573+
mockCopy.data[1].node.groups = [[2, 3]];
574+
575+
Plotly.plot(gd, mockCopy)
576+
.then(function() {
577+
expect(gd._fullData[0].node.groups).toEqual([]);
578+
expect(gd._fullData[1].node.groups).toEqual([[2, 3]]);
579+
580+
// Change groups
581+
return Plotly.restyle(gd, {
582+
'node.groups': [[[1, 2]], [[]]],
583+
'node.x': [[0.1]],
584+
'node.y': [[0.1]]
585+
});
586+
})
587+
.then(function() {
588+
// Check current state
589+
expect(gd._fullData[0].node.x).toEqual([0.1]);
590+
expect(gd._fullData[0].node.y).toEqual([0.1]);
591+
592+
expect(gd._fullData[0].node.groups).toEqual([[1, 2]]);
593+
expect(gd._fullData[1].node.groups).toEqual([[]]);
594+
595+
// Click reset
596+
var resetButton = selectButton(gd._fullLayout._modeBar, 'resetViewSankey');
597+
resetButton.click();
598+
})
599+
.then(function() {
600+
// Check we are back to initial view
601+
expect(gd._fullData[0].node.x).toEqual([0.25]);
602+
expect(gd._fullData[0].node.y).toEqual([0.25]);
603+
604+
expect(gd._fullData[0].node.groups).toEqual([]);
605+
expect(gd._fullData[1].node.groups).toEqual([[2, 3]]);
606+
})
607+
.catch(failTest)
608+
.then(done);
609+
});
549610
});
550611

551612
describe('Test hover/click interactions:', function() {

0 commit comments

Comments
 (0)