Skip to content

Commit 3130d1c

Browse files
committed
Algorithm to clip huge line points to a merely large box
1 parent a1a68fb commit 3130d1c

File tree

5 files changed

+257
-12
lines changed

5 files changed

+257
-12
lines changed

src/traces/scatter/line_points.js

Lines changed: 177 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
'use strict';
1111

1212
var BADNUM = require('../../constants/numerical').BADNUM;
13+
var segmentsIntersect = require('../../lib/geometry2d').segmentsIntersect;
1314

1415

1516
module.exports = function linePoints(d, opts) {
@@ -62,8 +63,8 @@ module.exports = function linePoints(d, opts) {
6263

6364
// if we're off-screen, increase tolerance over baseTolerance
6465
function getTolerance(pt) {
65-
var xFrac = pt[0] / xa._length,
66-
yFrac = pt[1] / ya._length;
66+
var xFrac = pt[0] / xa._length;
67+
var yFrac = pt[1] / ya._length;
6768
return (1 + 10 * Math.max(0, -xFrac, xFrac - 1, -yFrac, yFrac - 1)) * baseTolerance;
6869
}
6970

@@ -73,13 +74,176 @@ module.exports = function linePoints(d, opts) {
7374
return Math.sqrt(dx * dx + dy * dy);
7475
}
7576

77+
// last bit of filtering: clip paths that are VERY far off-screen
78+
// so we don't get near the browser's hard limit (+/- 2^29 px in Chrome and FF)
79+
80+
// maximum multiple of the screen size to use
81+
var maxScreensAway = 20;
82+
83+
// find the intersections between the segment from pt1 to pt2
84+
// and the large rectangle maxScreensAway around the viewport
85+
// if one of pt1 and pt2 is inside and the other outside, there
86+
// will be only one intersection.
87+
// if both are outside there will be 0 or 2 intersections
88+
// (or 1 if it's right at a corner - we'll treat that like 0)
89+
// returns an array of intersection pts
90+
var xEdge0 = -xa._length * maxScreensAway;
91+
var xEdge1 = xa._length * (1 + maxScreensAway);
92+
var yEdge0 = -ya._length * maxScreensAway;
93+
var yEdge1 = ya._length * (1 + maxScreensAway);
94+
var edges = [
95+
[xEdge0, yEdge0, xEdge1, yEdge0],
96+
[xEdge1, yEdge0, xEdge1, yEdge1],
97+
[xEdge1, yEdge1, xEdge0, yEdge1],
98+
[xEdge0, yEdge1, xEdge0, yEdge0]
99+
];
100+
var xEdge, yEdge, lastXEdge, lastYEdge, lastFarPt, edgePt;
101+
102+
function getEdgeIntersections(pt1, pt2) {
103+
var out = [];
104+
var ptCount = 0;
105+
for(var i = 0; i < 4; i++) {
106+
var edge = edges[i];
107+
var ptInt = segmentsIntersect(pt1[0], pt1[1], pt2[0], pt2[1],
108+
edge[0], edge[1], edge[2], edge[3]);
109+
if(ptInt && (!ptCount ||
110+
Math.abs(ptInt.x - out[0][0]) > 1 ||
111+
Math.abs(ptInt.y - out[0][1]) > 1
112+
)) {
113+
ptInt = [ptInt.x, ptInt.y];
114+
// if we have 2 intersections, make sure the closest one to pt1 comes first
115+
if(ptCount && ptDist(ptInt, pt1) < ptDist(out[0], pt1)) out.unshift(ptInt);
116+
else out.push(ptInt);
117+
ptCount++;
118+
}
119+
}
120+
return out;
121+
}
122+
123+
// a segment pt1->pt2 entirely outside the nearby region:
124+
// find the corner it gets closest to touching
125+
function getClosestCorner(pt1, pt2) {
126+
var dx = pt2[0] - pt1[0];
127+
var m = (pt2[1] - pt1[1]) / dx;
128+
var b = (pt1[1] * pt2[0] - pt2[1] * pt1[0]) / dx;
129+
130+
if(b > 0) return [m > 0 ? xEdge0 : xEdge1, yEdge1];
131+
else return [m > 0 ? xEdge1 : xEdge0, yEdge0];
132+
}
133+
134+
function updateEdge(pt) {
135+
var x = pt[0];
136+
var y = pt[1];
137+
var xSame = x === pts[pti - 1][0];
138+
var ySame = y === pts[pti - 1][1];
139+
// duplicate point?
140+
if(xSame && ySame) return;
141+
if(pti > 1) {
142+
// backtracking along an edge?
143+
var xSame2 = x === pts[pti - 2][0];
144+
var ySame2 = y === pts[pti - 2][1];
145+
if(xSame && (x === xEdge0 || x === xEdge1) && xSame2) {
146+
if(ySame2) pti--; // backtracking exactly - drop prev pt and don't add
147+
else pts[pti - 1] = pt; // not exact: replace the prev pt
148+
}
149+
else if(ySame && (y === yEdge0 || y === yEdge1) && ySame2) {
150+
if(xSame2) pti--;
151+
else pts[pti - 1] = pt;
152+
}
153+
else pts[pti++] = pt;
154+
}
155+
else pts[pti++] = pt;
156+
}
157+
158+
function updateEdgesForReentry(pt) {
159+
// if we're outside the nearby region and going back in,
160+
// we may need to loop around a corner point
161+
if(pts[pti - 1][0] !== pt[0] && pts[pti - 1][1] !== pt[1]) {
162+
updateEdge([lastXEdge, lastYEdge]);
163+
}
164+
updateEdge(pt);
165+
lastFarPt = null;
166+
lastXEdge = lastYEdge = 0;
167+
}
168+
169+
function addPt(pt) {
170+
// Are we more than maxScreensAway off-screen any direction?
171+
// if so, clip to this box, but in such a way that on-screen
172+
// drawing is unchanged
173+
xEdge = (pt[0] < xEdge0) ? xEdge0 : (pt[0] > xEdge1) ? xEdge1 : 0;
174+
yEdge = (pt[1] < yEdge0) ? yEdge0 : (pt[1] > yEdge1) ? yEdge1 : 0;
175+
if(xEdge || yEdge) {
176+
// to get fills right - if first point is far, push it toward the
177+
// screen in whichever direction(s) are far
178+
if(!pti) {
179+
pts[pti++] = [xEdge || pt[0], yEdge || pt[1]];
180+
}
181+
else if(lastFarPt) {
182+
// both this point and the last are outside the nearby region
183+
// check if we're crossing the nearby region
184+
var intersections = getEdgeIntersections(lastFarPt, pt);
185+
if(intersections.length > 1) {
186+
updateEdgesForReentry(intersections[0]);
187+
pts[pti++] = intersections[1];
188+
}
189+
}
190+
// we're leaving the nearby region - add the point where we left it
191+
else {
192+
edgePt = getEdgeIntersections(pts[pti - 1], pt)[0];
193+
pts[pti++] = edgePt;
194+
}
195+
196+
var lastPt = pts[pti - 1];
197+
if(xEdge && yEdge && (lastPt[0] !== xEdge || lastPt[1] !== yEdge)) {
198+
// we've gone out beyond a new corner: add the corner too
199+
// so that the next point will take the right winding
200+
if(lastFarPt) {
201+
if(lastXEdge !== xEdge && lastYEdge !== yEdge) {
202+
if(lastXEdge && lastYEdge) {
203+
// we've gone around to an opposite corner - we
204+
// need to add the correct extra corner
205+
// in order to get the right winding
206+
updateEdge(getClosestCorner(lastFarPt, pt));
207+
}
208+
else {
209+
// we're coming from a far edge - the extra corner
210+
// we need is determined uniquely by the sectors
211+
updateEdge([lastXEdge || xEdge, lastYEdge || yEdge]);
212+
}
213+
}
214+
else if(lastXEdge && lastYEdge) {
215+
updateEdge([lastXEdge, lastYEdge]);
216+
}
217+
}
218+
updateEdge([xEdge, yEdge]);
219+
}
220+
else if((lastXEdge - xEdge) && (lastYEdge - yEdge)) {
221+
// we're coming from an edge or far corner to an edge - again the
222+
// extra corner we need is uniquely determined by the sectors
223+
updateEdge([xEdge || lastXEdge, yEdge || lastYEdge]);
224+
}
225+
lastFarPt = pt;
226+
lastXEdge = xEdge;
227+
lastYEdge = yEdge;
228+
}
229+
else {
230+
if(lastFarPt) {
231+
// this point is in range but the previous wasn't: add its entry pt first
232+
updateEdgesForReentry(getEdgeIntersections(lastFarPt, pt)[0]);
233+
}
234+
235+
pts[pti++] = pt;
236+
}
237+
}
238+
76239
// loop over ALL points in this trace
77240
for(i = 0; i < d.length; i++) {
78241
clusterStartPt = getPt(i);
79242
if(!clusterStartPt) continue;
80243

81244
pti = 0;
82-
pts[pti++] = clusterStartPt;
245+
lastFarPt = null;
246+
addPt(clusterStartPt);
83247

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

@@ -147,23 +311,26 @@ module.exports = function linePoints(d, opts) {
147311
// insert this cluster into pts
148312
// we've already inserted the start pt, now check if we have high and low pts
149313
if(clusterHighFirst) {
150-
pts[pti++] = clusterHighPt;
151-
if(clusterEndPt !== clusterLowPt) pts[pti++] = clusterLowPt;
314+
addPt(clusterHighPt);
315+
if(clusterEndPt !== clusterLowPt) addPt(clusterLowPt);
152316
} else {
153-
if(clusterLowPt !== clusterStartPt) pts[pti++] = clusterLowPt;
154-
if(clusterEndPt !== clusterHighPt) pts[pti++] = clusterHighPt;
317+
if(clusterLowPt !== clusterStartPt) addPt(clusterLowPt);
318+
if(clusterEndPt !== clusterHighPt) addPt(clusterHighPt);
155319
}
156320
// and finally insert the end pt
157-
pts[pti++] = clusterEndPt;
321+
addPt(clusterEndPt);
158322

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

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

331+
// to get fills right - repeat what we did at the start
332+
if(lastFarPt) updateEdge([lastXEdge || lastFarPt[0], lastYEdge || lastFarPt[1]]);
333+
167334
segments.push(pts.slice(0, pti));
168335
}
169336

79 Bytes
Loading

test/image/mocks/axes_range_manual.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
1,
3232
2,
3333
3,
34+
1000005,
35+
-1e100,
36+
3,
37+
4,
3438
4,
3539
5,
3640
6,
@@ -42,13 +46,18 @@
4246
1,
4347
2,
4448
3,
49+
-3000000,
50+
3e100,
51+
1e12,
52+
-1e11,
4553
4,
4654
5,
4755
6,
4856
7,
4957
8
5058
],
51-
"type": "scatter"
59+
"type": "scatter",
60+
"mode": "lines"
5261
}
5362
],
5463
"layout": {

test/jasmine/assets/custom_matchers.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ var matchers = {
127127
'to be close to',
128128
arrayToStr(expected.map(arrayToStr)),
129129
msgExtra
130-
].join(' ');
130+
].join('\n');
131131

132132
return {
133133
pass: passed,

test/jasmine/tests/scatter_test.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,75 @@ describe('Test scatter', function() {
333333
});
334334

335335
// TODO: test coarser decimation outside plot, and removing very near duplicates from the four of a cluster
336+
337+
it('should clip extreme points without changing on-screen paths', function() {
338+
// TODO
339+
var ptsIn = [
340+
// first bunch: rays going in/out in many directions
341+
// and a few random moves within faraway sectors, that should get dropped
342+
// for simplicity of calculation all are 45 degree multiples, but not exactly on corners
343+
[[40, 70], [40, 1000000], [-100, 2000000], [200, 2000000], [60, 3000000], [60, 70]],
344+
// back and forth across the diagonal
345+
[[60, 70], [1000060, 1000070], [-2000070, -2000060], [-3000060, -3000070], [10000070, 10000060], [70, 60]],
346+
[[70, 60], [1000000, 60], [100000, 50], [60, 50]],
347+
[[60, 50], [1000110, -1000000], [10000100, -10000010], [50, 40]],
348+
// back and forth across the vertical
349+
[[50, 40], [50, -3000000], [49, -3000000], [49, 4000000], [48, 3000000], [48, -4000000], [40, -1000000], [40, 30]],
350+
[[40, 30], [-1000000, -1000010], [-2000010, -2000000], [30, 40]],
351+
// back and forth across the horizontal
352+
[[30, 40], [-5000000, 40], [-900000, -500], [-1000000, 50], [1000000, 50], [-2000000, 50], [40, 50]],
353+
[[40, 50], [-1000010, 1000100], [-2000000, 2000100], [50, 60]],
354+
355+
// some paths crossing the nearby region in various ways
356+
[[0, 3100], [-20000, -36900], [20000, -36900], [0, 3100]],
357+
[[0, -3000], [-20000, 37000], [20000, 37000], [0, -3000]],
358+
359+
// loops around the outside
360+
[[55, 1000000], [2000000, 23], [444, -3000000], [-4000000, 432], [-22, 5000000]],
361+
[[12, 1000000], [2000000, 1000000], [3000000, -4000000], [-5000000, -6000000], [-7000000, 8000000], [-13, 9000000]],
362+
363+
// wound-unwound loop
364+
[[55, -100000], [100000, 0], [0, 100000], [-100000, 0], [0, -100000], [-1000000, 100000], [1000000, 100000], [66, -100000]],
365+
366+
// outside kitty-corner
367+
[[1e5, 1e6], [-1e6, -1e5], [-1e6, 1e5], [1e5, -1e6], [-1e5, -1e6], [1e6, 1e5]]
368+
];
369+
370+
var ptsExpected = [
371+
[[40, 70], [40, 2100], [60, 2100], [60, 70]],
372+
[[60, 70], [2090, 2100], [-2000, -1990], [-2000, -2000], [-1990, -2000], [2100, 2090], [70, 60]],
373+
[[70, 60], [2100, 60], [2100, 50], [60, 50]],
374+
[[60, 50], [2100, -1990], [2100, -2000], [2090, -2000], [50, 40]],
375+
[[50, 40], [50, -2000], [49, -2000], [49, 2100], [48, 2100], [48, -2000], [40, -2000], [40, 30]],
376+
[[40, 30], [-1990, -2000], [-2000, -2000], [-2000, -1990], [30, 40]],
377+
[[30, 40], [-2000, 40], [-2000, 50], [2100, 50], [-2000, 50], [40, 50]],
378+
[[40, 50], [-2000, 2090], [-2000, 2100], [-1990, 2100], [50, 60]],
379+
380+
[[0, 2100], [-500, 2100], [-2000, -900], [-2000, -2000], [2100, -2000], [2100, -1100], [500, 2100], [0, 2100]],
381+
[[0, -2000], [-500, -2000], [-2000, 1000], [-2000, 2100], [2100, 2100], [2100, 1200], [500, -2000], [0, -2000]],
382+
383+
[[55, 2100], [2100, 2100], [2100, -2000], [-2000, -2000], [-2000, 2100], [-22, 2100]],
384+
[[12, 2100], [2100, 2100], [2100, -2000], [-2000, -2000], [-2000, 2100], [-13, 2100]],
385+
386+
[[55, -2000], [66, -2000]],
387+
388+
[[2100, 2100], [-2000, 2100], [-2000, -2000], [2100, -2000], [2100, 2100]]
389+
];
390+
391+
function reverseXY(v) { return [v[1], v[0]]; }
392+
393+
ptsIn.forEach(function(ptsIni, i) {
394+
// baseTolerance: disable clustering for this test
395+
var ptsOut = callLinePoints(ptsIni, {baseTolerance: 0});
396+
expect(ptsOut.length).toBe(1, i);
397+
expect(ptsOut[0]).toBeCloseTo2DArray(ptsExpected[i], 1, i);
398+
399+
// swap X and Y and all should work identically
400+
var ptsOut2 = callLinePoints(ptsIni.map(reverseXY), {baseTolerance: 0});
401+
expect(ptsOut2.length).toBe(1, i);
402+
expect(ptsOut2[0]).toBeCloseTo2DArray(ptsExpected[i].map(reverseXY), 1, i);
403+
});
404+
});
336405
});
337406

338407
});

0 commit comments

Comments
 (0)