Skip to content

Commit 0af79cc

Browse files
authored
Merge pull request #7905 from coyaSONG/fix/7904-antimeridian-selection
fix: Fix scattermap selection across antimeridian
2 parents 9247de4 + 99db750 commit 0af79cc

4 files changed

Lines changed: 141 additions & 37 deletions

File tree

draftlogs/7905_fix.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Fix `scattermap` box and lasso selection across the antimeridian [[#7905](https://github.com/plotly/plotly.js/pull/7905)], with thanks to @coyaSONG for the contribution!

src/plots/map/map.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -390,11 +390,13 @@ proto.createFramework = function(fullLayout) {
390390
// create mock x/y axes for hover routine
391391
self.xaxis = {
392392
_id: 'x',
393-
c2p: function(v) { return self.project(v).x; }
393+
c2p: function(v) { return self.project(v).x; },
394+
_subplot: self
394395
};
395396
self.yaxis = {
396397
_id: 'y',
397-
c2p: function(v) { return self.project(v).y; }
398+
c2p: function(v) { return self.project(v).y; },
399+
_subplot: self
398400
};
399401

400402
self.updateFramework(fullLayout);
@@ -726,8 +728,8 @@ proto.addLayer = function(opts, below) {
726728
};
727729

728730
// convenience method to project a [lon, lat] array to pixel coords
729-
proto.project = function(v) {
730-
return this.map.project(new maplibregl.LngLat(v[0], v[1]));
731+
proto.project = function ([lon, lat]) {
732+
return this.map.project(new maplibregl.LngLat(lon, lat));
731733
};
732734

733735
// get map's current view values in plotly.js notation

src/traces/scattermap/select.js

Lines changed: 53 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,63 @@
11
'use strict';
22

3-
var Lib = require('../../lib');
4-
var subtypes = require('../scatter/subtypes');
5-
var BADNUM = require('../../constants/numerical').BADNUM;
3+
const Lib = require('../../lib');
4+
const subtypes = require('../scatter/subtypes');
5+
const { BADNUM } = require('../../constants/numerical');
66

77
module.exports = function selectPoints(searchInfo, selectionTester) {
8-
var cd = searchInfo.cd;
9-
var xa = searchInfo.xaxis;
10-
var ya = searchInfo.yaxis;
11-
var selection = [];
12-
var trace = cd[0].trace;
13-
var i;
14-
15-
if(!subtypes.hasMarkers(trace)) return [];
16-
17-
if(selectionTester === false) {
18-
for(i = 0; i < cd.length; i++) {
19-
cd[i].selected = 0;
8+
const { cd, xaxis: xa, yaxis: ya } = searchInfo;
9+
const { trace } = cd[0];
10+
11+
if (!subtypes.hasMarkers(trace)) return [];
12+
13+
if (selectionTester === false) {
14+
for (const di of cd) {
15+
di.selected = 0;
2016
}
21-
} else {
22-
for(i = 0; i < cd.length; i++) {
23-
var di = cd[i];
24-
var lonlat = di.lonlat;
25-
26-
if(lonlat[0] !== BADNUM) {
27-
var lonlat2 = [Lib.modHalf(lonlat[0], 360), lonlat[1]];
28-
var xy = [xa.c2p(lonlat2), ya.c2p(lonlat2)];
29-
30-
if(selectionTester.contains(xy, null, i, searchInfo)) {
31-
selection.push({
32-
pointNumber: i,
33-
lon: lonlat[0],
34-
lat: lonlat[1]
35-
});
36-
di.selected = 1;
37-
} else {
38-
di.selected = 0;
17+
return [];
18+
}
19+
20+
// MapLibre renders repeated copies of the world when renderWorldCopies is
21+
// enabled, so a point can appear in the selection at any integer world
22+
// offset from its primary projection. Iterate offsets that fall within
23+
// the selection tester's x-extent to hit-test every visible copy. Skip
24+
// for degenerate testers (e.g. point-selection) where extent is zero.
25+
const map = xa._subplot?.map;
26+
const worldWidth =
27+
map?.getRenderWorldCopies() && selectionTester.xmax > selectionTester.xmin ? map.transform.worldSize : 0;
28+
29+
const selection = [];
30+
31+
for (let i = 0; i < cd.length; i++) {
32+
const di = cd[i];
33+
const [lon, lat] = di.lonlat;
34+
35+
if (lon === BADNUM) continue;
36+
37+
// Normalize lon to [-180, 180] so its projection lands on the primary world copy
38+
const normalizedLonlat = [Lib.modHalf(lon, 360), lat];
39+
const baseX = xa.c2p(normalizedLonlat);
40+
const baseY = ya.c2p(normalizedLonlat);
41+
let matched = false;
42+
43+
if (worldWidth) {
44+
const kMin = Math.floor((selectionTester.xmin - baseX) / worldWidth);
45+
const kMax = Math.ceil((selectionTester.xmax - baseX) / worldWidth);
46+
for (let k = kMin; k <= kMax; k++) {
47+
if (selectionTester.contains([baseX + k * worldWidth, baseY], null, i, searchInfo)) {
48+
matched = true;
49+
break;
3950
}
4051
}
52+
} else {
53+
matched = selectionTester.contains([baseX, baseY], null, i, searchInfo);
54+
}
55+
56+
if (matched) {
57+
selection.push({ pointNumber: i, lon, lat });
58+
di.selected = 1;
59+
} else {
60+
di.selected = 0;
4161
}
4262
}
4363

test/jasmine/tests/select_test.js

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2276,6 +2276,87 @@ describe('Test select box and lasso per trace:', function() {
22762276
}, LONG_TIMEOUT_INTERVAL);
22772277
});
22782278

2279+
it('@gl should select scattermap points across the antimeridian', function(done) {
2280+
var lons = [174.76, 178.44, 179.9, -176.2, -171.77, -175.2];
2281+
var lats = [-36.85, -18.14, -16.5, -13.3, -13.83, -21.14];
2282+
var expectedPoints = lons.map(function(lon, i) { return [lon, lats[i]]; });
2283+
var assertPoints = makeAssertPoints(['lon', 'lat']);
2284+
var assertSelectedPoints = makeAssertSelectedPoints();
2285+
var boxPath;
2286+
var lassoPath;
2287+
2288+
var fig = {
2289+
data: [{
2290+
type: 'scattermap',
2291+
mode: 'markers',
2292+
lon: lons,
2293+
lat: lats,
2294+
marker: {size: 10}
2295+
}],
2296+
layout: {
2297+
dragmode: 'select',
2298+
width: 900,
2299+
height: 600,
2300+
map: {
2301+
center: {lon: 180, lat: -24},
2302+
style: 'white-bg',
2303+
zoom: 3
2304+
}
2305+
},
2306+
config: {}
2307+
};
2308+
2309+
_newPlot(gd, fig)
2310+
.then(function() {
2311+
var subplot = gd._fullLayout.map._subplot;
2312+
var points = lons.map(function(lon, i) {
2313+
var unwrappedLon = lon < 0 ? lon + 360 : lon;
2314+
var pt = subplot.map.project([unwrappedLon, lats[i]]);
2315+
return [pt.x + subplot.xaxis._offset, pt.y + subplot.yaxis._offset];
2316+
});
2317+
var xs = points.map(function(pt) { return pt[0]; });
2318+
var ys = points.map(function(pt) { return pt[1]; });
2319+
var x0 = Math.min.apply(null, xs) - 10;
2320+
var x1 = Math.max.apply(null, xs) + 10;
2321+
var y0 = Math.min.apply(null, ys) - 10;
2322+
var y1 = Math.max.apply(null, ys) + 10;
2323+
2324+
boxPath = [[x0, y0], [x1, y1]];
2325+
lassoPath = [[x0, y0], [x0, y1], [x1, y1], [x1, y0], [x0, y0]];
2326+
2327+
return _run(false, boxPath,
2328+
function() {
2329+
assertPoints(expectedPoints);
2330+
assertSelectedPoints({0: [0, 1, 2, 3, 4, 5]});
2331+
2332+
var range = selectedData.range.map;
2333+
expect(range[1][0]).toBeGreaterThan(range[0][0], 'continuous longitude range');
2334+
expect(range[1][0]).toBeGreaterThan(180, 'east edge is unwrapped past 180');
2335+
},
2336+
null, BOXEVENTS, 'scattermap antimeridian select'
2337+
);
2338+
})
2339+
.then(function() {
2340+
return Plotly.relayout(gd, 'dragmode', 'lasso');
2341+
})
2342+
.then(function() {
2343+
return _run(false, lassoPath,
2344+
function() {
2345+
assertPoints(expectedPoints);
2346+
assertSelectedPoints({0: [0, 1, 2, 3, 4, 5]});
2347+
2348+
var lassoPoints = selectedData.lassoPoints.map;
2349+
for(var i = 1; i < lassoPoints.length; i++) {
2350+
expect(Math.abs(lassoPoints[i][0] - lassoPoints[i - 1][0]))
2351+
.toBeLessThan(180, 'continuous lasso longitude');
2352+
}
2353+
},
2354+
null, LASSOEVENTS, 'scattermap antimeridian lasso'
2355+
);
2356+
})
2357+
.then(done, done.fail);
2358+
}, LONG_TIMEOUT_INTERVAL);
2359+
22792360
[false, true].forEach(function(hasCssTransform) {
22802361
it('@gl should work on choroplethmap traces, hasCssTransform: ' + hasCssTransform, function(done) {
22812362
var assertPoints = makeAssertPoints(['location', 'z']);

0 commit comments

Comments
 (0)