Skip to content

Legend events #2581

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
May 1, 2018
62 changes: 43 additions & 19 deletions src/components/legend/draw.js
Original file line number Diff line number Diff line change
@@ -13,6 +13,7 @@ var d3 = require('d3');
var Lib = require('../../lib');
var Plots = require('../../plots/plots');
var Registry = require('../../registry');
var Events = require('../../lib/events');
var dragElement = require('../dragelement');
var Drawing = require('../drawing');
var Color = require('../color');
@@ -347,22 +348,53 @@ module.exports = function draw(gd) {
e.clientY >= bbox.top && e.clientY <= bbox.bottom);
});
if(clickedTrace.size() > 0) {
if(numClicks === 1) {
legend._clickTimeout = setTimeout(function() {
handleClick(clickedTrace, gd, numClicks);
}, DBLCLICKDELAY);
} else if(numClicks === 2) {
if(legend._clickTimeout) {
clearTimeout(legend._clickTimeout);
}
handleClick(clickedTrace, gd, numClicks);
}
clickOrDoubleClick(gd, legend, clickedTrace, numClicks, e);
}
}
});
}
};

function clickOrDoubleClick(gd, legend, legendItem, numClicks, evt) {
var trace = legendItem.data()[0][0].trace;

var evtData = {
event: evt,
node: legendItem.node(),
curveNumber: trace.index,
expandedIndex: trace._expandedIndex,
data: gd.data,
layout: gd.layout,
frames: gd._transitionData._frames,
config: gd._context,
fullData: gd._fullData,
fullLayout: gd._fullLayout
};

if(trace._group) {
evtData.group = trace._group;
}
if(trace.type === 'pie') {
evtData.label = legendItem.datum()[0].label;
}

var clickVal = Events.triggerHandler(gd, 'plotly_legendclick', evtData);
if(clickVal === false) return;

if(numClicks === 1) {
legend._clickTimeout = setTimeout(function() {
handleClick(legendItem, gd, numClicks);
}, DBLCLICKDELAY);
}
else if(numClicks === 2) {
if(legend._clickTimeout) clearTimeout(legend._clickTimeout);
gd._legendMouseDownTime = 0;

var dblClickVal = Events.triggerHandler(gd, 'plotly_legenddoubleclick', evtData);
if(dblClickVal !== false) handleClick(legendItem, gd, numClicks);
}
}

function drawTexts(g, gd, maxLength) {
var legendItem = g.data()[0][0];
var fullLayout = gd._fullLayout;
@@ -460,15 +492,7 @@ function setupTraceToggle(g, gd) {
numClicks = Math.max(numClicks - 1, 1);
}

if(numClicks === 1) {
legend._clickTimeout = setTimeout(function() { handleClick(g, gd, numClicks); }, DBLCLICKDELAY);
} else if(numClicks === 2) {
if(legend._clickTimeout) {
clearTimeout(legend._clickTimeout);
}
gd._legendMouseDownTime = 0;
handleClick(g, gd, numClicks);
}
clickOrDoubleClick(gd, legend, g, numClicks, d3.event);
});
}

60 changes: 34 additions & 26 deletions src/lib/events.js
Original file line number Diff line number Diff line change
@@ -58,7 +58,7 @@ var Events = {
plotObj.removeAllListeners = ev.removeAllListeners.bind(ev);

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

/*
* This function behaves like jQueries triggerHandler. It calls
* This function behaves like jQuery's triggerHandler. It calls
* all handlers for a particular event and returns the return value
* of the LAST handler. This function also triggers jQuery's
* triggerHandler for backwards compatibility.
*
* Note: triggerHandler has been recommended for deprecation in v2.0.0,
* so the additional behavior of triggerHandler triggering internal events
* is deliberate excluded in order to avoid reinforcing more usage.
*/
triggerHandler: function(plotObj, event, data) {
var jQueryHandlerValue;
var nodeEventHandlerValue;

/*
* If Jquery exists run all its handlers for this event and
* If jQuery exists run all its handlers for this event and
* collect the return value of the LAST handler function
*/
if(typeof jQuery !== 'undefined') {
@@ -114,30 +111,41 @@ var Events = {
var handlers = ev._events[event];
if(!handlers) return jQueryHandlerValue;

/*
* handlers can be function or an array of functions
*/
if(typeof handlers === 'function') handlers = [handlers];
var lastHandler = handlers.pop();

/*
* Call all the handlers except the last one.
*/
for(var i = 0; i < handlers.length; i++) {
handlers[i](data);
// making sure 'this' is the EventEmitter instance
function apply(handler) {
// The 'once' case, we can't just call handler() as we need
// the return value here. So,
// - remove handler
// - call listener and grab return value!
// - stash 'fired' key to not call handler twice
if(handler.listener) {
ev.removeListener(event, handler.listener);
if(!handler.fired) {
handler.fired = true;
return handler.listener.apply(ev, [data]);
}
} else {
return handler.apply(ev, [data]);
}
}

/*
* Now call the final handler and collect its value
*/
nodeEventHandlerValue = lastHandler(data);
// handlers can be function or an array of functions
handlers = Array.isArray(handlers) ? handlers : [handlers];

var i;
for(i = 0; i < handlers.length - 1; i++) {
apply(handlers[i]);
}
// now call the final handler and collect its value
nodeEventHandlerValue = apply(handlers[i]);

/*
* Return either the jquery handler value if it exists or the
* nodeEventHandler value. Jquery event value superceeds nodejs
* events for backwards compatability reasons.
* Return either the jQuery handler value if it exists or the
* nodeEventHandler value. jQuery event value supersedes nodejs
* events for backwards compatibility reasons.
*/
return jQueryHandlerValue !== undefined ? jQueryHandlerValue :
return jQueryHandlerValue !== undefined ?
jQueryHandlerValue :
nodeEventHandlerValue;
},

19 changes: 19 additions & 0 deletions test/jasmine/tests/events_test.js
Original file line number Diff line number Diff line change
@@ -220,6 +220,25 @@ describe('Events', function() {
expect(eventBaton).toBe(3);
expect(result).toBe('pong');
});

it('works with *once* event handlers', function() {
var eventBaton = 0;

Events.init(plotDiv);

plotDiv.once('ping', function() {
eventBaton++;
return 'pong';
});

var result = Events.triggerHandler(plotDiv, 'ping');
expect(result).toBe('pong');
expect(eventBaton).toBe(1);

var nop = Events.triggerHandler(plotDiv, 'ping');
expect(nop).toBeUndefined();
expect(eventBaton).toBe(1);
});
});

describe('purge', function() {
273 changes: 254 additions & 19 deletions test/jasmine/tests/legend_test.js
Original file line number Diff line number Diff line change
@@ -9,12 +9,11 @@ var helpers = require('@src/components/legend/helpers');
var anchorUtils = require('@src/components/legend/anchor_utils');

var d3 = require('d3');
var fail = require('../assets/fail_test');
var failTest = require('../assets/fail_test');
var delay = require('../assets/delay');
var createGraphDiv = require('../assets/create_graph_div');
var destroyGraphDiv = require('../assets/destroy_graph_div');


describe('legend defaults', function() {
'use strict';

@@ -538,7 +537,7 @@ describe('legend relayout update', function() {
.then(function() {
expect(d3.selectAll('g.legend').size()).toBe(1);
})
.catch(fail)
.catch(failTest)
.then(done);
});

@@ -575,7 +574,7 @@ describe('legend relayout update', function() {
}).then(function() {
assertLegendStyle('rgb(0, 0, 255)', 'rgb(255, 0, 0)', 10);
})
.catch(fail)
.catch(failTest)
.then(done);
});
});
@@ -907,7 +906,7 @@ describe('legend interaction', function() {
.then(function() {
assertVisible(gd, [true, true, true, true]);
})
.catch(fail)
.catch(failTest)
.then(done);
});
});
@@ -1009,7 +1008,7 @@ describe('legend interaction', function() {
{target: 2, value: {name: 'hoo'}}
]);
assertLabels(['boo~~~', '1 (trace 1)', 'hoo ', ' ', '4 (trace 1)']);
}).catch(fail).then(done);
}).catch(failTest).then(done);
});
});

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

function extractVisibilities(data) {
return data.map(function(trace) { return trace.visible; });
}

function assertVisible(expectation) {
return function() {
var actual = gd._fullData.map(function(trace) { return trace.visible; });
var actual = extractVisibilities(gd._fullData);
expect(actual).toEqual(expectation);
};
}
@@ -1056,15 +1059,15 @@ describe('legend interaction', function() {
.then(assertVisible([false, 'legendonly', true]))
.then(click(0))
.then(assertVisible([false, true, true]))
.catch(fail).then(done);
.catch(failTest).then(done);
});

it('clicking once toggles true -> legendonly', function(done) {
Promise.resolve()
.then(assertVisible([false, 'legendonly', true]))
.then(click(1))
.then(assertVisible([false, 'legendonly', 'legendonly']))
.catch(fail).then(done);
.catch(failTest).then(done);
});

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

it('double-clicking an isolated trace shows all non-hidden traces', function(done) {
Promise.resolve()
.then(click(0, 2))
.then(assertVisible([false, true, true]))
.catch(fail).then(done);
.catch(failTest).then(done);
});
});

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

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

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

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() {
.then(assertVisible([false, true, 'legendonly', true, true, true, true, true]))
.then(click(1))
.then(assertVisible([false, true, true, true, true, true, true, true]))
.catch(fail).then(done);
.catch(failTest).then(done);
});

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

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

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

it('double-clicking isolates a groupby trace', function(done) {
@@ -1197,7 +1200,239 @@ describe('legend interaction', function() {
.then(assertVisible([false, 'legendonly', true, 'legendonly', 'legendonly', 'legendonly', 'legendonly', 'legendonly']))
.then(click(1, 2))
.then(assertVisible([false, true, true, true, true, true, true, true]))
.catch(fail).then(done);
.catch(failTest).then(done);
});
});

describe('custom legend click/doubleclick handlers', function() {
var fig, to;

beforeEach(function() {
fig = Lib.extendDeep({}, require('@mocks/0.json'));
});

afterEach(function() {
clearTimeout(to);
});

function setupFail() {
to = setTimeout(function() {
fail('did not trigger plotly_legendclick');
}, 2 * DBLCLICKDELAY);
}

it('should call custom click handler before default handler', function(done) {
Plotly.newPlot(gd, fig).then(function() {
var gotCalled = false;

gd.on('plotly_legendclick', function(d) {
gotCalled = true;
expect(extractVisibilities(d.fullData)).toEqual([true, true, true]);
expect(extractVisibilities(gd._fullData)).toEqual([true, true, true]);
});
gd.on('plotly_restyle', function() {
expect(extractVisibilities(gd._fullData)).toEqual([true, 'legendonly', true]);
if(gotCalled) done();
});
setupFail();
})
.then(click(1, 1))
.catch(failTest);
});

it('should call custom doubleclick handler before default handler', function(done) {
Plotly.newPlot(gd, fig).then(function() {
var gotCalled = false;

gd.on('plotly_legenddoubleclick', function(d) {
gotCalled = true;
expect(extractVisibilities(d.fullData)).toEqual([true, true, true]);
expect(extractVisibilities(gd._fullData)).toEqual([true, true, true]);
});
gd.on('plotly_restyle', function() {
expect(extractVisibilities(gd._fullData)).toEqual(['legendonly', true, 'legendonly']);
if(gotCalled) done();
});
setupFail();
})
.then(click(1, 2))
.catch(failTest);
});

it('should not call default click handler if custom handler return *false*', function(done) {
Plotly.newPlot(gd, fig).then(function() {
gd.on('plotly_legendclick', function(d) {
Plotly.relayout(gd, 'title', 'just clicked on trace #' + d.curveNumber);
return false;
});
gd.on('plotly_relayout', function(d) {
expect(typeof d).toBe('object');
expect(d.title).toBe('just clicked on trace #2');
done();
});
gd.on('plotly_restyle', function() {
fail('should not have triggered plotly_restyle');
});
setupFail();
})
.then(click(2, 1))
.catch(failTest);
});

it('should not call default doubleclick handle if custom handler return *false*', function(done) {
Plotly.newPlot(gd, fig).then(function() {
gd.on('plotly_legenddoubleclick', function(d) {
Plotly.relayout(gd, 'title', 'just double clicked on trace #' + d.curveNumber);
return false;
});
gd.on('plotly_relayout', function(d) {
expect(typeof d).toBe('object');
expect(d.title).toBe('just double clicked on trace #0');
done();
});
gd.on('plotly_restyle', function() {
fail('should not have triggered plotly_restyle');
});
setupFail();
})
.then(click(0, 2))
.catch(failTest);
});
});

describe('legend click/doubleclick event data', function() {
function _assert(act, exp) {
for(var k in exp) {
if(k === 'event' || k === 'node') {
expect(act[k]).toBeDefined();
} else if(k === 'group') {
expect(act[k]).toEqual(exp[k]);
} else {
expect(act[k]).toBe(exp[k], 'key ' + k);
}
}

expect(Object.keys(act).length)
.toBe(Object.keys(exp).length, '# of keys');
}

function clickAndCheck(clickArg, exp) {
Lib.extendFlat(exp, {
event: true,
node: true,
data: gd.data,
layout: gd.layout,
frames: gd._transitionData._frames,
config: gd._context,
fullData: gd._fullData,
fullLayout: gd._fullLayout
});

var evtName = {
1: 'plotly_legendclick',
2: 'plotly_legenddoubleclick'
}[clickArg[1]];

return new Promise(function(resolve, reject) {
var hasBeenCalled = false;

var to = setTimeout(function() {
reject('did not trigger ' + evtName);
}, 2 * DBLCLICKDELAY);

function done() {
if(hasBeenCalled) {
clearTimeout(to);
resolve();
}
}

gd.once(evtName, function(d) {
hasBeenCalled = true;
_assert(d, exp);
});

gd.once('plotly_restyle', done);
gd.once('plotly_relayout', done);

click(clickArg[0], clickArg[1])();
});
}

it('should have correct keys (base case)', function(done) {
Plotly.newPlot(gd, [{
x: [1, 2, 3, 4, 5],
y: [1, 2, 1, 2, 3]
}], {
showlegend: true
})
.then(function() {
return clickAndCheck([0, 1], {
curveNumber: 0,
expandedIndex: 0
});
})
.then(function() {
return clickAndCheck([0, 2], {
curveNumber: 0,
expandedIndex: 0
});
})
.catch(failTest)
.then(done);
});

it('should have correct keys (groupby case)', function(done) {
Plotly.newPlot(gd, [{
x: [1, 2, 3, 4, 5],
y: [1, 2, 1, 2, 3],
transforms: [{
type: 'groupby',
groups: ['a', 'b', 'b', 'a', 'b']
}]
}, {
x: [1, 2, 3, 4, 5],
y: [1, 2, 1, 2, 3],
}])
.then(function() {
return clickAndCheck([1, 1], {
curveNumber: 0,
expandedIndex: 1,
group: 'b'
});
})
.then(function() {
return clickAndCheck([2, 2], {
curveNumber: 1,
expandedIndex: 2
});
})
.catch(failTest)
.then(done);
});

it('should have correct keys (pie case)', function(done) {
Plotly.newPlot(gd, [{
type: 'pie',
labels: ['A', 'B', 'C', 'D'],
values: [1, 2, 1, 3]
}])
.then(function() {
return clickAndCheck([0, 1], {
curveNumber: 0,
expandedIndex: 0,
label: 'D'
});
})
.then(function() {
return clickAndCheck([2, 2], {
curveNumber: 0,
expandedIndex: 0,
label: 'A'
});
})
.catch(failTest)
.then(done);
});
});
});