Skip to content

Support extreme off-plot data points in scatter lines #2060

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 6 commits into from
Oct 5, 2017
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions src/plot_api/plot_api.js
Original file line number Diff line number Diff line change
Expand Up @@ -2734,12 +2734,8 @@ Plotly.purge = function purge(gd) {
// remove plot container
if(fullLayout._container) fullLayout._container.remove();

// in contrast to Plotly.Plots.purge which does NOT clear _context!
delete gd._context;
delete gd._replotPending;
delete gd._mouseDownTime;
delete gd._legendMouseDownTime;
delete gd._hmpixcount;
delete gd._hmlumcount;

return gd;
};
Expand Down
15 changes: 13 additions & 2 deletions src/plots/plots.js
Original file line number Diff line number Diff line change
Expand Up @@ -1341,14 +1341,25 @@ plots.purge = function(gd) {
delete gd._promises;
delete gd._redrawTimer;
delete gd.firstscatter;
delete gd.hmlumcount;
delete gd.hmpixcount;
delete gd._hmlumcount;
delete gd._hmpixcount;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch!

delete gd.numboxes;
delete gd._transitionData;
delete gd._transitioning;
delete gd._initialAutoSize;
delete gd._transitioningWithDuration;

// created during certain events, that *should* clean them up
// themselves, but may not if there was an error
delete gd._dragging;
delete gd._dragged;
delete gd._hoverdata;
delete gd._snapshotInProgress;
delete gd._editing;
delete gd._replotPending;
delete gd._mouseDownTime;
delete gd._legendMouseDownTime;

// remove all event listeners
if(gd.removeAllListeners) gd.removeAllListeners();
};
Expand Down
13 changes: 12 additions & 1 deletion src/traces/scatter/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,16 @@
'use strict';

module.exports = {
PTS_LINESONLY: 20
PTS_LINESONLY: 20,

// fixed parameters of clustering and clipping algorithms

// fraction of clustering tolerance "so close we don't even consider it a new point"
minTolerance: 0.2,
// how fast does clustering tolerance increase as you get away from the visible region
toleranceGrowth: 10,

// number of viewport sizes away from the visible region
// at which we clip all lines to the perimeter
maxScreensAway: 20
};
266 changes: 217 additions & 49 deletions src/traces/scatter/line_points.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,76 +10,241 @@
'use strict';

var BADNUM = require('../../constants/numerical').BADNUM;
var segmentsIntersect = require('../../lib/geometry2d').segmentsIntersect;
var constants = require('./constants');


module.exports = function linePoints(d, opts) {
var xa = opts.xaxis,
ya = opts.yaxis,
simplify = opts.simplify,
connectGaps = opts.connectGaps,
baseTolerance = opts.baseTolerance,
linear = opts.linear,
segments = [],
minTolerance = 0.2, // fraction of tolerance "so close we don't even consider it a new point"
pts = new Array(d.length),
pti = 0,
i,

// pt variables are pixel coordinates [x,y] of one point
clusterStartPt, // these four are the outputs of clustering on a line
clusterEndPt,
clusterHighPt,
clusterLowPt,
thisPt, // "this" is the next point we're considering adding to the cluster

clusterRefDist,
clusterHighFirst, // did we encounter the high point first, then a low point, or vice versa?
clusterUnitVector, // the first two points in the cluster determine its unit vector
// so the second is always in the "High" direction
thisVector, // the pixel delta from clusterStartPt

// val variables are (signed) pixel distances along the cluster vector
clusterHighVal,
clusterLowVal,
thisVal,

// deviation variables are (signed) pixel distances normal to the cluster vector
clusterMinDeviation,
clusterMaxDeviation,
thisDeviation;
var xa = opts.xaxis;
var ya = opts.yaxis;
var simplify = opts.simplify;
var connectGaps = opts.connectGaps;
var baseTolerance = opts.baseTolerance;
var linear = opts.linear;
var segments = [];
var minTolerance = constants.minTolerance;
var pts = new Array(d.length);
var pti = 0;

var i;

// pt variables are pixel coordinates [x,y] of one point
// these four are the outputs of clustering on a line
var clusterStartPt, clusterEndPt, clusterHighPt, clusterLowPt;

// "this" is the next point we're considering adding to the cluster
var thisPt;

// did we encounter the high point first, then a low point, or vice versa?
var clusterHighFirst;

// the first two points in the cluster determine its unit vector
// so the second is always in the "High" direction
var clusterUnitVector;

// the pixel delta from clusterStartPt
var thisVector;

// val variables are (signed) pixel distances along the cluster vector
var clusterRefDist, clusterHighVal, clusterLowVal, thisVal;

// deviation variables are (signed) pixel distances normal to the cluster vector
var clusterMinDeviation, clusterMaxDeviation, thisDeviation;

if(!simplify) {
baseTolerance = minTolerance = -1;
}

// turn one calcdata point into pixel coordinates
function getPt(index) {
var x = xa.c2p(d[index].x),
y = ya.c2p(d[index].y);
var x = xa.c2p(d[index].x);
var y = ya.c2p(d[index].y);
if(x === BADNUM || y === BADNUM) return false;
return [x, y];
}

// if we're off-screen, increase tolerance over baseTolerance
function getTolerance(pt) {
var xFrac = pt[0] / xa._length,
yFrac = pt[1] / ya._length;
return (1 + 10 * Math.max(0, -xFrac, xFrac - 1, -yFrac, yFrac - 1)) * baseTolerance;
var xFrac = pt[0] / xa._length;
var yFrac = pt[1] / ya._length;
return (1 + constants.toleranceGrowth * Math.max(0, -xFrac, xFrac - 1, -yFrac, yFrac - 1)) * baseTolerance;
}

function ptDist(pt1, pt2) {
var dx = pt1[0] - pt2[0],
dy = pt1[1] - pt2[1];
var dx = pt1[0] - pt2[0];
var dy = pt1[1] - pt2[1];
return Math.sqrt(dx * dx + dy * dy);
}

// last bit of filtering: clip paths that are VERY far off-screen
// so we don't get near the browser's hard limit (+/- 2^29 px in Chrome and FF)

var maxScreensAway = constants.maxScreensAway;

// find the intersections between the segment from pt1 to pt2
// and the large rectangle maxScreensAway around the viewport
// if one of pt1 and pt2 is inside and the other outside, there
// will be only one intersection.
// if both are outside there will be 0 or 2 intersections
// (or 1 if it's right at a corner - we'll treat that like 0)
// returns an array of intersection pts
var xEdge0 = -xa._length * maxScreensAway;
var xEdge1 = xa._length * (1 + maxScreensAway);
var yEdge0 = -ya._length * maxScreensAway;
var yEdge1 = ya._length * (1 + maxScreensAway);
var edges = [
[xEdge0, yEdge0, xEdge1, yEdge0],
[xEdge1, yEdge0, xEdge1, yEdge1],
[xEdge1, yEdge1, xEdge0, yEdge1],
[xEdge0, yEdge1, xEdge0, yEdge0]
];
var xEdge, yEdge, lastXEdge, lastYEdge, lastFarPt, edgePt;

function getEdgeIntersections(pt1, pt2) {
var out = [];
var ptCount = 0;
for(var i = 0; i < 4; i++) {
var edge = edges[i];
var ptInt = segmentsIntersect(pt1[0], pt1[1], pt2[0], pt2[1],
edge[0], edge[1], edge[2], edge[3]);
if(ptInt && (!ptCount ||
Math.abs(ptInt.x - out[0][0]) > 1 ||
Math.abs(ptInt.y - out[0][1]) > 1
)) {
ptInt = [ptInt.x, ptInt.y];
// if we have 2 intersections, make sure the closest one to pt1 comes first
if(ptCount && ptDist(ptInt, pt1) < ptDist(out[0], pt1)) out.unshift(ptInt);
else out.push(ptInt);
ptCount++;
}
}
return out;
}

// a segment pt1->pt2 entirely outside the nearby region:
// find the corner it gets closest to touching
function getClosestCorner(pt1, pt2) {
var dx = pt2[0] - pt1[0];
var m = (pt2[1] - pt1[1]) / dx;
var b = (pt1[1] * pt2[0] - pt2[1] * pt1[0]) / dx;

if(b > 0) return [m > 0 ? xEdge0 : xEdge1, yEdge1];
else return [m > 0 ? xEdge1 : xEdge0, yEdge0];
}

function updateEdge(pt) {
var x = pt[0];
var y = pt[1];
var xSame = x === pts[pti - 1][0];
var ySame = y === pts[pti - 1][1];
// duplicate point?
if(xSame && ySame) return;
if(pti > 1) {
// backtracking along an edge?
var xSame2 = x === pts[pti - 2][0];
var ySame2 = y === pts[pti - 2][1];
if(xSame && (x === xEdge0 || x === xEdge1) && xSame2) {
if(ySame2) pti--; // backtracking exactly - drop prev pt and don't add
else pts[pti - 1] = pt; // not exact: replace the prev pt
}
else if(ySame && (y === yEdge0 || y === yEdge1) && ySame2) {
if(xSame2) pti--;
else pts[pti - 1] = pt;
}
else pts[pti++] = pt;
}
else pts[pti++] = pt;
}

function updateEdgesForReentry(pt) {
// if we're outside the nearby region and going back in,
// we may need to loop around a corner point
if(pts[pti - 1][0] !== pt[0] && pts[pti - 1][1] !== pt[1]) {
updateEdge([lastXEdge, lastYEdge]);
}
updateEdge(pt);
lastFarPt = null;
lastXEdge = lastYEdge = 0;
}

function addPt(pt) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, if I understand correctly, this here gets calls for all line.shape values?

If so, it might be nice to add one or two non-'linear' line.shape traces in the ultra_zoom mock.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's correct. And good call on adding some test cases for other line.shape values. I actually might want to think about this a little - spline is probably going to have to stay untouched, but [hv]+ may have relatively simple ways to make them behave perfectly correctly, which they don't now because we linearly interpolate between the on-screen and extreme points.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spline actually works remarkably well using the linear algorithm - because Catmull-Rom smoothing reaches a steady limit to the curve shape on-screen as the next point moves farther and farther off-screen.

37b5cdc adds special handling for the right-angle line shapes - not too bad, certainly less involved than linear! Test-wise, in the interest of time I only altered ultra-zoom, did not add extra jasmine tests for these, I hope that's OK.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hope that's OK.

Yeah, image test support should suffice here. 👍

// Are we more than maxScreensAway off-screen any direction?
// if so, clip to this box, but in such a way that on-screen
// drawing is unchanged
xEdge = (pt[0] < xEdge0) ? xEdge0 : (pt[0] > xEdge1) ? xEdge1 : 0;
yEdge = (pt[1] < yEdge0) ? yEdge0 : (pt[1] > yEdge1) ? yEdge1 : 0;
if(xEdge || yEdge) {
// to get fills right - if first point is far, push it toward the
// screen in whichever direction(s) are far
if(!pti) {
pts[pti++] = [xEdge || pt[0], yEdge || pt[1]];
}
else if(lastFarPt) {
// both this point and the last are outside the nearby region
// check if we're crossing the nearby region
var intersections = getEdgeIntersections(lastFarPt, pt);
if(intersections.length > 1) {
updateEdgesForReentry(intersections[0]);
pts[pti++] = intersections[1];
}
}
// we're leaving the nearby region - add the point where we left it
else {
edgePt = getEdgeIntersections(pts[pti - 1], pt)[0];
pts[pti++] = edgePt;
}

var lastPt = pts[pti - 1];
if(xEdge && yEdge && (lastPt[0] !== xEdge || lastPt[1] !== yEdge)) {
// we've gone out beyond a new corner: add the corner too
// so that the next point will take the right winding
if(lastFarPt) {
if(lastXEdge !== xEdge && lastYEdge !== yEdge) {
if(lastXEdge && lastYEdge) {
// we've gone around to an opposite corner - we
// need to add the correct extra corner
// in order to get the right winding
updateEdge(getClosestCorner(lastFarPt, pt));
}
else {
// we're coming from a far edge - the extra corner
// we need is determined uniquely by the sectors
updateEdge([lastXEdge || xEdge, lastYEdge || yEdge]);
}
}
else if(lastXEdge && lastYEdge) {
updateEdge([lastXEdge, lastYEdge]);
}
}
updateEdge([xEdge, yEdge]);
}
else if((lastXEdge - xEdge) && (lastYEdge - yEdge)) {
// we're coming from an edge or far corner to an edge - again the
// extra corner we need is uniquely determined by the sectors
updateEdge([xEdge || lastXEdge, yEdge || lastYEdge]);
}
lastFarPt = pt;
lastXEdge = xEdge;
lastYEdge = yEdge;
}
else {
if(lastFarPt) {
// this point is in range but the previous wasn't: add its entry pt first
updateEdgesForReentry(getEdgeIntersections(lastFarPt, pt)[0]);
}

pts[pti++] = pt;
}
}

// loop over ALL points in this trace
for(i = 0; i < d.length; i++) {
clusterStartPt = getPt(i);
if(!clusterStartPt) continue;

pti = 0;
pts[pti++] = clusterStartPt;
lastFarPt = null;
addPt(clusterStartPt);

// loop over one segment of the trace
for(i++; i < d.length; i++) {
Expand All @@ -93,7 +258,7 @@ module.exports = function linePoints(d, opts) {
// TODO: we *could* decimate [hv]{2,3} shapes if we restricted clusters to horz or vert again
// but spline would be verrry awkward to decimate
if(!linear) {
pts[pti++] = clusterHighPt;
addPt(clusterHighPt);
continue;
}

Expand Down Expand Up @@ -147,23 +312,26 @@ module.exports = function linePoints(d, opts) {
// insert this cluster into pts
// we've already inserted the start pt, now check if we have high and low pts
if(clusterHighFirst) {
pts[pti++] = clusterHighPt;
if(clusterEndPt !== clusterLowPt) pts[pti++] = clusterLowPt;
addPt(clusterHighPt);
if(clusterEndPt !== clusterLowPt) addPt(clusterLowPt);
} else {
if(clusterLowPt !== clusterStartPt) pts[pti++] = clusterLowPt;
if(clusterEndPt !== clusterHighPt) pts[pti++] = clusterHighPt;
if(clusterLowPt !== clusterStartPt) addPt(clusterLowPt);
if(clusterEndPt !== clusterHighPt) addPt(clusterHighPt);
}
// and finally insert the end pt
pts[pti++] = clusterEndPt;
addPt(clusterEndPt);

// have we reached the end of this segment?
if(i >= d.length || !thisPt) break;

// otherwise we have an out-of-cluster point to insert as next clusterStartPt
pts[pti++] = thisPt;
addPt(thisPt);
clusterStartPt = thisPt;
}

// to get fills right - repeat what we did at the start
if(lastFarPt) updateEdge([lastXEdge || lastFarPt[0], lastYEdge || lastFarPt[1]]);

segments.push(pts.slice(0, pti));
}

Expand Down
Binary file modified test/image/baselines/axes_range_manual.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/image/baselines/range_selector_style.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/image/baselines/ultra_zoom.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading