diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index b12611ccbc7..75f6b092529 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -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; }; diff --git a/src/plots/plots.js b/src/plots/plots.js index b73a8bbe487..9bd83886d96 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -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; 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(); }; diff --git a/src/traces/scatter/constants.js b/src/traces/scatter/constants.js index 66eb332f109..159fca23b0c 100644 --- a/src/traces/scatter/constants.js +++ b/src/traces/scatter/constants.js @@ -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 }; diff --git a/src/traces/scatter/line_points.js b/src/traces/scatter/line_points.js index 03b77be8dfa..993e3c8982c 100644 --- a/src/traces/scatter/line_points.js +++ b/src/traces/scatter/line_points.js @@ -10,43 +10,49 @@ 'use strict'; var BADNUM = require('../../constants/numerical').BADNUM; +var Lib = require('../../lib'); +var segmentsIntersect = Lib.segmentsIntersect; +var constrain = Lib.constrain; +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 shape = opts.shape; + var linear = shape === '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; @@ -54,32 +60,261 @@ module.exports = function linePoints(d, opts) { // 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; + + // for linear line shape, edge intersections should be linearly interpolated + // spline uses this too, which isn't precisely correct but is actually pretty + // good, because Catmull-Rom weights far-away points less in creating the curvature + function getLinearEdgeIntersections(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; + } + + function onlyConstrainedPoint(pt) { + if(pt[0] < xEdge0 || pt[0] > xEdge1 || pt[1] < yEdge0 || pt[1] > yEdge1) { + return [constrain(pt[0], xEdge0, xEdge1), constrain(pt[1], yEdge0, yEdge1)]; + } + } + + function sameEdge(pt1, pt2) { + if(pt1[0] === pt2[0] && (pt1[0] === xEdge0 || pt1[0] === xEdge1)) return true; + if(pt1[1] === pt2[1] && (pt1[1] === yEdge0 || pt1[1] === yEdge1)) return true; + } + + // for line shapes hv and vh, movement in the two dimensions is decoupled, + // so all we need to do is constrain each dimension independently + function getHVEdgeIntersections(pt1, pt2) { + var out = []; + var ptInt1 = onlyConstrainedPoint(pt1); + var ptInt2 = onlyConstrainedPoint(pt2); + if(ptInt1 && ptInt2 && sameEdge(ptInt1, ptInt2)) return out; + + if(ptInt1) out.push(ptInt1); + if(ptInt2) out.push(ptInt2); + return out; + } + + // hvh and vhv we sometimes have to move one of the intersection points + // out BEYOND the clipping rect, by a maximum of a factor of 2, so that + // the midpoint line is drawn in the right place + function getABAEdgeIntersections(dim, limit0, limit1) { + return function(pt1, pt2) { + var ptInt1 = onlyConstrainedPoint(pt1); + var ptInt2 = onlyConstrainedPoint(pt2); + + var out = []; + if(ptInt1 && ptInt2 && sameEdge(ptInt1, ptInt2)) return out; + + if(ptInt1) out.push(ptInt1); + if(ptInt2) out.push(ptInt2); + + var midShift = 2 * Lib.constrain((pt1[dim] + pt2[dim]) / 2, limit0, limit1) - + ((ptInt1 || pt1)[dim] + (ptInt2 || pt2)[dim]); + if(midShift) { + var ptToAlter; + if(ptInt1 && ptInt2) { + ptToAlter = (midShift > 0 === ptInt1[dim] > ptInt2[dim]) ? ptInt1 : ptInt2; + } + else ptToAlter = ptInt1 || ptInt2; + + ptToAlter[dim] += midShift; + } + + return out; + }; + } + + var getEdgeIntersections; + if(shape === 'linear' || shape === 'spline') { + getEdgeIntersections = getLinearEdgeIntersections; + } + else if(shape === 'hv' || shape === 'vh') { + getEdgeIntersections = getHVEdgeIntersections; + } + else if(shape === 'hvh') getEdgeIntersections = getABAEdgeIntersections(0, xEdge0, xEdge1); + else if(shape === 'vhv') getEdgeIntersections = getABAEdgeIntersections(1, yEdge0, yEdge1); + + // 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) { + // 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++) { @@ -93,7 +328,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; } @@ -147,23 +382,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)); } diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index e4b454857dd..7c34555aeb0 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -250,7 +250,7 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition yaxis: ya, connectGaps: trace.connectgaps, baseTolerance: Math.max(line.width || 1, 3) / 4, - linear: line.shape === 'linear', + shape: line.shape, simplify: line.simplify }); diff --git a/test/image/baselines/axes_range_manual.png b/test/image/baselines/axes_range_manual.png index 0419301721b..9ebce9e1f7c 100644 Binary files a/test/image/baselines/axes_range_manual.png and b/test/image/baselines/axes_range_manual.png differ diff --git a/test/image/baselines/range_selector_style.png b/test/image/baselines/range_selector_style.png index c5be54fd04c..7bc630a406e 100644 Binary files a/test/image/baselines/range_selector_style.png and b/test/image/baselines/range_selector_style.png differ diff --git a/test/image/baselines/ultra_zoom.png b/test/image/baselines/ultra_zoom.png index 7117f8e990d..bdeab353df4 100644 Binary files a/test/image/baselines/ultra_zoom.png and b/test/image/baselines/ultra_zoom.png differ diff --git a/test/image/mocks/axes_range_manual.json b/test/image/mocks/axes_range_manual.json index 19d8aff3912..3d4e663126d 100644 --- a/test/image/mocks/axes_range_manual.json +++ b/test/image/mocks/axes_range_manual.json @@ -31,6 +31,10 @@ 1, 2, 3, + 1000005, + -1e100, + 3, + 4, 4, 5, 6, @@ -42,13 +46,18 @@ 1, 2, 3, + -3000000, + 3e100, + 1e12, + -1e11, 4, 5, 6, 7, 8 ], - "type": "scatter" + "type": "scatter", + "mode": "lines" } ], "layout": { diff --git a/test/image/mocks/ultra_zoom.json b/test/image/mocks/ultra_zoom.json index e71b81683c0..e907bd41a63 100644 --- a/test/image/mocks/ultra_zoom.json +++ b/test/image/mocks/ultra_zoom.json @@ -1,33 +1,15 @@ { "data": [ - { - "x": [ - 10000, - 0.0001 - ], - "y": [ - 0.0001, - 10000 - ] - } + {"x": [10000, 0.0001], "y": [0.0001, 10000], "fill": "tozeroy", "fillcolor": "blue"}, + {"x": [10000, 0.0001], "y": [0.0001, 10000], "fill": "tozeroy", "line": {"shape": "hvh"}}, + {"x": [10000, 0.0001], "y": [0.0001, 10000], "fill": "tozeroy", "line": {"shape": "vhv"}}, + {"x": [0, 5001], "y": [5002, 0], "line": {"shape": "hv"}}, + {"x": [5002, 0], "y": [0, 5003], "line": {"shape": "vh"}}, + {"x": [0, 5000, 5001, 5002, 5003], "y": [-5000, 5000, 5000, 5000, 15000], "line": {"shape": "spline"}} ], "layout": { - "xaxis": { - "range": [ - 4990.012238820174, - 5009.888574816098 - ], - "type": "linear", - "autorange": false - }, - "yaxis": { - "range": [ - 4907.413431128477, - 5132.099078595185 - ], - "type": "linear", - "autorange": false - }, + "xaxis": {"range": [4999, 5003]}, + "yaxis": {"range": [4998, 5004]}, "shapes": [ { "type": "line", @@ -37,8 +19,7 @@ "y1": 0.0001 } ], - "height": 450, - "width": 1000, - "autosize": true + "height": 400, + "width": 600 } } diff --git a/test/jasmine/assets/custom_matchers.js b/test/jasmine/assets/custom_matchers.js index c8aaf5c6602..b29f7be4f88 100644 --- a/test/jasmine/assets/custom_matchers.js +++ b/test/jasmine/assets/custom_matchers.js @@ -127,7 +127,7 @@ var matchers = { 'to be close to', arrayToStr(expected.map(arrayToStr)), msgExtra - ].join(' '); + ].join('\n'); return { pass: passed, diff --git a/test/jasmine/tests/plots_test.js b/test/jasmine/tests/plots_test.js index efe5a92bf14..d529b7363ed 100644 --- a/test/jasmine/tests/plots_test.js +++ b/test/jasmine/tests/plots_test.js @@ -408,6 +408,19 @@ describe('Test Plots', function() { beforeEach(function(done) { gd = createGraphDiv(); Plotly.plot(gd, [{ x: [1, 2, 3], y: [2, 3, 4] }], {}).then(done); + + // hacky: simulate getting stuck with these flags due to an error + // see #2055 and commit 6a44a9a - before fixing that error, we would + // end up in an inconsistent state that prevented future Plotly.newPlot + // because _dragging and _dragged were not cleared by purge. + gd._dragging = true; + gd._dragged = true; + gd._hoverdata = true; + gd._snapshotInProgress = true; + gd._editing = true; + gd._replotPending = true; + gd._mouseDownTime = true; + gd._legendMouseDownTime = true; }); afterEach(destroyGraphDiv); @@ -416,15 +429,16 @@ describe('Test Plots', function() { var expectedKeys = [ '_ev', '_internalEv', 'on', 'once', 'removeListener', 'removeAllListeners', '_internalOn', '_internalOnce', '_removeInternalListener', - '_removeAllInternalListeners', 'emit', '_context', '_replotPending', - '_hmpixcount', '_hmlumcount', '_mouseDownTime', '_legendMouseDownTime', + '_removeAllInternalListeners', 'emit', '_context' ]; var expectedUndefined = [ 'data', 'layout', '_fullData', '_fullLayout', 'calcdata', 'framework', 'empty', 'fid', 'undoqueue', 'undonum', 'autoplay', 'changed', - '_promises', '_redrawTimer', 'firstscatter', 'hmlumcount', 'hmpixcount', - 'numboxes', '_transitionData', '_transitioning' + '_promises', '_redrawTimer', 'firstscatter', 'numboxes', + '_transitionData', '_transitioning', '_hmpixcount', '_hmlumcount', + '_dragging', '_dragged', '_hoverdata', '_snapshotInProgress', '_editing', + '_replotPending', '_mouseDownTime', '_legendMouseDownTime' ]; Plots.purge(gd); diff --git a/test/jasmine/tests/scatter_test.js b/test/jasmine/tests/scatter_test.js index 3b588f496d0..d61a2e6ce62 100644 --- a/test/jasmine/tests/scatter_test.js +++ b/test/jasmine/tests/scatter_test.js @@ -239,7 +239,7 @@ describe('Test scatter', function() { yaxis: ax, connectGaps: false, baseTolerance: 1, - linear: true, + shape: 'linear', simplify: true }; @@ -333,6 +333,74 @@ describe('Test scatter', function() { }); // TODO: test coarser decimation outside plot, and removing very near duplicates from the four of a cluster + + it('should clip extreme points without changing on-screen paths', function() { + var ptsIn = [ + // first bunch: rays going in/out in many directions + // and a few random moves within faraway sectors, that should get dropped + // for simplicity of calculation all are 45 degree multiples, but not exactly on corners + [[40, 70], [40, 1000000], [-100, 2000000], [200, 2000000], [60, 3000000], [60, 70]], + // back and forth across the diagonal + [[60, 70], [1000060, 1000070], [-2000070, -2000060], [-3000060, -3000070], [10000070, 10000060], [70, 60]], + [[70, 60], [1000000, 60], [100000, 50], [60, 50]], + [[60, 50], [1000110, -1000000], [10000100, -10000010], [50, 40]], + // back and forth across the vertical + [[50, 40], [50, -3000000], [49, -3000000], [49, 4000000], [48, 3000000], [48, -4000000], [40, -1000000], [40, 30]], + [[40, 30], [-1000000, -1000010], [-2000010, -2000000], [30, 40]], + // back and forth across the horizontal + [[30, 40], [-5000000, 40], [-900000, -500], [-1000000, 50], [1000000, 50], [-2000000, 50], [40, 50]], + [[40, 50], [-1000010, 1000100], [-2000000, 2000100], [50, 60]], + + // some paths crossing the nearby region in various ways + [[0, 3100], [-20000, -36900], [20000, -36900], [0, 3100]], + [[0, -3000], [-20000, 37000], [20000, 37000], [0, -3000]], + + // loops around the outside + [[55, 1000000], [2000000, 23], [444, -3000000], [-4000000, 432], [-22, 5000000]], + [[12, 1000000], [2000000, 1000000], [3000000, -4000000], [-5000000, -6000000], [-7000000, 8000000], [-13, 9000000]], + + // wound-unwound loop + [[55, -100000], [100000, 0], [0, 100000], [-100000, 0], [0, -100000], [-1000000, 100000], [1000000, 100000], [66, -100000]], + + // outside kitty-corner + [[1e5, 1e6], [-1e6, -1e5], [-1e6, 1e5], [1e5, -1e6], [-1e5, -1e6], [1e6, 1e5]] + ]; + + var ptsExpected = [ + [[40, 70], [40, 2100], [60, 2100], [60, 70]], + [[60, 70], [2090, 2100], [-2000, -1990], [-2000, -2000], [-1990, -2000], [2100, 2090], [70, 60]], + [[70, 60], [2100, 60], [2100, 50], [60, 50]], + [[60, 50], [2100, -1990], [2100, -2000], [2090, -2000], [50, 40]], + [[50, 40], [50, -2000], [49, -2000], [49, 2100], [48, 2100], [48, -2000], [40, -2000], [40, 30]], + [[40, 30], [-1990, -2000], [-2000, -2000], [-2000, -1990], [30, 40]], + [[30, 40], [-2000, 40], [-2000, 50], [2100, 50], [-2000, 50], [40, 50]], + [[40, 50], [-2000, 2090], [-2000, 2100], [-1990, 2100], [50, 60]], + + [[0, 2100], [-500, 2100], [-2000, -900], [-2000, -2000], [2100, -2000], [2100, -1100], [500, 2100], [0, 2100]], + [[0, -2000], [-500, -2000], [-2000, 1000], [-2000, 2100], [2100, 2100], [2100, 1200], [500, -2000], [0, -2000]], + + [[55, 2100], [2100, 2100], [2100, -2000], [-2000, -2000], [-2000, 2100], [-22, 2100]], + [[12, 2100], [2100, 2100], [2100, -2000], [-2000, -2000], [-2000, 2100], [-13, 2100]], + + [[55, -2000], [66, -2000]], + + [[2100, 2100], [-2000, 2100], [-2000, -2000], [2100, -2000], [2100, 2100]] + ]; + + function reverseXY(v) { return [v[1], v[0]]; } + + ptsIn.forEach(function(ptsIni, i) { + // disable clustering for these tests + var ptsOut = callLinePoints(ptsIni, {simplify: false}); + expect(ptsOut.length).toBe(1, i); + expect(ptsOut[0]).toBeCloseTo2DArray(ptsExpected[i], 1, i); + + // swap X and Y and all should work identically + var ptsOut2 = callLinePoints(ptsIni.map(reverseXY), {simplify: false}); + expect(ptsOut2.length).toBe(1, i); + expect(ptsOut2[0]).toBeCloseTo2DArray(ptsExpected[i].map(reverseXY), 1, i); + }); + }); }); });