Skip to content

Commit ce5bc97

Browse files
authored
Merge pull request #2379 from plotly/generalize-spikelines
Generalize spikelines
2 parents 6011f0b + 31e4e34 commit ce5bc97

16 files changed

+803
-584
lines changed

src/components/fx/constants.js

-3
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,6 @@
99
'use strict';
1010

1111
module.exports = {
12-
// max pixels away from mouse to allow a point to highlight
13-
MAXDIST: 20,
14-
1512
// hover labels for multiple horizontal bars get tilted by this angle
1613
YANGLE: 60,
1714

src/components/fx/helpers.js

+10-14
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
'use strict';
1010

1111
var Lib = require('../../lib');
12-
var constants = require('./constants');
1312

1413
// look for either subplot or xaxis and yaxis attributes
1514
exports.getSubplot = function getSubplot(trace) {
@@ -62,19 +61,16 @@ exports.getClosest = function getClosest(cd, distfn, pointData) {
6261
return pointData;
6362
};
6463

65-
// for bar charts and others with finite-size objects: you must be inside
66-
// it to see its hover info, so distance is infinite outside.
67-
// But make distance inside be at least 1/4 MAXDIST, and a little bigger
68-
// for bigger bars, to prioritize scatter and smaller bars over big bars
69-
//
70-
// note that for closest mode, two inbox's will get added in quadrature
71-
// args are (signed) difference from the two opposite edges
72-
// count one edge as in, so that over continuous ranges you never get a gap
73-
exports.inbox = function inbox(v0, v1) {
74-
if(v0 * v1 < 0 || v0 === 0) {
75-
return constants.MAXDIST * (0.6 - 0.3 / Math.max(3, Math.abs(v0 - v1)));
76-
}
77-
return Infinity;
64+
/*
65+
* pseudo-distance function for hover effects on areas: inside the region
66+
* distance is finite (`passVal`), outside it's Infinity.
67+
*
68+
* @param {number} v0: signed difference between the current position and the left edge
69+
* @param {number} v1: signed difference between the current position and the right edge
70+
* @param {number} passVal: the value to return on success
71+
*/
72+
exports.inbox = function inbox(v0, v1, passVal) {
73+
return (v0 * v1 < 0 || v0 === 0) ? passVal : Infinity;
7874
};
7975

8076
exports.quadrature = function quadrature(dx, dy) {

src/components/fx/hover.js

+85-63
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,7 @@ exports.loneHover = function loneHover(hoverItem, opts) {
144144
rotateLabels: false,
145145
bgColor: opts.bgColor || Color.background,
146146
container: container3,
147-
outerContainer: outerContainer3,
148-
hoverdistance: constants.MAXDIST
147+
outerContainer: outerContainer3
149148
};
150149

151150
var hoverLabel = createHoverText([pointData], fullOpts, opts.gd);
@@ -162,9 +161,10 @@ function _hover(gd, evt, subplot, noHoverEvent) {
162161
// use those instead of finding overlayed plots
163162
var subplots = Array.isArray(subplot) ? subplot : [subplot];
164163

165-
var fullLayout = gd._fullLayout,
166-
plots = fullLayout._plots || [],
167-
plotinfo = plots[subplot];
164+
var fullLayout = gd._fullLayout;
165+
var plots = fullLayout._plots || [];
166+
var plotinfo = plots[subplot];
167+
var hasCartesian = fullLayout._has('cartesian');
168168

169169
// list of all overlaid subplots to look at
170170
if(plotinfo) {
@@ -351,9 +351,29 @@ function _hover(gd, evt, subplot, noHoverEvent) {
351351
trace: trace,
352352
xa: xaArray[subploti],
353353
ya: yaArray[subploti],
354+
355+
// max distances for hover and spikes - for points that want to show but do not
356+
// want to override other points, set distance/spikeDistance equal to max*Distance
357+
// and it will not get filtered out but it will be guaranteed to have a greater
358+
// distance than any point that calculated a real distance.
359+
maxHoverDistance: hoverdistance,
360+
maxSpikeDistance: spikedistance,
361+
354362
// point properties - override all of these
355363
index: false, // point index in trace - only used by plotly.js hoverdata consumers
356364
distance: Math.min(distance, hoverdistance), // pixel distance or pseudo-distance
365+
366+
// distance/pseudo-distance for spikes. This distance should always be calculated
367+
// as if in "closest" mode, and should only be set if this point should
368+
// generate a spike.
369+
spikeDistance: Infinity,
370+
371+
// in some cases the spikes have different positioning from the hover label
372+
// they don't need x0/x1, just one position
373+
xSpike: undefined,
374+
ySpike: undefined,
375+
376+
// where and how to display the hover label
357377
color: Color.defaultLine, // trace color
358378
name: trace.name,
359379
x0: undefined,
@@ -418,29 +438,37 @@ function _hover(gd, evt, subplot, noHoverEvent) {
418438
}
419439

420440
// in closest mode, remove any existing (farther) points
421-
// and don't look any farther than this latest point (or points, if boxes)
441+
// and don't look any farther than this latest point (or points, some
442+
// traces like box & violin make multiple hover labels at once)
422443
if(hovermode === 'closest' && hoverData.length > closedataPreviousLength) {
423444
hoverData.splice(0, closedataPreviousLength);
424445
distance = hoverData[0].distance;
425446
}
426447

427448
// Now if there is range to look in, find the points to draw the spikelines
428449
// Do it only if there is no hoverData
429-
if(fullLayout._has('cartesian') && (spikedistance !== 0)) {
450+
if(hasCartesian && (spikedistance !== 0)) {
430451
if(hoverData.length === 0) {
431452
pointData.distance = spikedistance;
432453
pointData.index = false;
433454
var closestPoints = trace._module.hoverPoints(pointData, xval, yval, 'closest', fullLayout._hoverlayer);
434455
if(closestPoints) {
456+
closestPoints = closestPoints.filter(function(point) {
457+
// some hover points, like scatter fills, do not allow spikes,
458+
// so will generate a hover point but without a valid spikeDistance
459+
return point.spikeDistance <= spikedistance;
460+
});
461+
}
462+
if(closestPoints && closestPoints.length) {
435463
var tmpPoint;
436464
var closestVPoints = closestPoints.filter(function(point) {
437465
return point.xa.showspikes;
438466
});
439467
if(closestVPoints.length) {
440468
var closestVPt = closestVPoints[0];
441469
if(isNumeric(closestVPt.x0) && isNumeric(closestVPt.y0)) {
442-
tmpPoint = fillClosestPoint(closestVPt);
443-
if(!spikePoints.vLinePoint || (spikePoints.vLinePoint.distance > tmpPoint.distance)) {
470+
tmpPoint = fillSpikePoint(closestVPt);
471+
if(!spikePoints.vLinePoint || (spikePoints.vLinePoint.spikeDistance > tmpPoint.spikeDistance)) {
444472
spikePoints.vLinePoint = tmpPoint;
445473
}
446474
}
@@ -452,8 +480,8 @@ function _hover(gd, evt, subplot, noHoverEvent) {
452480
if(closestHPoints.length) {
453481
var closestHPt = closestHPoints[0];
454482
if(isNumeric(closestHPt.x0) && isNumeric(closestHPt.y0)) {
455-
tmpPoint = fillClosestPoint(closestHPt);
456-
if(!spikePoints.hLinePoint || (spikePoints.hLinePoint.distance > tmpPoint.distance)) {
483+
tmpPoint = fillSpikePoint(closestHPt);
484+
if(!spikePoints.hLinePoint || (spikePoints.hLinePoint.spikeDistance > tmpPoint.spikeDistance)) {
457485
spikePoints.hLinePoint = tmpPoint;
458486
}
459487
}
@@ -464,47 +492,28 @@ function _hover(gd, evt, subplot, noHoverEvent) {
464492
}
465493

466494
function selectClosestPoint(pointsData, spikedistance) {
467-
if(!pointsData.length) return null;
468-
var resultPoint;
469-
var pointsDistances = pointsData.map(function(point, index) {
470-
var xa = point.xa,
471-
ya = point.ya,
472-
xpx = xa.c2p(xval),
473-
ypx = ya.c2p(yval),
474-
dxy = function(point) {
475-
var rad = point.kink,
476-
dx = (point.x1 + point.x0) / 2 - xpx,
477-
dy = (point.y1 + point.y0) / 2 - ypx;
478-
return Math.max(Math.sqrt(dx * dx + dy * dy) - rad, 1 - 3 / rad);
479-
};
480-
var distance = dxy(point);
481-
return {distance: distance, index: index};
482-
});
483-
pointsDistances = pointsDistances
484-
.filter(function(point) {
485-
return point.distance <= spikedistance;
486-
})
487-
.sort(function(a, b) {
488-
return a.distance - b.distance;
489-
});
490-
if(pointsDistances.length) {
491-
resultPoint = pointsData[pointsDistances[0].index];
492-
} else {
493-
resultPoint = null;
495+
var resultPoint = null;
496+
var minDistance = Infinity;
497+
var thisSpikeDistance;
498+
for(var i = 0; i < pointsData.length; i++) {
499+
thisSpikeDistance = pointsData[i].spikeDistance;
500+
if(thisSpikeDistance < minDistance && thisSpikeDistance <= spikedistance) {
501+
resultPoint = pointsData[i];
502+
minDistance = thisSpikeDistance;
503+
}
494504
}
495505
return resultPoint;
496506
}
497507

498-
function fillClosestPoint(point) {
508+
function fillSpikePoint(point) {
499509
if(!point) return null;
500510
return {
501511
xa: point.xa,
502512
ya: point.ya,
503-
x0: point.x0,
504-
x1: point.x1,
505-
y0: point.y0,
506-
y1: point.y1,
513+
x: point.xSpike !== undefined ? point.xSpike : (point.x0 + point.x1) / 2,
514+
y: point.ySpike !== undefined ? point.ySpike : (point.y0 + point.y1) / 2,
507515
distance: point.distance,
516+
spikeDistance: point.spikeDistance,
508517
curveNumber: point.trace.index,
509518
color: point.color,
510519
pointNumber: point.index
@@ -525,34 +534,34 @@ function _hover(gd, evt, subplot, noHoverEvent) {
525534
gd._spikepoints = newspikepoints;
526535

527536
// Now if it is not restricted by spikedistance option, set the points to draw the spikelines
528-
if(fullLayout._has('cartesian') && (spikedistance !== 0)) {
537+
if(hasCartesian && (spikedistance !== 0)) {
529538
if(hoverData.length !== 0) {
530539
var tmpHPointData = hoverData.filter(function(point) {
531540
return point.ya.showspikes;
532541
});
533542
var tmpHPoint = selectClosestPoint(tmpHPointData, spikedistance);
534-
spikePoints.hLinePoint = fillClosestPoint(tmpHPoint);
543+
spikePoints.hLinePoint = fillSpikePoint(tmpHPoint);
535544

536545
var tmpVPointData = hoverData.filter(function(point) {
537546
return point.xa.showspikes;
538547
});
539548
var tmpVPoint = selectClosestPoint(tmpVPointData, spikedistance);
540-
spikePoints.vLinePoint = fillClosestPoint(tmpVPoint);
549+
spikePoints.vLinePoint = fillSpikePoint(tmpVPoint);
541550
}
542551
}
543552

544553
// if hoverData is empty check for the spikes to draw and quit if there are none
545554
if(hoverData.length === 0) {
546555
var result = dragElement.unhoverRaw(gd, evt);
547-
if(fullLayout._has('cartesian') && ((spikePoints.hLinePoint !== null) || (spikePoints.vLinePoint !== null))) {
556+
if(hasCartesian && ((spikePoints.hLinePoint !== null) || (spikePoints.vLinePoint !== null))) {
548557
if(spikesChanged(oldspikepoints)) {
549558
createSpikelines(spikePoints, spikelineOpts);
550559
}
551560
}
552561
return result;
553562
}
554563

555-
if(fullLayout._has('cartesian')) {
564+
if(hasCartesian) {
556565
if(spikesChanged(oldspikepoints)) {
557566
createSpikelines(spikePoints, spikelineOpts);
558567
}
@@ -653,20 +662,31 @@ function createHoverText(hoverData, opts, gd) {
653662
// show the common label, if any, on the axis
654663
// never show a common label in array mode,
655664
// even if sometimes there could be one
656-
var showCommonLabel = c0.distance <= opts.hoverdistance &&
657-
(hovermode === 'x' || hovermode === 'y');
665+
var showCommonLabel = (
666+
(t0 !== undefined) &&
667+
(c0.distance <= opts.hoverdistance) &&
668+
(hovermode === 'x' || hovermode === 'y')
669+
);
658670

659671
// all hover traces hoverinfo must contain the hovermode
660672
// to have common labels
661-
var i, traceHoverinfo;
662-
for(i = 0; i < hoverData.length; i++) {
663-
traceHoverinfo = hoverData[i].hoverinfo || hoverData[i].trace.hoverinfo;
664-
var parts = Array.isArray(traceHoverinfo) ? traceHoverinfo : traceHoverinfo.split('+');
665-
if(parts.indexOf('all') === -1 &&
666-
parts.indexOf(hovermode) === -1) {
667-
showCommonLabel = false;
668-
break;
673+
if(showCommonLabel) {
674+
var i, traceHoverinfo;
675+
var allHaveZ = true;
676+
for(i = 0; i < hoverData.length; i++) {
677+
if(allHaveZ && hoverData[i].zLabel === undefined) allHaveZ = false;
678+
679+
traceHoverinfo = hoverData[i].hoverinfo || hoverData[i].trace.hoverinfo;
680+
var parts = Array.isArray(traceHoverinfo) ? traceHoverinfo : traceHoverinfo.split('+');
681+
if(parts.indexOf('all') === -1 &&
682+
parts.indexOf(hovermode) === -1) {
683+
showCommonLabel = false;
684+
break;
685+
}
669686
}
687+
688+
// xyz labels put all info in their main label, so have no need of a common label
689+
if(allHaveZ) showCommonLabel = false;
670690
}
671691

672692
var commonLabel = container.selectAll('g.axistext')
@@ -1170,7 +1190,9 @@ function cleanPoint(d, hovermode) {
11701190
fill('fontColor', 'htc', 'hoverlabel.font.color');
11711191
fill('nameLength', 'hnl', 'hoverlabel.namelength');
11721192

1173-
d.posref = hovermode === 'y' ? (d.x0 + d.x1) / 2 : (d.y0 + d.y1) / 2;
1193+
d.posref = hovermode === 'y' ?
1194+
(d.xa._offset + (d.x0 + d.x1) / 2) :
1195+
(d.ya._offset + (d.y0 + d.y1) / 2);
11741196

11751197
// then constrain all the positions to be on the plot
11761198
d.x0 = Lib.constrain(d.x0, 0, d.xa._length);
@@ -1262,8 +1284,8 @@ function createSpikelines(closestPoints, opts) {
12621284
hLinePointX = evt.pointerX;
12631285
hLinePointY = evt.pointerY;
12641286
} else {
1265-
hLinePointX = xa._offset + (hLinePoint.x0 + hLinePoint.x1) / 2;
1266-
hLinePointY = ya._offset + (hLinePoint.y0 + hLinePoint.y1) / 2;
1287+
hLinePointX = xa._offset + hLinePoint.x;
1288+
hLinePointY = ya._offset + hLinePoint.y;
12671289
}
12681290
var dfltHLineColor = tinycolor.readability(hLinePoint.color, contrastColor) < 1.5 ?
12691291
Color.contrast(contrastColor) : hLinePoint.color;
@@ -1338,8 +1360,8 @@ function createSpikelines(closestPoints, opts) {
13381360
vLinePointX = evt.pointerX;
13391361
vLinePointY = evt.pointerY;
13401362
} else {
1341-
vLinePointX = xa._offset + (vLinePoint.x0 + vLinePoint.x1) / 2;
1342-
vLinePointY = ya._offset + (vLinePoint.y0 + vLinePoint.y1) / 2;
1363+
vLinePointX = xa._offset + vLinePoint.x;
1364+
vLinePointY = ya._offset + vLinePoint.y;
13431365
}
13441366
var dfltVLineColor = tinycolor.readability(vLinePoint.color, contrastColor) < 1.5 ?
13451367
Color.contrast(contrastColor) : vLinePoint.color;

src/components/fx/layout_attributes.js

+9-2
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,11 @@ module.exports = {
4646
editType: 'none',
4747
description: [
4848
'Sets the default distance (in pixels) to look for data',
49-
'to add hover labels (-1 means no cutoff, 0 means no looking for data)'
49+
'to add hover labels (-1 means no cutoff, 0 means no looking for data).',
50+
'This is only a real distance for hovering on point-like objects,',
51+
'like scatter points. For area-like objects (bars, scatter fills, etc)',
52+
'hovering is on inside the area and off outside, but these objects',
53+
'will not supersede hover on point-like objects in case of conflict.'
5054
].join(' ')
5155
},
5256
spikedistance: {
@@ -57,7 +61,10 @@ module.exports = {
5761
editType: 'none',
5862
description: [
5963
'Sets the default distance (in pixels) to look for data to draw',
60-
'spikelines to (-1 means no cutoff, 0 means no looking for data).'
64+
'spikelines to (-1 means no cutoff, 0 means no looking for data).',
65+
'As with hoverdistance, distance does not apply to area-like objects.',
66+
'In addition, some objects can be hovered on but will not generate',
67+
'spikelines, such as scatter fills.'
6168
].join(' ')
6269
},
6370
hoverlabel: {

0 commit comments

Comments
 (0)