diff --git a/draftlogs/7734_add.md b/draftlogs/7734_add.md new file mode 100644 index 00000000000..e2b3c6368c0 --- /dev/null +++ b/draftlogs/7734_add.md @@ -0,0 +1 @@ + - Add `hoversort` layout attribute to sort unified hover label items by value [[#7734](https://github.com/plotly/plotly.js/pull/7734)] diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index 15cc6fa9cee..20715d2dda4 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -1341,6 +1341,15 @@ function createHoverText(hoverData, opts) { mockLegend.entries.push([pt]); } mockLegend.entries.sort(function (a, b) { + var hoversort = fullLayout.hoversort; + if (hoversort === 'value descending' || hoversort === 'value ascending') { + var valueLetter = hovermode.charAt(0) === 'x' ? 'y' : 'x'; + var aVal = a[0][valueLetter + 'LabelVal']; + var bVal = b[0][valueLetter + 'LabelVal']; + if (aVal !== bVal) { + return hoversort === 'value descending' ? bVal - aVal : aVal - bVal; + } + } return a[0].trace.index - b[0].trace.index; }); mockLegend.layer = container; diff --git a/src/components/fx/layout_attributes.js b/src/components/fx/layout_attributes.js index 16e3057fa16..73c76275dc1 100644 --- a/src/components/fx/layout_attributes.js +++ b/src/components/fx/layout_attributes.js @@ -78,6 +78,19 @@ module.exports = { 'If false, hover interactions are disabled.' ].join(' ') }, + hoversort: { + valType: 'enumerated', + values: ['trace', 'value descending', 'value ascending'], + dflt: 'trace', + editType: 'none', + description: [ + 'Determines the order of items shown in unified hover labels.', + 'If *trace*, items are sorted by trace index.', + 'If *value descending*, items are sorted by value from largest to smallest.', + 'If *value ascending*, items are sorted by value from smallest to largest.', + 'Only applies when `hovermode` is *x unified* or *y unified*.' + ].join(' ') + }, hoversubplots: { valType: 'enumerated', values: ['single', 'overlaying', 'axis'], diff --git a/src/components/fx/layout_defaults.js b/src/components/fx/layout_defaults.js index 19504efe95a..ce057208fc0 100644 --- a/src/components/fx/layout_defaults.js +++ b/src/components/fx/layout_defaults.js @@ -14,6 +14,9 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut) { if(hoverMode) { coerce('hoverdistance'); coerce('spikedistance'); + if(hoverMode.indexOf('unified') !== -1) { + coerce('hoversort'); + } } var dragMode = coerce('dragmode'); diff --git a/test/jasmine/tests/fx_test.js b/test/jasmine/tests/fx_test.js index d123999e265..04877c801e0 100644 --- a/test/jasmine/tests/fx_test.js +++ b/test/jasmine/tests/fx_test.js @@ -234,6 +234,37 @@ describe('Fx defaults', function() { } }); }); + + it('should coerce hoversort only for unified hovermode', function() { + var out = _supply([{type: 'bar', y: [1, 2, 3]}], { + hovermode: 'x unified', + hoversort: 'value descending' + }); + expect(out.layout.hoversort).toBe('value descending'); + }); + + it('should not coerce hoversort for non-unified hovermode', function() { + var out = _supply([{type: 'bar', y: [1, 2, 3]}], { + hovermode: 'closest', + hoversort: 'value descending' + }); + expect(out.layout.hoversort).toBeUndefined(); + }); + + it('should default hoversort to trace', function() { + var out = _supply([{type: 'bar', y: [1, 2, 3]}], { + hovermode: 'x unified' + }); + expect(out.layout.hoversort).toBe('trace'); + }); + + it('should coerce hoversort for y unified hovermode', function() { + var out = _supply([{type: 'bar', y: [1, 2, 3]}], { + hovermode: 'y unified', + hoversort: 'value ascending' + }); + expect(out.layout.hoversort).toBe('value ascending'); + }); }); describe('relayout', function() { diff --git a/test/jasmine/tests/hover_label_test.js b/test/jasmine/tests/hover_label_test.js index 0dbbe01019c..b252872decf 100644 --- a/test/jasmine/tests/hover_label_test.js +++ b/test/jasmine/tests/hover_label_test.js @@ -7315,3 +7315,228 @@ describe('hoverlabel.showarrow', function() { .then(done, done.fail); }); }); + +describe('hoversort', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + function _hover(gd, opts) { + Fx.hover(gd, opts); + Lib.clearThrottle(); + } + + function getHoverLabel() { + var hoverLayer = d3Select('g.hoverlayer'); + return hoverLayer.select('g.legend'); + } + + function assertLabel(expectation) { + var hover = getHoverLabel(); + var title = hover.select('text.legendtitletext'); + var traces = hover.selectAll('g.traces'); + + if(expectation.title) { + expect(title.text()).toBe(expectation.title); + } + + expect(traces.size()).toBe(expectation.items.length, 'has the incorrect number of items'); + traces.each(function(_, i) { + var e = d3Select(this); + expect(e.select('text').text()).toBe(expectation.items[i]); + }); + } + + it('should sort unified hover items by value descending', function(done) { + Plotly.newPlot(gd, { + data: [ + {name: 'small', y: [1, 2, 3]}, + {name: 'large', y: [10, 20, 30]}, + {name: 'medium', y: [5, 10, 15]} + ], + layout: { + hovermode: 'x unified', + hoversort: 'value descending', + showlegend: false, + width: 500, + height: 500 + } + }) + .then(function() { + _hover(gd, { xval: 2 }); + assertLabel({title: '2', items: [ + 'large : 30', + 'medium : 15', + 'small : 3' + ]}); + }) + .then(done, done.fail); + }); + + it('should sort unified hover items by value ascending', function(done) { + Plotly.newPlot(gd, { + data: [ + {name: 'small', y: [1, 2, 3]}, + {name: 'large', y: [10, 20, 30]}, + {name: 'medium', y: [5, 10, 15]} + ], + layout: { + hovermode: 'x unified', + hoversort: 'value ascending', + showlegend: false, + width: 500, + height: 500 + } + }) + .then(function() { + _hover(gd, { xval: 2 }); + assertLabel({title: '2', items: [ + 'small : 3', + 'medium : 15', + 'large : 30' + ]}); + }) + .then(done, done.fail); + }); + + it('should default to trace index order', function(done) { + Plotly.newPlot(gd, { + data: [ + {name: 'small', y: [1, 2, 3]}, + {name: 'large', y: [10, 20, 30]}, + {name: 'medium', y: [5, 10, 15]} + ], + layout: { + hovermode: 'x unified', + showlegend: false, + width: 500, + height: 500 + } + }) + .then(function() { + _hover(gd, { xval: 2 }); + assertLabel({title: '2', items: [ + 'small : 3', + 'large : 30', + 'medium : 15' + ]}); + }) + .then(done, done.fail); + }); + + it('should sort by value descending with bar charts', function(done) { + Plotly.newPlot(gd, { + data: [ + {name: 'A', type: 'bar', y: [5, 10, 15]}, + {name: 'B', type: 'bar', y: [20, 25, 30]}, + {name: 'C', type: 'bar', y: [1, 2, 3]} + ], + layout: { + hovermode: 'x unified', + hoversort: 'value descending', + showlegend: false, + width: 500, + height: 500 + } + }) + .then(function() { + _hover(gd, { xval: 1 }); + assertLabel({title: '1', items: [ + 'B : 25', + 'A : 10', + 'C : 2' + ]}); + }) + .then(done, done.fail); + }); + + it('should sort by value descending with y unified hovermode', function(done) { + Plotly.newPlot(gd, { + data: [ + {name: 'first', x: [1, 10, 5]}, + {name: 'second', x: [20, 2, 15]}, + {name: 'third', x: [8, 8, 8]} + ], + layout: { + hovermode: 'y unified', + hoversort: 'value descending', + showlegend: false, + width: 500, + height: 500 + } + }) + .then(function() { + _hover(gd, { yval: 0 }); + assertLabel({title: '0', items: [ + 'second : 20', + 'third : 8', + 'first : 1' + ]}); + }) + .then(done, done.fail); + }); + + it('should fall back to trace index when values are equal', function(done) { + Plotly.newPlot(gd, { + data: [ + {name: 'A', y: [5, 10, 10]}, + {name: 'B', y: [5, 20, 10]}, + {name: 'C', y: [5, 5, 10]} + ], + layout: { + hovermode: 'x unified', + hoversort: 'value descending', + showlegend: false, + width: 500, + height: 500 + } + }) + .then(function() { + // At xval=0, all values are 5 - should keep trace order + _hover(gd, { xval: 0 }); + assertLabel({title: '0', items: [ + 'A : 5', + 'B : 5', + 'C : 5' + ]}); + }) + .then(done, done.fail); + }); + + it('should dynamically update sort via relayout', function(done) { + Plotly.newPlot(gd, { + data: [ + {name: 'low', y: [1, 2, 3]}, + {name: 'high', y: [10, 20, 30]} + ], + layout: { + hovermode: 'x unified', + hoversort: 'trace', + showlegend: false, + width: 500, + height: 500 + } + }) + .then(function() { + _hover(gd, { xval: 1 }); + assertLabel({title: '1', items: [ + 'low : 2', + 'high : 20' + ]}); + + return Plotly.relayout(gd, 'hoversort', 'value descending'); + }) + .then(function() { + _hover(gd, { xval: 1 }); + assertLabel({title: '1', items: [ + 'high : 20', + 'low : 2' + ]}); + }) + .then(done, done.fail); + }); +}); diff --git a/test/plot-schema.json b/test/plot-schema.json index 2c2d38fe3b6..ed4c4cf368a 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -124,6 +124,11 @@ false ] }, + "displayNotifier": { + "description": "Determines whether or not notifier is displayed.", + "dflt": true, + "valType": "boolean" + }, "doubleClick": { "description": "Sets the double click interaction mode. Has an effect only in cartesian plots. If *false*, double click is disable. If *reset*, double click resets the axis ranges to their initial values. If *autosize*, double click set the axis ranges to their autorange values. If *reset+autosize*, the odd double clicks resets the axis ranges to their initial values and even double clicks set the axis ranges to their autorange values.", "dflt": "reset+autosize", @@ -3058,6 +3063,17 @@ "y unified" ] }, + "hoversort": { + "description": "Determines the order of items shown in unified hover labels. If *trace*, items are sorted by trace index. If *value descending*, items are sorted by value from largest to smallest. If *value ascending*, items are sorted by value from smallest to largest. Only applies when `hovermode` is *x unified* or *y unified*.", + "dflt": "trace", + "editType": "none", + "valType": "enumerated", + "values": [ + "trace", + "value descending", + "value ascending" + ] + }, "hoversubplots": { "description": "Determines expansion of hover effects to other subplots If *single* just the axis pair of the primary point is included without overlaying subplots. If *overlaying* all subplots using the main axis and occupying the same space are included. If *axis*, also include stacked subplots using the same axis when `hovermode` is set to *x*, *x unified*, *y* or *y unified*.", "dflt": "overlaying",