diff --git a/draftlogs/7905_fix.md b/draftlogs/7905_fix.md new file mode 100644 index 00000000000..dfb50e92628 --- /dev/null +++ b/draftlogs/7905_fix.md @@ -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! diff --git a/src/plots/map/map.js b/src/plots/map/map.js index 9a397743482..2172f13b9b0 100644 --- a/src/plots/map/map.js +++ b/src/plots/map/map.js @@ -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); @@ -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 diff --git a/src/traces/scattermap/select.js b/src/traces/scattermap/select.js index 4bf52b8e69c..0cf02882325 100644 --- a/src/traces/scattermap/select.js +++ b/src/traces/scattermap/select.js @@ -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; } } diff --git a/test/jasmine/tests/select_test.js b/test/jasmine/tests/select_test.js index 2261a72b24b..9eb41b49ad7 100644 --- a/test/jasmine/tests/select_test.js +++ b/test/jasmine/tests/select_test.js @@ -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']);