Skip to content

Commit b8b7efc

Browse files
authored
Merge pull request #4620 from plotly/pr-unified-hoverlabel
unified hover label
2 parents 49899c6 + af4a07c commit b8b7efc

File tree

12 files changed

+691
-127
lines changed

12 files changed

+691
-127
lines changed

src/components/fx/helpers.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ exports.p2c = function p2c(axArray, v) {
5454

5555
exports.getDistanceFunction = function getDistanceFunction(mode, dx, dy, dxy) {
5656
if(mode === 'closest') return dxy || exports.quadrature(dx, dy);
57-
return mode === 'x' ? dx : dy;
57+
return mode.charAt(0) === 'x' ? dx : dy;
5858
};
5959

6060
exports.getClosest = function getClosest(cd, distfn, pointData) {

src/components/fx/hover.js

+191-75
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ var Registry = require('../../registry');
2525
var helpers = require('./helpers');
2626
var constants = require('./constants');
2727

28+
var legendSupplyDefaults = require('../legend/defaults');
29+
var legendDraw = require('../legend/draw');
30+
2831
// hover labels for multiple horizontal bars get tilted by some angle,
2932
// then need to be offset differently if they overlap
3033
var YANGLE = constants.YANGLE;
@@ -244,7 +247,7 @@ function _hover(gd, evt, subplot, noHoverEvent) {
244247

245248
if(hovermode && !supportsCompare) hovermode = 'closest';
246249

247-
if(['x', 'y', 'closest'].indexOf(hovermode) === -1 || !gd.calcdata ||
250+
if(['x', 'y', 'closest', 'x unified', 'y unified'].indexOf(hovermode) === -1 || !gd.calcdata ||
248251
gd.querySelector('.zoombox') || gd._dragging) {
249252
return dragElement.unhoverRaw(gd, evt);
250253
}
@@ -388,6 +391,9 @@ function _hover(gd, evt, subplot, noHoverEvent) {
388391

389392
// within one trace mode can sometimes be overridden
390393
mode = hovermode;
394+
if(['x unified', 'y unified'].indexOf(mode) !== -1) {
395+
mode = mode.charAt(0);
396+
}
391397

392398
// container for new point, also used to pass info into module.hoverPoints
393399
pointData = {
@@ -661,9 +667,10 @@ function _hover(gd, evt, subplot, noHoverEvent) {
661667

662668
var hoverLabels = createHoverText(hoverData, labelOpts, gd);
663669

664-
hoverAvoidOverlaps(hoverLabels, rotateLabels ? 'xa' : 'ya', fullLayout);
665-
666-
alignHoverText(hoverLabels, rotateLabels);
670+
if(['x unified', 'y unified'].indexOf(hovermode) === -1) {
671+
hoverAvoidOverlaps(hoverLabels, rotateLabels ? 'xa' : 'ya', fullLayout);
672+
alignHoverText(hoverLabels, rotateLabels);
673+
}
667674

668675
// TODO: tagName hack is needed to appease geo.js's hack of using evt.target=true
669676
// we should improve the "fx" API so other plots can use it without these hack.
@@ -712,7 +719,7 @@ function createHoverText(hoverData, opts, gd) {
712719
var c0 = hoverData[0];
713720
var xa = c0.xa;
714721
var ya = c0.ya;
715-
var commonAttr = hovermode === 'y' ? 'yLabel' : 'xLabel';
722+
var commonAttr = hovermode.charAt(0) === 'y' ? 'yLabel' : 'xLabel';
716723
var t0 = c0[commonAttr];
717724
var t00 = (String(t0) || '').split(' ')[0];
718725
var outerContainerBB = outerContainer.node().getBoundingClientRect();
@@ -906,11 +913,113 @@ function createHoverText(hoverData, opts, gd) {
906913

907914
// remove the "close but not quite" points
908915
// because of error bars, only take up to a space
909-
hoverData = hoverData.filter(function(d) {
916+
hoverData = filterClosePoints(hoverData);
917+
});
918+
919+
function filterClosePoints(hoverData) {
920+
return hoverData.filter(function(d) {
910921
return (d.zLabelVal !== undefined) ||
911922
(d[commonAttr] || '').split(' ')[0] === t00;
912923
});
913-
});
924+
}
925+
926+
// Show a single hover label
927+
if(['x unified', 'y unified'].indexOf(hovermode) !== -1) {
928+
// Delete leftover hover labels from other hovermodes
929+
container.selectAll('g.hovertext').remove();
930+
931+
// similarly to compare mode, we remove the "close but not quite together" points
932+
if((t0 !== undefined) && (c0.distance <= opts.hoverdistance)) hoverData = filterClosePoints(hoverData);
933+
934+
// Return early if nothing is hovered on
935+
if(hoverData.length === 0) return;
936+
937+
// mock legend
938+
var mockLayoutIn = {
939+
showlegend: true,
940+
legend: {
941+
title: {text: t0, font: fullLayout.font},
942+
font: fullLayout.font,
943+
bgcolor: fullLayout.paper_bgcolor,
944+
borderwidth: 1,
945+
tracegroupgap: 7,
946+
traceorder: fullLayout.legend ? fullLayout.legend.traceorder : undefined,
947+
orientation: 'v'
948+
}
949+
};
950+
var mockLayoutOut = {};
951+
legendSupplyDefaults(mockLayoutIn, mockLayoutOut, gd._fullData);
952+
var legendOpts = mockLayoutOut.legend;
953+
954+
// prepare items for the legend
955+
legendOpts.entries = [];
956+
for(var j = 0; j < hoverData.length; j++) {
957+
var texts = getHoverLabelText(hoverData[j], true, hovermode, fullLayout, t0);
958+
var text = texts[0];
959+
var name = texts[1];
960+
var pt = hoverData[j];
961+
pt.name = name;
962+
if(name !== '') {
963+
pt.text = name + ' : ' + text;
964+
} else {
965+
pt.text = text;
966+
}
967+
968+
// pass through marker's calcdata to style legend items
969+
var cd = pt.cd[pt.index];
970+
if(cd) {
971+
if(cd.mc) pt.mc = cd.mc;
972+
if(cd.mcc) pt.mc = cd.mcc;
973+
if(cd.mlc) pt.mlc = cd.mlc;
974+
if(cd.mlcc) pt.mlc = cd.mlcc;
975+
if(cd.mlw) pt.mlw = cd.mlw;
976+
if(cd.mrc) pt.mrc = cd.mrc;
977+
if(cd.dir) pt.dir = cd.dir;
978+
}
979+
pt._distinct = true;
980+
981+
legendOpts.entries.push([pt]);
982+
}
983+
legendOpts.entries.sort(function(a, b) { return a[0].trace.index - b[0].trace.index;});
984+
legendOpts.layer = container;
985+
986+
// Draw unified hover label
987+
legendDraw(gd, legendOpts);
988+
989+
// Position the hover
990+
var ly = Lib.mean(hoverData.map(function(c) {return (c.y0 + c.y1) / 2;}));
991+
var lx = Lib.mean(hoverData.map(function(c) {return (c.x0 + c.x1) / 2;}));
992+
var legendContainer = container.select('g.legend');
993+
var tbb = legendContainer.node().getBoundingClientRect();
994+
lx += xa._offset;
995+
ly += ya._offset - tbb.height / 2;
996+
997+
// Change horizontal alignment to end up on screen
998+
var txWidth = tbb.width + 2 * HOVERTEXTPAD;
999+
var anchorStartOK = lx + txWidth <= outerWidth;
1000+
var anchorEndOK = lx - txWidth >= 0;
1001+
if(!anchorStartOK && anchorEndOK) {
1002+
lx -= txWidth;
1003+
} else {
1004+
lx += 2 * HOVERTEXTPAD;
1005+
}
1006+
1007+
// Change vertical alignement to end up on screen
1008+
var txHeight = tbb.height + 2 * HOVERTEXTPAD;
1009+
var overflowTop = ly <= outerTop;
1010+
var overflowBottom = ly + txHeight >= outerHeight;
1011+
var canFit = txHeight <= outerHeight;
1012+
if(canFit) {
1013+
if(overflowTop) {
1014+
ly = ya._offset + 2 * HOVERTEXTPAD;
1015+
} else if(overflowBottom) {
1016+
ly = outerHeight - txHeight;
1017+
}
1018+
}
1019+
legendContainer.attr('transform', 'translate(' + lx + ',' + ly + ')');
1020+
1021+
return legendContainer;
1022+
}
9141023

9151024
// show all the individual labels
9161025

@@ -941,8 +1050,6 @@ function createHoverText(hoverData, opts, gd) {
9411050
// and figure out sizes
9421051
hoverLabels.each(function(d) {
9431052
var g = d3.select(this).attr('transform', '');
944-
var name = '';
945-
var text = '';
9461053

9471054
// combine possible non-opaque trace color with bgColor
9481055
var color0 = d.bgcolor || d.color;
@@ -959,72 +1066,9 @@ function createHoverText(hoverData, opts, gd) {
9591066
// find a contrasting color for border and text
9601067
var contrastColor = d.borderColor || Color.contrast(numsColor);
9611068

962-
// to get custom 'name' labels pass cleanPoint
963-
if(d.nameOverride !== undefined) d.name = d.nameOverride;
964-
965-
if(d.name) {
966-
if(d.trace._meta) {
967-
d.name = Lib.templateString(d.name, d.trace._meta);
968-
}
969-
name = plainText(d.name, d.nameLength);
970-
}
971-
972-
if(d.zLabel !== undefined) {
973-
if(d.xLabel !== undefined) text += 'x: ' + d.xLabel + '<br>';
974-
if(d.yLabel !== undefined) text += 'y: ' + d.yLabel + '<br>';
975-
if(d.trace.type !== 'choropleth' && d.trace.type !== 'choroplethmapbox') {
976-
text += (text ? 'z: ' : '') + d.zLabel;
977-
}
978-
} else if(showCommonLabel && d[hovermode + 'Label'] === t0) {
979-
text = d[(hovermode === 'x' ? 'y' : 'x') + 'Label'] || '';
980-
} else if(d.xLabel === undefined) {
981-
if(d.yLabel !== undefined && d.trace.type !== 'scattercarpet') {
982-
text = d.yLabel;
983-
}
984-
} else if(d.yLabel === undefined) text = d.xLabel;
985-
else text = '(' + d.xLabel + ', ' + d.yLabel + ')';
986-
987-
if((d.text || d.text === 0) && !Array.isArray(d.text)) {
988-
text += (text ? '<br>' : '') + d.text;
989-
}
990-
991-
// used by other modules (initially just ternary) that
992-
// manage their own hoverinfo independent of cleanPoint
993-
// the rest of this will still apply, so such modules
994-
// can still put things in (x|y|z)Label, text, and name
995-
// and hoverinfo will still determine their visibility
996-
if(d.extraText !== undefined) text += (text ? '<br>' : '') + d.extraText;
997-
998-
// if 'text' is empty at this point,
999-
// and hovertemplate is not defined,
1000-
// put 'name' in main label and don't show secondary label
1001-
if(text === '' && !d.hovertemplate) {
1002-
// if 'name' is also empty, remove entire label
1003-
if(name === '') g.remove();
1004-
text = name;
1005-
}
1006-
1007-
// hovertemplate
1008-
var d3locale = fullLayout._d3locale;
1009-
var hovertemplate = d.hovertemplate || false;
1010-
var hovertemplateLabels = d.hovertemplateLabels || d;
1011-
var eventData = d.eventData[0] || {};
1012-
if(hovertemplate) {
1013-
text = Lib.hovertemplateString(
1014-
hovertemplate,
1015-
hovertemplateLabels,
1016-
d3locale,
1017-
eventData,
1018-
d.trace._meta
1019-
);
1020-
1021-
text = text.replace(EXTRA_STRING_REGEX, function(match, extra) {
1022-
// assign name for secondary text label
1023-
name = plainText(extra, d.nameLength);
1024-
// remove from main text label
1025-
return '';
1026-
});
1027-
}
1069+
var texts = getHoverLabelText(d, showCommonLabel, hovermode, fullLayout, t0, g);
1070+
var text = texts[0];
1071+
var name = texts[1];
10281072

10291073
// main label
10301074
var tx = g.select('text.nums')
@@ -1123,6 +1167,78 @@ function createHoverText(hoverData, opts, gd) {
11231167
return hoverLabels;
11241168
}
11251169

1170+
function getHoverLabelText(d, showCommonLabel, hovermode, fullLayout, t0, g) {
1171+
var name = '';
1172+
var text = '';
1173+
// to get custom 'name' labels pass cleanPoint
1174+
if(d.nameOverride !== undefined) d.name = d.nameOverride;
1175+
1176+
if(d.name) {
1177+
if(d.trace._meta) {
1178+
d.name = Lib.templateString(d.name, d.trace._meta);
1179+
}
1180+
name = plainText(d.name, d.nameLength);
1181+
}
1182+
1183+
if(d.zLabel !== undefined) {
1184+
if(d.xLabel !== undefined) text += 'x: ' + d.xLabel + '<br>';
1185+
if(d.yLabel !== undefined) text += 'y: ' + d.yLabel + '<br>';
1186+
if(d.trace.type !== 'choropleth' && d.trace.type !== 'choroplethmapbox') {
1187+
text += (text ? 'z: ' : '') + d.zLabel;
1188+
}
1189+
} else if(showCommonLabel && d[hovermode.charAt(0) + 'Label'] === t0) {
1190+
text = d[(hovermode.charAt(0) === 'x' ? 'y' : 'x') + 'Label'] || '';
1191+
} else if(d.xLabel === undefined) {
1192+
if(d.yLabel !== undefined && d.trace.type !== 'scattercarpet') {
1193+
text = d.yLabel;
1194+
}
1195+
} else if(d.yLabel === undefined) text = d.xLabel;
1196+
else text = '(' + d.xLabel + ', ' + d.yLabel + ')';
1197+
1198+
if((d.text || d.text === 0) && !Array.isArray(d.text)) {
1199+
text += (text ? '<br>' : '') + d.text;
1200+
}
1201+
1202+
// used by other modules (initially just ternary) that
1203+
// manage their own hoverinfo independent of cleanPoint
1204+
// the rest of this will still apply, so such modules
1205+
// can still put things in (x|y|z)Label, text, and name
1206+
// and hoverinfo will still determine their visibility
1207+
if(d.extraText !== undefined) text += (text ? '<br>' : '') + d.extraText;
1208+
1209+
// if 'text' is empty at this point,
1210+
// and hovertemplate is not defined,
1211+
// put 'name' in main label and don't show secondary label
1212+
if(g && text === '' && !d.hovertemplate) {
1213+
// if 'name' is also empty, remove entire label
1214+
if(name === '') g.remove();
1215+
text = name;
1216+
}
1217+
1218+
// hovertemplate
1219+
var d3locale = fullLayout._d3locale;
1220+
var hovertemplate = d.hovertemplate || false;
1221+
var hovertemplateLabels = d.hovertemplateLabels || d;
1222+
var eventData = d.eventData[0] || {};
1223+
if(hovertemplate) {
1224+
text = Lib.hovertemplateString(
1225+
hovertemplate,
1226+
hovertemplateLabels,
1227+
d3locale,
1228+
eventData,
1229+
d.trace._meta
1230+
);
1231+
1232+
text = text.replace(EXTRA_STRING_REGEX, function(match, extra) {
1233+
// assign name for secondary text label
1234+
name = plainText(extra, d.nameLength);
1235+
// remove from main text label
1236+
return '';
1237+
});
1238+
}
1239+
return [text, name];
1240+
}
1241+
11261242
// Make groups of touching points, and within each group
11271243
// move each point so that no labels overlap, but the average
11281244
// label position is the same as it was before moving. Indicentally,

src/components/fx/layout_attributes.js

+11-1
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,20 @@ module.exports = {
5757
hovermode: {
5858
valType: 'enumerated',
5959
role: 'info',
60-
values: ['x', 'y', 'closest', false],
60+
values: ['x', 'y', 'closest', false, 'x unified', 'y unified'],
6161
editType: 'modebar',
6262
description: [
6363
'Determines the mode of hover interactions.',
64+
'If *closest*, a single hoverlabel will appear',
65+
'for the *closest* point within the `hoverdistance`.',
66+
'If *x* (or *y*), multiple hoverlabels will appear for multiple points',
67+
'at the *closest* x- (or y-) coordinate within the `hoverdistance`,',
68+
'with the caveat that no more than one hoverlabel will appear per trace.',
69+
'If *x unified* (or *y unified*), a single hoverlabel will appear',
70+
'multiple points at the closest x- (or y-) coordinate within the `hoverdistance`',
71+
'with the caveat that no more than one hoverlabel will appear per trace.',
72+
'In this mode, spikelines are enabled by default perpendicular to the specified axis.',
73+
'If false, hover interactions are disabled.',
6474
'If `clickmode` includes the *select* flag,',
6575
'`hovermode` defaults to *closest*.',
6676
'If `clickmode` lacks the *select* flag,',

src/components/fx/layout_defaults.js

+5-1
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,12 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) {
3535

3636
var hoverMode = coerce('hovermode', hovermodeDflt);
3737
if(hoverMode) {
38+
var dflt;
39+
if(['x unified', 'y unified'].indexOf(hoverMode) !== -1) {
40+
dflt = -1;
41+
}
3842
coerce('hoverdistance');
39-
coerce('spikedistance');
43+
coerce('spikedistance', dflt);
4044
}
4145

4246
// if only mapbox or geo subplots is present on graph,

0 commit comments

Comments
 (0)