Skip to content

Commit ef6b259

Browse files
authoredMay 1, 2018
Merge pull request #2581 from plotly/legend-events
Legend events
2 parents a22c0d5 + 05e1b6b commit ef6b259

File tree

4 files changed

+350
-64
lines changed

4 files changed

+350
-64
lines changed
 

Diff for: ‎src/components/legend/draw.js

+43-19
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ var d3 = require('d3');
1313
var Lib = require('../../lib');
1414
var Plots = require('../../plots/plots');
1515
var Registry = require('../../registry');
16+
var Events = require('../../lib/events');
1617
var dragElement = require('../dragelement');
1718
var Drawing = require('../drawing');
1819
var Color = require('../color');
@@ -347,22 +348,53 @@ module.exports = function draw(gd) {
347348
e.clientY >= bbox.top && e.clientY <= bbox.bottom);
348349
});
349350
if(clickedTrace.size() > 0) {
350-
if(numClicks === 1) {
351-
legend._clickTimeout = setTimeout(function() {
352-
handleClick(clickedTrace, gd, numClicks);
353-
}, DBLCLICKDELAY);
354-
} else if(numClicks === 2) {
355-
if(legend._clickTimeout) {
356-
clearTimeout(legend._clickTimeout);
357-
}
358-
handleClick(clickedTrace, gd, numClicks);
359-
}
351+
clickOrDoubleClick(gd, legend, clickedTrace, numClicks, e);
360352
}
361353
}
362354
});
363355
}
364356
};
365357

358+
function clickOrDoubleClick(gd, legend, legendItem, numClicks, evt) {
359+
var trace = legendItem.data()[0][0].trace;
360+
361+
var evtData = {
362+
event: evt,
363+
node: legendItem.node(),
364+
curveNumber: trace.index,
365+
expandedIndex: trace._expandedIndex,
366+
data: gd.data,
367+
layout: gd.layout,
368+
frames: gd._transitionData._frames,
369+
config: gd._context,
370+
fullData: gd._fullData,
371+
fullLayout: gd._fullLayout
372+
};
373+
374+
if(trace._group) {
375+
evtData.group = trace._group;
376+
}
377+
if(trace.type === 'pie') {
378+
evtData.label = legendItem.datum()[0].label;
379+
}
380+
381+
var clickVal = Events.triggerHandler(gd, 'plotly_legendclick', evtData);
382+
if(clickVal === false) return;
383+
384+
if(numClicks === 1) {
385+
legend._clickTimeout = setTimeout(function() {
386+
handleClick(legendItem, gd, numClicks);
387+
}, DBLCLICKDELAY);
388+
}
389+
else if(numClicks === 2) {
390+
if(legend._clickTimeout) clearTimeout(legend._clickTimeout);
391+
gd._legendMouseDownTime = 0;
392+
393+
var dblClickVal = Events.triggerHandler(gd, 'plotly_legenddoubleclick', evtData);
394+
if(dblClickVal !== false) handleClick(legendItem, gd, numClicks);
395+
}
396+
}
397+
366398
function drawTexts(g, gd, maxLength) {
367399
var legendItem = g.data()[0][0];
368400
var fullLayout = gd._fullLayout;
@@ -460,15 +492,7 @@ function setupTraceToggle(g, gd) {
460492
numClicks = Math.max(numClicks - 1, 1);
461493
}
462494

463-
if(numClicks === 1) {
464-
legend._clickTimeout = setTimeout(function() { handleClick(g, gd, numClicks); }, DBLCLICKDELAY);
465-
} else if(numClicks === 2) {
466-
if(legend._clickTimeout) {
467-
clearTimeout(legend._clickTimeout);
468-
}
469-
gd._legendMouseDownTime = 0;
470-
handleClick(g, gd, numClicks);
471-
}
495+
clickOrDoubleClick(gd, legend, g, numClicks, d3.event);
472496
});
473497
}
474498

Diff for: ‎src/lib/events.js

+34-26
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ var Events = {
5858
plotObj.removeAllListeners = ev.removeAllListeners.bind(ev);
5959

6060
/*
61-
* Create funtions for managing internal events. These are *only* triggered
61+
* Create functions for managing internal events. These are *only* triggered
6262
* by the mirroring of external events via the emit function.
6363
*/
6464
plotObj._internalOn = internalEv.on.bind(internalEv);
@@ -85,20 +85,17 @@ var Events = {
8585
},
8686

8787
/*
88-
* This function behaves like jQueries triggerHandler. It calls
88+
* This function behaves like jQuery's triggerHandler. It calls
8989
* all handlers for a particular event and returns the return value
9090
* of the LAST handler. This function also triggers jQuery's
9191
* triggerHandler for backwards compatibility.
92-
*
93-
* Note: triggerHandler has been recommended for deprecation in v2.0.0,
94-
* so the additional behavior of triggerHandler triggering internal events
95-
* is deliberate excluded in order to avoid reinforcing more usage.
9692
*/
9793
triggerHandler: function(plotObj, event, data) {
9894
var jQueryHandlerValue;
9995
var nodeEventHandlerValue;
96+
10097
/*
101-
* If Jquery exists run all its handlers for this event and
98+
* If jQuery exists run all its handlers for this event and
10299
* collect the return value of the LAST handler function
103100
*/
104101
if(typeof jQuery !== 'undefined') {
@@ -114,30 +111,41 @@ var Events = {
114111
var handlers = ev._events[event];
115112
if(!handlers) return jQueryHandlerValue;
116113

117-
/*
118-
* handlers can be function or an array of functions
119-
*/
120-
if(typeof handlers === 'function') handlers = [handlers];
121-
var lastHandler = handlers.pop();
122-
123-
/*
124-
* Call all the handlers except the last one.
125-
*/
126-
for(var i = 0; i < handlers.length; i++) {
127-
handlers[i](data);
114+
// making sure 'this' is the EventEmitter instance
115+
function apply(handler) {
116+
// The 'once' case, we can't just call handler() as we need
117+
// the return value here. So,
118+
// - remove handler
119+
// - call listener and grab return value!
120+
// - stash 'fired' key to not call handler twice
121+
if(handler.listener) {
122+
ev.removeListener(event, handler.listener);
123+
if(!handler.fired) {
124+
handler.fired = true;
125+
return handler.listener.apply(ev, [data]);
126+
}
127+
} else {
128+
return handler.apply(ev, [data]);
129+
}
128130
}
129131

130-
/*
131-
* Now call the final handler and collect its value
132-
*/
133-
nodeEventHandlerValue = lastHandler(data);
132+
// handlers can be function or an array of functions
133+
handlers = Array.isArray(handlers) ? handlers : [handlers];
134+
135+
var i;
136+
for(i = 0; i < handlers.length - 1; i++) {
137+
apply(handlers[i]);
138+
}
139+
// now call the final handler and collect its value
140+
nodeEventHandlerValue = apply(handlers[i]);
134141

135142
/*
136-
* Return either the jquery handler value if it exists or the
137-
* nodeEventHandler value. Jquery event value superceeds nodejs
138-
* events for backwards compatability reasons.
143+
* Return either the jQuery handler value if it exists or the
144+
* nodeEventHandler value. jQuery event value supersedes nodejs
145+
* events for backwards compatibility reasons.
139146
*/
140-
return jQueryHandlerValue !== undefined ? jQueryHandlerValue :
147+
return jQueryHandlerValue !== undefined ?
148+
jQueryHandlerValue :
141149
nodeEventHandlerValue;
142150
},
143151

Diff for: ‎test/jasmine/tests/events_test.js

+19
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,25 @@ describe('Events', function() {
220220
expect(eventBaton).toBe(3);
221221
expect(result).toBe('pong');
222222
});
223+
224+
it('works with *once* event handlers', function() {
225+
var eventBaton = 0;
226+
227+
Events.init(plotDiv);
228+
229+
plotDiv.once('ping', function() {
230+
eventBaton++;
231+
return 'pong';
232+
});
233+
234+
var result = Events.triggerHandler(plotDiv, 'ping');
235+
expect(result).toBe('pong');
236+
expect(eventBaton).toBe(1);
237+
238+
var nop = Events.triggerHandler(plotDiv, 'ping');
239+
expect(nop).toBeUndefined();
240+
expect(eventBaton).toBe(1);
241+
});
223242
});
224243

225244
describe('purge', function() {

Diff for: ‎test/jasmine/tests/legend_test.js

+254-19
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,11 @@ var helpers = require('@src/components/legend/helpers');
99
var anchorUtils = require('@src/components/legend/anchor_utils');
1010

1111
var d3 = require('d3');
12-
var fail = require('../assets/fail_test');
12+
var failTest = require('../assets/fail_test');
1313
var delay = require('../assets/delay');
1414
var createGraphDiv = require('../assets/create_graph_div');
1515
var destroyGraphDiv = require('../assets/destroy_graph_div');
1616

17-
1817
describe('legend defaults', function() {
1918
'use strict';
2019

@@ -538,7 +537,7 @@ describe('legend relayout update', function() {
538537
.then(function() {
539538
expect(d3.selectAll('g.legend').size()).toBe(1);
540539
})
541-
.catch(fail)
540+
.catch(failTest)
542541
.then(done);
543542
});
544543

@@ -575,7 +574,7 @@ describe('legend relayout update', function() {
575574
}).then(function() {
576575
assertLegendStyle('rgb(0, 0, 255)', 'rgb(255, 0, 0)', 10);
577576
})
578-
.catch(fail)
577+
.catch(failTest)
579578
.then(done);
580579
});
581580
});
@@ -907,7 +906,7 @@ describe('legend interaction', function() {
907906
.then(function() {
908907
assertVisible(gd, [true, true, true, true]);
909908
})
910-
.catch(fail)
909+
.catch(failTest)
911910
.then(done);
912911
});
913912
});
@@ -1009,7 +1008,7 @@ describe('legend interaction', function() {
10091008
{target: 2, value: {name: 'hoo'}}
10101009
]);
10111010
assertLabels(['boo~~~', '1 (trace 1)', 'hoo ', ' ', '4 (trace 1)']);
1012-
}).catch(fail).then(done);
1011+
}).catch(failTest).then(done);
10131012
});
10141013
});
10151014

@@ -1035,9 +1034,13 @@ describe('legend interaction', function() {
10351034
};
10361035
}
10371036

1037+
function extractVisibilities(data) {
1038+
return data.map(function(trace) { return trace.visible; });
1039+
}
1040+
10381041
function assertVisible(expectation) {
10391042
return function() {
1040-
var actual = gd._fullData.map(function(trace) { return trace.visible; });
1043+
var actual = extractVisibilities(gd._fullData);
10411044
expect(actual).toEqual(expectation);
10421045
};
10431046
}
@@ -1056,15 +1059,15 @@ describe('legend interaction', function() {
10561059
.then(assertVisible([false, 'legendonly', true]))
10571060
.then(click(0))
10581061
.then(assertVisible([false, true, true]))
1059-
.catch(fail).then(done);
1062+
.catch(failTest).then(done);
10601063
});
10611064

10621065
it('clicking once toggles true -> legendonly', function(done) {
10631066
Promise.resolve()
10641067
.then(assertVisible([false, 'legendonly', true]))
10651068
.then(click(1))
10661069
.then(assertVisible([false, 'legendonly', 'legendonly']))
1067-
.catch(fail).then(done);
1070+
.catch(failTest).then(done);
10681071
});
10691072

10701073
it('double-clicking isolates a visible trace ', function(done) {
@@ -1073,14 +1076,14 @@ describe('legend interaction', function() {
10731076
.then(assertVisible([false, true, true]))
10741077
.then(click(0, 2))
10751078
.then(assertVisible([false, true, 'legendonly']))
1076-
.catch(fail).then(done);
1079+
.catch(failTest).then(done);
10771080
});
10781081

10791082
it('double-clicking an isolated trace shows all non-hidden traces', function(done) {
10801083
Promise.resolve()
10811084
.then(click(0, 2))
10821085
.then(assertVisible([false, true, true]))
1083-
.catch(fail).then(done);
1086+
.catch(failTest).then(done);
10841087
});
10851088
});
10861089

@@ -1110,7 +1113,7 @@ describe('legend interaction', function() {
11101113
.then(assertVisible([false, 'legendonly', true, 'legendonly']))
11111114
.then(click(1))
11121115
.then(assertVisible([false, true, true, true]))
1113-
.catch(fail).then(done);
1116+
.catch(failTest).then(done);
11141117
});
11151118

11161119
it('isolates legendgroups as a whole', function(done) {
@@ -1119,7 +1122,7 @@ describe('legend interaction', function() {
11191122
.then(assertVisible([false, true, 'legendonly', true]))
11201123
.then(click(1, 2))
11211124
.then(assertVisible([false, true, true, true]))
1122-
.catch(fail).then(done);
1125+
.catch(failTest).then(done);
11231126
});
11241127
});
11251128

@@ -1152,7 +1155,7 @@ describe('legend interaction', function() {
11521155
it('computes the initial visibility correctly', function(done) {
11531156
Promise.resolve()
11541157
.then(assertVisible([false, true, true, true, true, true, true, true]))
1155-
.catch(fail).then(done);
1158+
.catch(failTest).then(done);
11561159
});
11571160

11581161
it('toggles the visibility of a non-groupby trace in the presence of groupby traces', function(done) {
@@ -1161,7 +1164,7 @@ describe('legend interaction', function() {
11611164
.then(assertVisible([false, true, 'legendonly', true, true, true, true, true]))
11621165
.then(click(1))
11631166
.then(assertVisible([false, true, true, true, true, true, true, true]))
1164-
.catch(fail).then(done);
1167+
.catch(failTest).then(done);
11651168
});
11661169

11671170
it('toggles the visibility of the first group in a groupby trace', function(done) {
@@ -1170,7 +1173,7 @@ describe('legend interaction', function() {
11701173
.then(assertVisible([false, 'legendonly', true, true, true, true, true, true]))
11711174
.then(click(0))
11721175
.then(assertVisible([false, true, true, true, true, true, true, true]))
1173-
.catch(fail).then(done);
1176+
.catch(failTest).then(done);
11741177
});
11751178

11761179
it('toggles the visibility of the third group in a groupby trace', function(done) {
@@ -1179,7 +1182,7 @@ describe('legend interaction', function() {
11791182
.then(assertVisible([false, true, true, true, 'legendonly', true, true, true]))
11801183
.then(click(3))
11811184
.then(assertVisible([false, true, true, true, true, true, true, true]))
1182-
.catch(fail).then(done);
1185+
.catch(failTest).then(done);
11831186
});
11841187

11851188
it('double-clicking isolates a non-groupby trace', function(done) {
@@ -1188,7 +1191,7 @@ describe('legend interaction', function() {
11881191
.then(assertVisible([false, true, 'legendonly', 'legendonly', 'legendonly', 'legendonly', 'legendonly', 'legendonly']))
11891192
.then(click(0, 2))
11901193
.then(assertVisible([false, true, true, true, true, true, true, true]))
1191-
.catch(fail).then(done);
1194+
.catch(failTest).then(done);
11921195
});
11931196

11941197
it('double-clicking isolates a groupby trace', function(done) {
@@ -1197,7 +1200,239 @@ describe('legend interaction', function() {
11971200
.then(assertVisible([false, 'legendonly', true, 'legendonly', 'legendonly', 'legendonly', 'legendonly', 'legendonly']))
11981201
.then(click(1, 2))
11991202
.then(assertVisible([false, true, true, true, true, true, true, true]))
1200-
.catch(fail).then(done);
1203+
.catch(failTest).then(done);
1204+
});
1205+
});
1206+
1207+
describe('custom legend click/doubleclick handlers', function() {
1208+
var fig, to;
1209+
1210+
beforeEach(function() {
1211+
fig = Lib.extendDeep({}, require('@mocks/0.json'));
1212+
});
1213+
1214+
afterEach(function() {
1215+
clearTimeout(to);
1216+
});
1217+
1218+
function setupFail() {
1219+
to = setTimeout(function() {
1220+
fail('did not trigger plotly_legendclick');
1221+
}, 2 * DBLCLICKDELAY);
1222+
}
1223+
1224+
it('should call custom click handler before default handler', function(done) {
1225+
Plotly.newPlot(gd, fig).then(function() {
1226+
var gotCalled = false;
1227+
1228+
gd.on('plotly_legendclick', function(d) {
1229+
gotCalled = true;
1230+
expect(extractVisibilities(d.fullData)).toEqual([true, true, true]);
1231+
expect(extractVisibilities(gd._fullData)).toEqual([true, true, true]);
1232+
});
1233+
gd.on('plotly_restyle', function() {
1234+
expect(extractVisibilities(gd._fullData)).toEqual([true, 'legendonly', true]);
1235+
if(gotCalled) done();
1236+
});
1237+
setupFail();
1238+
})
1239+
.then(click(1, 1))
1240+
.catch(failTest);
1241+
});
1242+
1243+
it('should call custom doubleclick handler before default handler', function(done) {
1244+
Plotly.newPlot(gd, fig).then(function() {
1245+
var gotCalled = false;
1246+
1247+
gd.on('plotly_legenddoubleclick', function(d) {
1248+
gotCalled = true;
1249+
expect(extractVisibilities(d.fullData)).toEqual([true, true, true]);
1250+
expect(extractVisibilities(gd._fullData)).toEqual([true, true, true]);
1251+
});
1252+
gd.on('plotly_restyle', function() {
1253+
expect(extractVisibilities(gd._fullData)).toEqual(['legendonly', true, 'legendonly']);
1254+
if(gotCalled) done();
1255+
});
1256+
setupFail();
1257+
})
1258+
.then(click(1, 2))
1259+
.catch(failTest);
1260+
});
1261+
1262+
it('should not call default click handler if custom handler return *false*', function(done) {
1263+
Plotly.newPlot(gd, fig).then(function() {
1264+
gd.on('plotly_legendclick', function(d) {
1265+
Plotly.relayout(gd, 'title', 'just clicked on trace #' + d.curveNumber);
1266+
return false;
1267+
});
1268+
gd.on('plotly_relayout', function(d) {
1269+
expect(typeof d).toBe('object');
1270+
expect(d.title).toBe('just clicked on trace #2');
1271+
done();
1272+
});
1273+
gd.on('plotly_restyle', function() {
1274+
fail('should not have triggered plotly_restyle');
1275+
});
1276+
setupFail();
1277+
})
1278+
.then(click(2, 1))
1279+
.catch(failTest);
1280+
});
1281+
1282+
it('should not call default doubleclick handle if custom handler return *false*', function(done) {
1283+
Plotly.newPlot(gd, fig).then(function() {
1284+
gd.on('plotly_legenddoubleclick', function(d) {
1285+
Plotly.relayout(gd, 'title', 'just double clicked on trace #' + d.curveNumber);
1286+
return false;
1287+
});
1288+
gd.on('plotly_relayout', function(d) {
1289+
expect(typeof d).toBe('object');
1290+
expect(d.title).toBe('just double clicked on trace #0');
1291+
done();
1292+
});
1293+
gd.on('plotly_restyle', function() {
1294+
fail('should not have triggered plotly_restyle');
1295+
});
1296+
setupFail();
1297+
})
1298+
.then(click(0, 2))
1299+
.catch(failTest);
1300+
});
1301+
});
1302+
1303+
describe('legend click/doubleclick event data', function() {
1304+
function _assert(act, exp) {
1305+
for(var k in exp) {
1306+
if(k === 'event' || k === 'node') {
1307+
expect(act[k]).toBeDefined();
1308+
} else if(k === 'group') {
1309+
expect(act[k]).toEqual(exp[k]);
1310+
} else {
1311+
expect(act[k]).toBe(exp[k], 'key ' + k);
1312+
}
1313+
}
1314+
1315+
expect(Object.keys(act).length)
1316+
.toBe(Object.keys(exp).length, '# of keys');
1317+
}
1318+
1319+
function clickAndCheck(clickArg, exp) {
1320+
Lib.extendFlat(exp, {
1321+
event: true,
1322+
node: true,
1323+
data: gd.data,
1324+
layout: gd.layout,
1325+
frames: gd._transitionData._frames,
1326+
config: gd._context,
1327+
fullData: gd._fullData,
1328+
fullLayout: gd._fullLayout
1329+
});
1330+
1331+
var evtName = {
1332+
1: 'plotly_legendclick',
1333+
2: 'plotly_legenddoubleclick'
1334+
}[clickArg[1]];
1335+
1336+
return new Promise(function(resolve, reject) {
1337+
var hasBeenCalled = false;
1338+
1339+
var to = setTimeout(function() {
1340+
reject('did not trigger ' + evtName);
1341+
}, 2 * DBLCLICKDELAY);
1342+
1343+
function done() {
1344+
if(hasBeenCalled) {
1345+
clearTimeout(to);
1346+
resolve();
1347+
}
1348+
}
1349+
1350+
gd.once(evtName, function(d) {
1351+
hasBeenCalled = true;
1352+
_assert(d, exp);
1353+
});
1354+
1355+
gd.once('plotly_restyle', done);
1356+
gd.once('plotly_relayout', done);
1357+
1358+
click(clickArg[0], clickArg[1])();
1359+
});
1360+
}
1361+
1362+
it('should have correct keys (base case)', function(done) {
1363+
Plotly.newPlot(gd, [{
1364+
x: [1, 2, 3, 4, 5],
1365+
y: [1, 2, 1, 2, 3]
1366+
}], {
1367+
showlegend: true
1368+
})
1369+
.then(function() {
1370+
return clickAndCheck([0, 1], {
1371+
curveNumber: 0,
1372+
expandedIndex: 0
1373+
});
1374+
})
1375+
.then(function() {
1376+
return clickAndCheck([0, 2], {
1377+
curveNumber: 0,
1378+
expandedIndex: 0
1379+
});
1380+
})
1381+
.catch(failTest)
1382+
.then(done);
1383+
});
1384+
1385+
it('should have correct keys (groupby case)', function(done) {
1386+
Plotly.newPlot(gd, [{
1387+
x: [1, 2, 3, 4, 5],
1388+
y: [1, 2, 1, 2, 3],
1389+
transforms: [{
1390+
type: 'groupby',
1391+
groups: ['a', 'b', 'b', 'a', 'b']
1392+
}]
1393+
}, {
1394+
x: [1, 2, 3, 4, 5],
1395+
y: [1, 2, 1, 2, 3],
1396+
}])
1397+
.then(function() {
1398+
return clickAndCheck([1, 1], {
1399+
curveNumber: 0,
1400+
expandedIndex: 1,
1401+
group: 'b'
1402+
});
1403+
})
1404+
.then(function() {
1405+
return clickAndCheck([2, 2], {
1406+
curveNumber: 1,
1407+
expandedIndex: 2
1408+
});
1409+
})
1410+
.catch(failTest)
1411+
.then(done);
1412+
});
1413+
1414+
it('should have correct keys (pie case)', function(done) {
1415+
Plotly.newPlot(gd, [{
1416+
type: 'pie',
1417+
labels: ['A', 'B', 'C', 'D'],
1418+
values: [1, 2, 1, 3]
1419+
}])
1420+
.then(function() {
1421+
return clickAndCheck([0, 1], {
1422+
curveNumber: 0,
1423+
expandedIndex: 0,
1424+
label: 'D'
1425+
});
1426+
})
1427+
.then(function() {
1428+
return clickAndCheck([2, 2], {
1429+
curveNumber: 0,
1430+
expandedIndex: 0,
1431+
label: 'A'
1432+
});
1433+
})
1434+
.catch(failTest)
1435+
.then(done);
12011436
});
12021437
});
12031438
});

0 commit comments

Comments
 (0)
Please sign in to comment.