From 4eed12e12572d7af0d0d550f23499120e242fe14 Mon Sep 17 00:00:00 2001 From: coyaSONG <66289470+coyaSONG@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:13:21 +0900 Subject: [PATCH 1/6] Fix scattermap selection across antimeridian --- src/plots/map/map.js | 10 +++- test/jasmine/tests/select_test.js | 81 +++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/plots/map/map.js b/src/plots/map/map.js index 9a397743482..a9c6dcc406f 100644 --- a/src/plots/map/map.js +++ b/src/plots/map/map.js @@ -727,7 +727,15 @@ 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])); + var map = this.map; + var lon = v[0]; + + if(map.getRenderWorldCopies()) { + var centerLon = map.getCenter().lng; + lon = centerLon + Lib.modHalf(lon - centerLon, 360); + } + + return map.project(new maplibregl.LngLat(lon, v[1])); }; // get map's current view values in plotly.js notation 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']); From 51409cec0186da9b85ccc4ec9fa2e1ed1fea74cf Mon Sep 17 00:00:00 2001 From: coyaSONG <66289470+coyaSONG@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:16:03 +0900 Subject: [PATCH 2/6] Add draftlog for antimeridian selection fix --- draftlogs/7905_fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 draftlogs/7905_fix.md diff --git a/draftlogs/7905_fix.md b/draftlogs/7905_fix.md new file mode 100644 index 00000000000..643239a3304 --- /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)] From 1f7adc3c366e546d642a75c5b4072784a02aa588 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Wed, 22 Jul 2026 14:38:58 -0600 Subject: [PATCH 3/6] Refactor helper function --- src/plots/map/map.js | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/plots/map/map.js b/src/plots/map/map.js index a9c6dcc406f..b5692d0c44d 100644 --- a/src/plots/map/map.js +++ b/src/plots/map/map.js @@ -726,16 +726,14 @@ proto.addLayer = function(opts, below) { }; // convenience method to project a [lon, lat] array to pixel coords -proto.project = function(v) { - var map = this.map; - var lon = v[0]; - - if(map.getRenderWorldCopies()) { - var centerLon = map.getCenter().lng; +proto.project = function ([lon, lat]) { + const map = this.map; + if (map.getRenderWorldCopies()) { + const centerLon = map.getCenter().lng; lon = centerLon + Lib.modHalf(lon - centerLon, 360); } - return map.project(new maplibregl.LngLat(lon, v[1])); + return map.project(new maplibregl.LngLat(lon, lat)); }; // get map's current view values in plotly.js notation From 5a383f25375191d48cc8d9f495dddfc9f1bf53ed Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Wed, 22 Jul 2026 14:39:56 -0600 Subject: [PATCH 4/6] Update draftlog --- draftlogs/7905_fix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/draftlogs/7905_fix.md b/draftlogs/7905_fix.md index 643239a3304..dfb50e92628 100644 --- a/draftlogs/7905_fix.md +++ b/draftlogs/7905_fix.md @@ -1 +1 @@ -- Fix `scattermap` box and lasso selection across the antimeridian [[#7905](https://github.com/plotly/plotly.js/pull/7905)] +- 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! From 2f0c6ec89c70849a2b1692364a904be2063368c7 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Wed, 22 Jul 2026 16:49:56 -0600 Subject: [PATCH 5/6] Linting/formatting --- src/traces/scattermap/select.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/traces/scattermap/select.js b/src/traces/scattermap/select.js index 4bf52b8e69c..1edee609022 100644 --- a/src/traces/scattermap/select.js +++ b/src/traces/scattermap/select.js @@ -12,22 +12,22 @@ module.exports = function selectPoints(searchInfo, selectionTester) { var trace = cd[0].trace; var i; - if(!subtypes.hasMarkers(trace)) return []; + if (!subtypes.hasMarkers(trace)) return []; - if(selectionTester === false) { - for(i = 0; i < cd.length; i++) { + if (selectionTester === false) { + for (i = 0; i < cd.length; i++) { cd[i].selected = 0; } } else { - for(i = 0; i < cd.length; i++) { + for (i = 0; i < cd.length; i++) { var di = cd[i]; var lonlat = di.lonlat; - if(lonlat[0] !== BADNUM) { + 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)) { + if (selectionTester.contains(xy, null, i, searchInfo)) { selection.push({ pointNumber: i, lon: lonlat[0], From 99db75041e56fc023fd89222980c42db8f86c4c0 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Wed, 22 Jul 2026 17:10:17 -0600 Subject: [PATCH 6/6] Move map world size testing to select --- src/plots/map/map.js | 14 +++--- src/traces/scattermap/select.js | 78 +++++++++++++++++++++------------ 2 files changed, 54 insertions(+), 38 deletions(-) diff --git a/src/plots/map/map.js b/src/plots/map/map.js index b5692d0c44d..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); @@ -727,13 +729,7 @@ proto.addLayer = function(opts, below) { // convenience method to project a [lon, lat] array to pixel coords proto.project = function ([lon, lat]) { - const map = this.map; - if (map.getRenderWorldCopies()) { - const centerLon = map.getCenter().lng; - lon = centerLon + Lib.modHalf(lon - centerLon, 360); - } - - return map.project(new maplibregl.LngLat(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 1edee609022..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; + const { cd, xaxis: xa, yaxis: ya } = searchInfo; + const { trace } = cd[0]; if (!subtypes.hasMarkers(trace)) return []; if (selectionTester === false) { - for (i = 0; i < cd.length; i++) { - cd[i].selected = 0; + 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; } }