From 93ab0ae66f8d432c8ae82e46cb5332e8f3870ea3 Mon Sep 17 00:00:00 2001 From: Branden Palmer Date: Mon, 19 Jan 2026 16:41:18 -0600 Subject: [PATCH 1/9] Add mocks for dynamic default testing --- test/image/mocks/map_dynamic_defaults.json | 15 ++++++++++++ .../map_dynamic_defaults_anti_meridian.json | 15 ++++++++++++ .../map_dynamic_defaults_anti_meridian_2.json | 15 ++++++++++++ .../map_dynamic_defaults_center_set.json | 23 +++++++++++++++++++ .../mocks/map_dynamic_defaults_zoom_set.json | 20 ++++++++++++++++ 5 files changed, 88 insertions(+) create mode 100644 test/image/mocks/map_dynamic_defaults.json create mode 100644 test/image/mocks/map_dynamic_defaults_anti_meridian.json create mode 100644 test/image/mocks/map_dynamic_defaults_anti_meridian_2.json create mode 100644 test/image/mocks/map_dynamic_defaults_center_set.json create mode 100644 test/image/mocks/map_dynamic_defaults_zoom_set.json diff --git a/test/image/mocks/map_dynamic_defaults.json b/test/image/mocks/map_dynamic_defaults.json new file mode 100644 index 00000000000..269967cd28a --- /dev/null +++ b/test/image/mocks/map_dynamic_defaults.json @@ -0,0 +1,15 @@ +{ + "data": [ + { + "hovertext": ["San Marino", "Cairo", "Istanbul", "Trondheim"], + "lat": [43.9360958, 30.06263, 41.01384, 63.43049], + "legendgroup": "", + "lon": [12.4417702, 31.24967, 28.94966, 10.39506], + "marker": { + "color": "#636efa" + }, + "mode": "markers", + "type": "scattermap" + } + ] +} diff --git a/test/image/mocks/map_dynamic_defaults_anti_meridian.json b/test/image/mocks/map_dynamic_defaults_anti_meridian.json new file mode 100644 index 00000000000..39f97bd9022 --- /dev/null +++ b/test/image/mocks/map_dynamic_defaults_anti_meridian.json @@ -0,0 +1,15 @@ +{ + "data": [ + { + "hovertext": ["P1", "P2", "P3", "P4"], + "lat": [43.9360958, 30.06263, 41.01384, 63.43049], + "legendgroup": "", + "lon": [170, 180, -180, -170], + "marker": { + "color": "#636efa" + }, + "mode": "markers", + "type": "scattermap" + } + ] +} diff --git a/test/image/mocks/map_dynamic_defaults_anti_meridian_2.json b/test/image/mocks/map_dynamic_defaults_anti_meridian_2.json new file mode 100644 index 00000000000..177b51ce3ff --- /dev/null +++ b/test/image/mocks/map_dynamic_defaults_anti_meridian_2.json @@ -0,0 +1,15 @@ +{ + "data": [ + { + "hovertext": ["P1", "P2", "P3", "P4"], + "lat": [43.9360958, 30.06263, 41.01384, 43.43049], + "legendgroup": "", + "lon": [100, 180, -180, -170], + "marker": { + "color": "#636efa" + }, + "mode": "markers", + "type": "scattermap" + } + ] +} diff --git a/test/image/mocks/map_dynamic_defaults_center_set.json b/test/image/mocks/map_dynamic_defaults_center_set.json new file mode 100644 index 00000000000..0e939ed37b3 --- /dev/null +++ b/test/image/mocks/map_dynamic_defaults_center_set.json @@ -0,0 +1,23 @@ +{ + "data": [ + { + "hovertext": ["San Marino", "Cairo", "Istanbul", "Trondheim"], + "lat": [43.9360958, 30.06263, 41.01384, 63.43049], + "legendgroup": "", + "lon": [12.4417702, 31.24967, 28.94966, 10.39506], + "marker": { + "color": "#636efa" + }, + "mode": "markers", + "type": "scattermap" + } + ], + "layout": { + "map": { + "center": { + "lat": 90, + "lon": 90 + } + } + } +} diff --git a/test/image/mocks/map_dynamic_defaults_zoom_set.json b/test/image/mocks/map_dynamic_defaults_zoom_set.json new file mode 100644 index 00000000000..af2ade74067 --- /dev/null +++ b/test/image/mocks/map_dynamic_defaults_zoom_set.json @@ -0,0 +1,20 @@ +{ + "data": [ + { + "hovertext": ["San Marino", "Cairo", "Istanbul", "Trondheim"], + "lat": [43.9360958, 30.06263, 41.01384, 63.43049], + "legendgroup": "", + "lon": [12.4417702, 31.24967, 28.94966, 10.39506], + "marker": { + "color": "#636efa" + }, + "mode": "markers", + "type": "scattermap" + } + ], + "layout": { + "map": { + "zoom": 2 + } + } +} From 4ffa90aa311aba0773feeb5e8a0898991fbfffcb Mon Sep 17 00:00:00 2001 From: Branden Palmer Date: Mon, 19 Jan 2026 16:45:25 -0600 Subject: [PATCH 2/9] Update map handleDefaults sig --- src/plots/map/layout_defaults.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/plots/map/layout_defaults.js b/src/plots/map/layout_defaults.js index 5cb2531fa1c..e3b08556277 100644 --- a/src/plots/map/layout_defaults.js +++ b/src/plots/map/layout_defaults.js @@ -12,11 +12,12 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { type: 'map', attributes: layoutAttributes, handleDefaults: handleDefaults, - partition: 'y' + partition: 'y', + fullData }); }; -function handleDefaults(containerIn, containerOut, coerce) { +function handleDefaults(containerIn, containerOut, coerce, opts) { coerce('style'); coerce('center.lon'); coerce('center.lat'); From 2a728c78d6e2b7627a4007a9bed9f5d8312e664c Mon Sep 17 00:00:00 2001 From: Branden Palmer Date: Mon, 19 Jan 2026 16:47:18 -0600 Subject: [PATCH 3/9] Add custom bounding box helpers These work with anti-meridian unlike turf.js. --- src/plots/map/layout_defaults.js | 45 ++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/plots/map/layout_defaults.js b/src/plots/map/layout_defaults.js index e3b08556277..069efdbcb87 100644 --- a/src/plots/map/layout_defaults.js +++ b/src/plots/map/layout_defaults.js @@ -47,6 +47,51 @@ function handleDefaults(containerIn, containerOut, coerce, opts) { containerOut._input = containerIn; } +function getMinBoundLon(lon) { + if (!lon.length) return { minLon: 0, maxLon: 0 }; + + // normalize to [0, 360) + const norm = lon.map(to360).sort((a, b) => a - b); + + let maxGap = -1; + let gapIndex = 0; + + // find largest gap + for (let i = 0; i < norm.length; i++) { + const curr = norm[i]; + const next = norm[(i + 1) % norm.length]; + const gap = (next - curr + 360) % 360; + + if (gap > maxGap) { + maxGap = gap; + gapIndex = i; + } + } + + // take complement of largest gap + let minLon = norm[(gapIndex + 1) % norm.length]; + let maxLon = norm[gapIndex]; + minLon = to180(minLon) + maxLon = to180(maxLon) + + return { minLon, maxLon }; + + // https://gis.stackexchange.com/questions/201789/verifying-formula-that-will-convert-longitude-0-360-to-180-to-180 + function to180(deg) { + return ((deg + 180) % 360) - 180 + } + function to360(deg) { + return ((deg % 360) + 360) % 360; + } +} + +function getMinBoundLat(lat) { + return { + minLat: Math.min(...lat), + maxLat: Math.max(...lat) + }; +} + function handleLayerDefaults(layerIn, layerOut) { function coerce(attr, dflt) { return Lib.coerce(layerIn, layerOut, layoutAttributes.layers, attr, dflt); From f121429802f970b345b4f6c5e7bdce5cd003c683 Mon Sep 17 00:00:00 2001 From: Branden Palmer Date: Mon, 19 Jan 2026 16:51:35 -0600 Subject: [PATCH 4/9] Call fitBounds in ctor and on update --- src/plots/map/layout_defaults.js | 16 ++++++++++++++++ src/plots/map/map.js | 12 ++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/plots/map/layout_defaults.js b/src/plots/map/layout_defaults.js index 069efdbcb87..823a07b972e 100644 --- a/src/plots/map/layout_defaults.js +++ b/src/plots/map/layout_defaults.js @@ -25,6 +25,22 @@ function handleDefaults(containerIn, containerOut, coerce, opts) { coerce('bearing'); coerce('pitch'); + // dynamically set center/zoom if neither param provided + if (!containerIn?.center && !containerIn?.zoom) { + var [{ lon, lat }] = opts.fullData; + var { minLon, maxLon } = getMinBoundLon(lon); + var { minLat, maxLat } = getMinBoundLat(lat); + // this param is called bounds in mapLibre ctor + // not to be confused with maxBounds aliased below + containerOut.fitBounds = { + west: minLon, + east: maxLon, + south: minLat, + north: maxLat, + }; + } + + // bounds is really for setting maxBounds in mapLibre ctor var west = coerce('bounds.west'); var east = coerce('bounds.east'); var south = coerce('bounds.south'); diff --git a/src/plots/map/map.js b/src/plots/map/map.js index 9a397743482..e1cb87f76db 100644 --- a/src/plots/map/map.js +++ b/src/plots/map/map.js @@ -80,6 +80,10 @@ proto.createMap = function(calcData, fullLayout, resolve, reject) { var bounds = opts.bounds; var maxBounds = bounds ? [[bounds.west, bounds.south], [bounds.east, bounds.north]] : null; + var fitBounds = opts.fitBounds ? [ + [opts.fitBounds.west, opts.fitBounds.south], + [opts.fitBounds.east, opts.fitBounds.north], + ] : null; // create the map! var map = self.map = new maplibregl.Map({ @@ -90,6 +94,10 @@ proto.createMap = function(calcData, fullLayout, resolve, reject) { zoom: opts.zoom, bearing: opts.bearing, pitch: opts.pitch, + bounds: fitBounds, + fitBoundsOptions: { + padding: 20, + }, maxBounds: maxBounds, interactive: !self.isStatic, @@ -334,6 +342,10 @@ proto.updateLayout = function(fullLayout) { if(!this.dragging && !this.wheeling) { map.setCenter(convertCenter(opts.center)); map.setZoom(opts.zoom); + if (opts.fitBounds) { + var { west, south, east, north } = opts.fitBounds + map.fitBounds([[west, south], [east, north]], { padding: 20 }) + } map.setBearing(opts.bearing); map.setPitch(opts.pitch); } From 26f183b634be69f10d5dfc3464e6a1c5c9b2c8f2 Mon Sep 17 00:00:00 2001 From: Branden Palmer Date: Tue, 21 Apr 2026 14:22:14 -0500 Subject: [PATCH 5/9] Rename fit bounds --- src/plots/map/layout_defaults.js | 2 +- src/plots/map/map.js | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/plots/map/layout_defaults.js b/src/plots/map/layout_defaults.js index 823a07b972e..cdbae9a9eba 100644 --- a/src/plots/map/layout_defaults.js +++ b/src/plots/map/layout_defaults.js @@ -32,7 +32,7 @@ function handleDefaults(containerIn, containerOut, coerce, opts) { var { minLat, maxLat } = getMinBoundLat(lat); // this param is called bounds in mapLibre ctor // not to be confused with maxBounds aliased below - containerOut.fitBounds = { + containerOut._fitBounds = { west: minLon, east: maxLon, south: minLat, diff --git a/src/plots/map/map.js b/src/plots/map/map.js index e1cb87f76db..5f1c17119bf 100644 --- a/src/plots/map/map.js +++ b/src/plots/map/map.js @@ -80,9 +80,9 @@ proto.createMap = function(calcData, fullLayout, resolve, reject) { var bounds = opts.bounds; var maxBounds = bounds ? [[bounds.west, bounds.south], [bounds.east, bounds.north]] : null; - var fitBounds = opts.fitBounds ? [ - [opts.fitBounds.west, opts.fitBounds.south], - [opts.fitBounds.east, opts.fitBounds.north], + var _fitBounds = opts._fitBounds ? [ + [opts._fitBounds.west, opts._fitBounds.south], + [opts._fitBounds.east, opts._fitBounds.north], ] : null; // create the map! @@ -94,7 +94,7 @@ proto.createMap = function(calcData, fullLayout, resolve, reject) { zoom: opts.zoom, bearing: opts.bearing, pitch: opts.pitch, - bounds: fitBounds, + bounds: _fitBounds, fitBoundsOptions: { padding: 20, }, @@ -342,8 +342,8 @@ proto.updateLayout = function(fullLayout) { if(!this.dragging && !this.wheeling) { map.setCenter(convertCenter(opts.center)); map.setZoom(opts.zoom); - if (opts.fitBounds) { - var { west, south, east, north } = opts.fitBounds + if (opts._fitBounds) { + var { west, south, east, north } = opts._fitBounds map.fitBounds([[west, south], [east, north]], { padding: 20 }) } map.setBearing(opts.bearing); From 967acde4839fedb6ca7f833d0a15b88f83ffc442 Mon Sep 17 00:00:00 2001 From: Branden Palmer Date: Tue, 21 Apr 2026 14:26:43 -0500 Subject: [PATCH 6/9] Rename get bounds fns --- src/plots/map/layout_defaults.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/plots/map/layout_defaults.js b/src/plots/map/layout_defaults.js index cdbae9a9eba..7e540b0c2ec 100644 --- a/src/plots/map/layout_defaults.js +++ b/src/plots/map/layout_defaults.js @@ -28,8 +28,8 @@ function handleDefaults(containerIn, containerOut, coerce, opts) { // dynamically set center/zoom if neither param provided if (!containerIn?.center && !containerIn?.zoom) { var [{ lon, lat }] = opts.fullData; - var { minLon, maxLon } = getMinBoundLon(lon); - var { minLat, maxLat } = getMinBoundLat(lat); + var { minLon, maxLon } = getLonBounds(lon); + var { minLat, maxLat } = getLatBounds(lat); // this param is called bounds in mapLibre ctor // not to be confused with maxBounds aliased below containerOut._fitBounds = { @@ -63,7 +63,7 @@ function handleDefaults(containerIn, containerOut, coerce, opts) { containerOut._input = containerIn; } -function getMinBoundLon(lon) { +function getLonBounds(lon) { if (!lon.length) return { minLon: 0, maxLon: 0 }; // normalize to [0, 360) @@ -101,7 +101,7 @@ function getMinBoundLon(lon) { } } -function getMinBoundLat(lat) { +function getLatBounds(lat) { return { minLat: Math.min(...lat), maxLat: Math.max(...lat) From b2e15cefbfbc95320e9634ac1acb658c30bc03d1 Mon Sep 17 00:00:00 2001 From: Branden Palmer Date: Tue, 21 Apr 2026 15:59:47 -0500 Subject: [PATCH 7/9] Move padding to constants.js --- src/plots/map/constants.js | 3 +-- src/plots/map/map.js | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/plots/map/constants.js b/src/plots/map/constants.js index 7d48768d8d7..3f3200c0c73 100644 --- a/src/plots/map/constants.js +++ b/src/plots/map/constants.js @@ -85,6 +85,5 @@ module.exports = { mapOnErrorMsg: 'Map error.', - - + fitBoundsPadding: 20, }; diff --git a/src/plots/map/map.js b/src/plots/map/map.js index 5f1c17119bf..a98b5c05a4f 100644 --- a/src/plots/map/map.js +++ b/src/plots/map/map.js @@ -96,7 +96,7 @@ proto.createMap = function(calcData, fullLayout, resolve, reject) { pitch: opts.pitch, bounds: _fitBounds, fitBoundsOptions: { - padding: 20, + padding: constants.fitBoundsPadding, }, maxBounds: maxBounds, @@ -344,7 +344,7 @@ proto.updateLayout = function(fullLayout) { map.setZoom(opts.zoom); if (opts._fitBounds) { var { west, south, east, north } = opts._fitBounds - map.fitBounds([[west, south], [east, north]], { padding: 20 }) + map.fitBounds([[west, south], [east, north]], { padding: constants.fitBoundsPadding }) } map.setBearing(opts.bearing); map.setPitch(opts.pitch); From 0aa4a338119efb2444803c5a211e9aefdb33fc92 Mon Sep 17 00:00:00 2001 From: Branden Palmer Date: Tue, 21 Apr 2026 19:11:21 -0500 Subject: [PATCH 8/9] Add loop to get around min()/max() args limit --- src/plots/map/layout_defaults.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/plots/map/layout_defaults.js b/src/plots/map/layout_defaults.js index 7e540b0c2ec..612f3887e8a 100644 --- a/src/plots/map/layout_defaults.js +++ b/src/plots/map/layout_defaults.js @@ -102,9 +102,15 @@ function getLonBounds(lon) { } function getLatBounds(lat) { + let minLat=lat[0] + let maxLat=lat[0] + for(let i = 1; i < lat.length; i++){ + minLat=Math.min(minLat,lat[i]) + maxLat=Math.max(maxLat,lat[i]) + } return { - minLat: Math.min(...lat), - maxLat: Math.max(...lat) + minLat, + maxLat }; } From 7e08880628b93d3aaedd7d112bfdcb8d7d64520d Mon Sep 17 00:00:00 2001 From: Branden Palmer Date: Wed, 22 Apr 2026 09:57:49 -0500 Subject: [PATCH 9/9] Add flag to disable dynamic centering --- src/plots/map/layout_attributes.js | 11 +++++++++++ src/plots/map/layout_defaults.js | 3 ++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/plots/map/layout_attributes.js b/src/plots/map/layout_attributes.js index f05839e491a..0fb61c0abac 100644 --- a/src/plots/map/layout_attributes.js +++ b/src/plots/map/layout_attributes.js @@ -77,6 +77,17 @@ var attrs = module.exports = overrideAll({ '(in degrees, where *0* means perpendicular to the surface of the map) (map.pitch).' ].join(' ') }, + disableDynamicCentering: { + valType: 'boolean', + dflt: false, + description: [ + 'By default the map camera will be dynamically centered on lon/lat points.', + 'This can be slow when the number of points are above 1e7.', + 'It is recommended to disable this feature for large datasets.', + 'Note: This feature is also disabled if center or zoom attributes are set', + 'regardless of disableDynamicCentering\'s setting.' + ].join(' ') + }, bounds: { west: { diff --git a/src/plots/map/layout_defaults.js b/src/plots/map/layout_defaults.js index 612f3887e8a..d016c074e81 100644 --- a/src/plots/map/layout_defaults.js +++ b/src/plots/map/layout_defaults.js @@ -24,9 +24,10 @@ function handleDefaults(containerIn, containerOut, coerce, opts) { coerce('zoom'); coerce('bearing'); coerce('pitch'); + coerce('disableDynamicCentering'); // dynamically set center/zoom if neither param provided - if (!containerIn?.center && !containerIn?.zoom) { + if (!containerIn?.disableDynamicCentering && !containerIn?.center && !containerIn?.zoom) { var [{ lon, lat }] = opts.fullData; var { minLon, maxLon } = getLonBounds(lon); var { minLat, maxLat } = getLatBounds(lat);