Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions draftlogs/7905_fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +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!
10 changes: 6 additions & 4 deletions src/plots/map/map.js
Original file line number Diff line number Diff line change
Expand Up @@ -390,11 +390,13 @@ proto.createFramework = function(fullLayout) {
// create mock x/y axes for hover routine
self.xaxis = {
_id: 'x',
c2p: function(v) { return self.project(v).x; }
c2p: function(v) { return self.project(v).x; },
_subplot: self
};
self.yaxis = {
_id: 'y',
c2p: function(v) { return self.project(v).y; }
c2p: function(v) { return self.project(v).y; },
_subplot: self
};

self.updateFramework(fullLayout);
Expand Down Expand Up @@ -726,8 +728,8 @@ proto.addLayer = function(opts, below) {
};

// convenience method to project a [lon, lat] array to pixel coords
proto.project = function(v) {
return this.map.project(new maplibregl.LngLat(v[0], v[1]));
proto.project = function ([lon, lat]) {
return this.map.project(new maplibregl.LngLat(lon, lat));
};

// get map's current view values in plotly.js notation
Expand Down
86 changes: 53 additions & 33 deletions src/traces/scattermap/select.js
Original file line number Diff line number Diff line change
@@ -1,43 +1,63 @@
'use strict';

var Lib = require('../../lib');
var subtypes = require('../scatter/subtypes');
var BADNUM = require('../../constants/numerical').BADNUM;
const Lib = require('../../lib');
const subtypes = require('../scatter/subtypes');
const { BADNUM } = require('../../constants/numerical');

module.exports = function selectPoints(searchInfo, selectionTester) {
var cd = searchInfo.cd;
var xa = searchInfo.xaxis;
var ya = searchInfo.yaxis;
var selection = [];
var trace = cd[0].trace;
var i;

if(!subtypes.hasMarkers(trace)) return [];

if(selectionTester === false) {
for(i = 0; i < cd.length; i++) {
cd[i].selected = 0;
const { cd, xaxis: xa, yaxis: ya } = searchInfo;
const { trace } = cd[0];

if (!subtypes.hasMarkers(trace)) return [];

if (selectionTester === false) {
for (const di of cd) {
di.selected = 0;
}
} else {
for(i = 0; i < cd.length; i++) {
var di = cd[i];
var lonlat = di.lonlat;

if(lonlat[0] !== BADNUM) {
var lonlat2 = [Lib.modHalf(lonlat[0], 360), lonlat[1]];
var xy = [xa.c2p(lonlat2), ya.c2p(lonlat2)];

if(selectionTester.contains(xy, null, i, searchInfo)) {
selection.push({
pointNumber: i,
lon: lonlat[0],
lat: lonlat[1]
});
di.selected = 1;
} else {
di.selected = 0;
return [];
}

// MapLibre renders repeated copies of the world when renderWorldCopies is
// enabled, so a point can appear in the selection at any integer world
// offset from its primary projection. Iterate offsets that fall within
// the selection tester's x-extent to hit-test every visible copy. Skip
// for degenerate testers (e.g. point-selection) where extent is zero.
const map = xa._subplot?.map;
const worldWidth =
map?.getRenderWorldCopies() && selectionTester.xmax > selectionTester.xmin ? map.transform.worldSize : 0;

const selection = [];

for (let i = 0; i < cd.length; i++) {
const di = cd[i];
const [lon, lat] = di.lonlat;

if (lon === BADNUM) continue;

// Normalize lon to [-180, 180] so its projection lands on the primary world copy
const normalizedLonlat = [Lib.modHalf(lon, 360), lat];
const baseX = xa.c2p(normalizedLonlat);
const baseY = ya.c2p(normalizedLonlat);
let matched = false;

if (worldWidth) {
const kMin = Math.floor((selectionTester.xmin - baseX) / worldWidth);
const kMax = Math.ceil((selectionTester.xmax - baseX) / worldWidth);
for (let k = kMin; k <= kMax; k++) {
if (selectionTester.contains([baseX + k * worldWidth, baseY], null, i, searchInfo)) {
matched = true;
break;
}
}
} else {
matched = selectionTester.contains([baseX, baseY], null, i, searchInfo);
}

if (matched) {
selection.push({ pointNumber: i, lon, lat });
di.selected = 1;
} else {
di.selected = 0;
}
}

Expand Down
81 changes: 81 additions & 0 deletions test/jasmine/tests/select_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2276,6 +2276,87 @@ describe('Test select box and lasso per trace:', function() {
}, LONG_TIMEOUT_INTERVAL);
});

it('@gl should select scattermap points across the antimeridian', function(done) {
var lons = [174.76, 178.44, 179.9, -176.2, -171.77, -175.2];
var lats = [-36.85, -18.14, -16.5, -13.3, -13.83, -21.14];
var expectedPoints = lons.map(function(lon, i) { return [lon, lats[i]]; });
var assertPoints = makeAssertPoints(['lon', 'lat']);
var assertSelectedPoints = makeAssertSelectedPoints();
var boxPath;
var lassoPath;

var fig = {
data: [{
type: 'scattermap',
mode: 'markers',
lon: lons,
lat: lats,
marker: {size: 10}
}],
layout: {
dragmode: 'select',
width: 900,
height: 600,
map: {
center: {lon: 180, lat: -24},
style: 'white-bg',
zoom: 3
}
},
config: {}
};

_newPlot(gd, fig)
.then(function() {
var subplot = gd._fullLayout.map._subplot;
var points = lons.map(function(lon, i) {
var unwrappedLon = lon < 0 ? lon + 360 : lon;
var pt = subplot.map.project([unwrappedLon, lats[i]]);
return [pt.x + subplot.xaxis._offset, pt.y + subplot.yaxis._offset];
});
var xs = points.map(function(pt) { return pt[0]; });
var ys = points.map(function(pt) { return pt[1]; });
var x0 = Math.min.apply(null, xs) - 10;
var x1 = Math.max.apply(null, xs) + 10;
var y0 = Math.min.apply(null, ys) - 10;
var y1 = Math.max.apply(null, ys) + 10;

boxPath = [[x0, y0], [x1, y1]];
lassoPath = [[x0, y0], [x0, y1], [x1, y1], [x1, y0], [x0, y0]];

return _run(false, boxPath,
function() {
assertPoints(expectedPoints);
assertSelectedPoints({0: [0, 1, 2, 3, 4, 5]});

var range = selectedData.range.map;
expect(range[1][0]).toBeGreaterThan(range[0][0], 'continuous longitude range');
expect(range[1][0]).toBeGreaterThan(180, 'east edge is unwrapped past 180');
},
null, BOXEVENTS, 'scattermap antimeridian select'
);
})
.then(function() {
return Plotly.relayout(gd, 'dragmode', 'lasso');
})
.then(function() {
return _run(false, lassoPath,
function() {
assertPoints(expectedPoints);
assertSelectedPoints({0: [0, 1, 2, 3, 4, 5]});

var lassoPoints = selectedData.lassoPoints.map;
for(var i = 1; i < lassoPoints.length; i++) {
expect(Math.abs(lassoPoints[i][0] - lassoPoints[i - 1][0]))
.toBeLessThan(180, 'continuous lasso longitude');
}
},
null, LASSOEVENTS, 'scattermap antimeridian lasso'
);
})
.then(done, done.fail);
}, LONG_TIMEOUT_INTERVAL);

[false, true].forEach(function(hasCssTransform) {
it('@gl should work on choroplethmap traces, hasCssTransform: ' + hasCssTransform, function(done) {
var assertPoints = makeAssertPoints(['location', 'z']);
Expand Down