From d3cbc3cf1e82cbefd8b512cf128c77281ac7542b Mon Sep 17 00:00:00 2001 From: kirthi_b Date: Sat, 18 Jul 2026 19:41:23 -0700 Subject: [PATCH 1/2] Add fitbounds support for map subplots scattermap, choroplethmap and densitymap traces currently need center and zoom set by hand. The geo subplot has supported auto-fitting since #4419; this ports the same idea over to the MapLibre-backed map subplot. Setting layout.map.fitbounds to 'locations' or 'geojson' computes a lon/lat bounding box from the visible map-type traces on that subplot (choropleth features use the matched geometry or the full input geojson depending on the mode, same distinction as geo.fitbounds) and feeds it into MapLibre's cameraForBounds to get a center and zoom. Auto-fit only kicks in when the user hasn't set center or zoom explicitly, mirroring how geo.fitbounds and cartesian autorange treat "auto" as the absence of an explicit value rather than an override. Once the user pans or zooms the map by hand, fitbounds gets cleared back to false on that interaction so it stops recomputing the view on every subsequent draw, the same way geo's zoom handler clears fitbounds after a GUI zoom. viewInitial (used for double-click reset) is now captured after the fitbounds computation runs, so resetting the view lands back on the fitted framing instead of the pre-fit default. Fixes #3434 --- src/plots/map/constants.js | 4 + src/plots/map/index.js | 12 +-- src/plots/map/layout_attributes.js | 14 +++ src/plots/map/layout_defaults.js | 12 +++ src/plots/map/map.js | 130 +++++++++++++++++++++++++- test/jasmine/tests/map_test.js | 144 +++++++++++++++++++++++++++++ test/plot-schema.json | 11 +++ 7 files changed, 317 insertions(+), 10 deletions(-) diff --git a/src/plots/map/constants.js b/src/plots/map/constants.js index 7d48768d8d7..1a3e5e3cb58 100644 --- a/src/plots/map/constants.js +++ b/src/plots/map/constants.js @@ -86,5 +86,9 @@ module.exports = { mapOnErrorMsg: 'Map error.', + // padding (in css pixels) applied around the computed data bounding box + // when `fitbounds` is set, so that points/features near the edge of the + // data aren't drawn flush against the subplot's edge. + fitboundsPadding: 30, }; diff --git a/src/plots/map/index.js b/src/plots/map/index.js index bd074948584..2c9979249ce 100644 --- a/src/plots/map/index.js +++ b/src/plots/map/index.js @@ -55,14 +55,10 @@ exports.plot = function plot(gd) { fullLayout[id]._subplot = map; } - if(!map.viewInitial) { - map.viewInitial = { - center: Lib.extendFlat({}, opts.center), - zoom: opts.zoom, - bearing: opts.bearing, - pitch: opts.pitch - }; - } + // N.B. `viewInitial` is captured inside `Map.prototype.createMap`, + // *after* `fitbounds` (if any) has had a chance to compute + // `center` / `zoom`, so that resetting the view lands back on the + // auto-fit framing rather than the pre-fit default. map.plot(subplotCalcData, fullLayout, gd._promises); } diff --git a/src/plots/map/layout_attributes.js b/src/plots/map/layout_attributes.js index f05839e491a..2696d7751bb 100644 --- a/src/plots/map/layout_attributes.js +++ b/src/plots/map/layout_attributes.js @@ -78,6 +78,20 @@ var attrs = module.exports = overrideAll({ ].join(' ') }, + fitbounds: { + valType: 'enumerated', + values: [false, 'locations', 'geojson'], + dflt: false, + editType: 'plot', + description: [ + 'Determines if this subplot\'s view settings are auto-computed to fit trace data.', + 'This only takes effect when `center` and `zoom` are not explicitly set.', + 'If *locations*, only the trace\'s visible locations are considered in the `fitbounds` computations.', + 'If *geojson*, the entire trace input `geojson` (if provided) is considered in the `fitbounds` computations,', + 'Defaults to *false*.' + ].join(' ') + }, + bounds: { west: { valType: 'number', diff --git a/src/plots/map/layout_defaults.js b/src/plots/map/layout_defaults.js index 5cb2531fa1c..00013d06d92 100644 --- a/src/plots/map/layout_defaults.js +++ b/src/plots/map/layout_defaults.js @@ -18,12 +18,24 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { function handleDefaults(containerIn, containerOut, coerce) { coerce('style'); + + // `fitbounds` only auto-computes `center` and `zoom` when the user has + // not explicitly set either of them - mirrors the `geo.fitbounds` / + // cartesian `autorange` convention of auto behavior being the absence + // of an explicit value, rather than fitbounds overriding a user choice. + var centerIn = containerIn.center; + var hasExplicitCenter = !!centerIn && (centerIn.lon !== undefined || centerIn.lat !== undefined); + var hasExplicitZoom = containerIn.zoom !== undefined; + coerce('center.lon'); coerce('center.lat'); coerce('zoom'); coerce('bearing'); coerce('pitch'); + var fitbounds = coerce('fitbounds'); + containerOut._fitboundsAuto = Boolean(fitbounds) && !hasExplicitCenter && !hasExplicitZoom; + 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..168c15ac5b8 100644 --- a/src/plots/map/map.js +++ b/src/plots/map/map.js @@ -1,6 +1,7 @@ 'use strict'; var maplibregl = require('maplibre-gl'); +var isNumeric = require('fast-isnumeric'); var Lib = require('../../lib'); var geoUtils = require('../../lib/geo_location_utils'); @@ -149,6 +150,20 @@ proto.createMap = function(calcData, fullLayout, resolve, reject) { Promise.all(promises).then(function() { self.fillBelowLookup(calcData, fullLayout); self.updateData(calcData); + self.updateFitbounds(calcData, fullLayout); + + // capture the view *after* fitbounds has had a chance to compute + // center/zoom, so that double-clicking to reset the view lands back + // on the auto-fit framing rather than the pre-fit default. + if(!self.viewInitial) { + self.viewInitial = { + center: Lib.extendFlat({}, opts.center), + zoom: opts.zoom, + bearing: opts.bearing, + pitch: opts.pitch + }; + } + self.updateLayout(fullLayout); self.resolveOnRender(resolve); }).catch(reject); @@ -182,6 +197,7 @@ proto.updateMap = function(calcData, fullLayout, resolve, reject) { Promise.all(promises).then(function() { self.fillBelowLookup(calcData, fullLayout); self.updateData(calcData); + self.updateFitbounds(calcData, fullLayout); self.updateLayout(fullLayout); self.resolveOnRender(resolve); }).catch(reject); @@ -327,6 +343,34 @@ proto.updateData = function(calcData) { } }; +// Auto-compute `center` / `zoom` from the visible trace data on this +// subplot, mirroring `geo.fitbounds`. Only takes effect when the user has +// not explicitly set `center` or `zoom` (see `_fitboundsAuto`, computed in +// layout_defaults.js) and leaves the manual pan/zoom values in place once +// the user has interacted with the map - see the `moveend` handler in +// `initFx` below, which clears `fitbounds` on GUI edits. +proto.updateFitbounds = function(calcData, fullLayout) { + var opts = fullLayout[this.id]; + if(!opts._fitboundsAuto) return; + + var box = computeFitboundsBox(calcData, opts.fitbounds); + if(!box) return; + + var bounds = [[box.lonMin, box.latMin], [box.lonMax, box.latMax]]; + + var camera; + try { + camera = this.map.cameraForBounds(bounds, {padding: constants.fitboundsPadding}); + } catch(e) { + Lib.warn('Something went wrong during ' + this.id + ' fitbounds computations.'); + return; + } + if(!camera) return; + + opts.center = {lon: camera.center.lng, lat: camera.center.lat}; + opts.zoom = camera.zoom; +}; + proto.updateLayout = function(fullLayout) { var map = this.map; var opts = fullLayout[this.id]; @@ -429,14 +473,28 @@ proto.initFx = function(calcData, fullLayout) { if(evt.originalEvent || self.wheeling) { var optsNow = fullLayoutNow[self.id]; - Registry.call('_storeDirectGUIEdit', gd.layout, fullLayoutNow._preGUI, self.getViewEdits(optsNow)); + + // once the user has manually panned/zoomed, `fitbounds` must + // stop recomputing `center` / `zoom` on subsequent draws - + // mirrors `geo.fitbounds` getting cleared on GUI zoom/pan. + var hadFitbounds = Boolean(optsNow.fitbounds); + var viewEdits = self.getViewEdits(optsNow); + if(hadFitbounds) viewEdits[self.id + '.fitbounds'] = false; + Registry.call('_storeDirectGUIEdit', gd.layout, fullLayoutNow._preGUI, viewEdits); var viewNow = self.getView(); optsNow._input.center = optsNow.center = viewNow.center; optsNow._input.zoom = optsNow.zoom = viewNow.zoom; optsNow._input.bearing = optsNow.bearing = viewNow.bearing; optsNow._input.pitch = optsNow.pitch = viewNow.pitch; - gd.emit('plotly_relayout', self.getViewEditsWithDerived(viewNow)); + + var eventEdits = self.getViewEditsWithDerived(viewNow); + if(hadFitbounds) { + optsNow._input.fitbounds = optsNow.fitbounds = false; + optsNow._fitboundsAuto = false; + eventEdits[self.id + '.fitbounds'] = false; + } + gd.emit('plotly_relayout', eventEdits); } if(evt.originalEvent && evt.originalEvent.type === 'mouseup') { self.dragging = false; @@ -811,4 +869,72 @@ function convertCenter(center) { return [center.lon, center.lat]; } +function extendLonLatBounds(box, lon, lat) { + if(!isNumeric(lon) || !isNumeric(lat)) return; + if(lon < box.lonMin) box.lonMin = lon; + if(lon > box.lonMax) box.lonMax = lon; + if(lat < box.latMin) box.latMin = lat; + if(lat > box.latMax) box.latMax = lat; + box.any = true; +} + +// Compute a lon/lat bounding box from the visible map-type traces +// (scattermap, densitymap, choroplethmap) on this subplot, to feed into +// MapLibre's `cameraForBounds`. Point-based traces (scattermap, +// densitymap) contribute their `lon`/`lat` values directly; choroplethmap +// traces contribute either the bounding box of their matched geometries +// (`fitbounds: 'locations'`) or of the entire input `geojson` +// (`fitbounds: 'geojson'`), analogous to `geo.fitbounds`. +function computeFitboundsBox(calcData, fitbounds) { + var box = {lonMin: Infinity, lonMax: -Infinity, latMin: Infinity, latMax: -Infinity, any: false}; + + for(var i = 0; i < calcData.length; i++) { + var calcTrace = calcData[i]; + var trace = calcTrace[0].trace; + if(trace.visible !== true) continue; + + if(trace.type === 'choroplethmap') { + if(fitbounds === 'geojson') { + var geojsonIn = geoUtils.getTraceGeojson(trace); + if(geojsonIn) { + var bbox = geoUtils.computeBbox(geojsonIn); + extendLonLatBounds(box, bbox[0], bbox[1]); + extendLonLatBounds(box, bbox[2], bbox[3]); + } + } else { + for(var j = 0; j < calcTrace.length; j++) { + var fOut = calcTrace[j].fOut; + if(fOut) { + var fbbox = geoUtils.computeBbox(fOut); + extendLonLatBounds(box, fbbox[0], fbbox[1]); + extendLonLatBounds(box, fbbox[2], fbbox[3]); + } + } + } + } else { + for(var k = 0; k < calcTrace.length; k++) { + var lonlat = calcTrace[k].lonlat; + if(lonlat) extendLonLatBounds(box, lonlat[0], lonlat[1]); + } + } + } + + if(!box.any) return null; + + // a single point (or multiple coincident points) collapses the box to + // zero width/height, which some `cameraForBounds` implementations turn + // into a non-finite zoom - pad it out to a small but non-degenerate + // region centered on the point. + if(box.lonMin === box.lonMax) { + box.lonMin -= 0.1; + box.lonMax += 0.1; + } + if(box.latMin === box.latMax) { + box.latMin -= 0.1; + box.latMax += 0.1; + } + + return box; +} + module.exports = Map; diff --git a/test/jasmine/tests/map_test.js b/test/jasmine/tests/map_test.js index 14be643b757..9ebf58e8a1d 100644 --- a/test/jasmine/tests/map_test.js +++ b/test/jasmine/tests/map_test.js @@ -244,6 +244,53 @@ describe('map defaults', function() { supplyAllDefaults(gd); expect(gd._fullLayout.dragmode).toBe('pan'); }); + + describe('fitbounds', function() { + it('defaults to false and does not mark the view for auto-fit', function() { + layoutIn = { map: {} }; + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + + expect(layoutOut.map.fitbounds).toBe(false); + expect(layoutOut.map._fitboundsAuto).toBe(false); + }); + + it('marks the view for auto-fit when set with no explicit center or zoom', function() { + layoutIn = { map: { fitbounds: 'locations' } }; + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + + expect(layoutOut.map.fitbounds).toBe('locations'); + expect(layoutOut.map._fitboundsAuto).toBe(true); + // center/zoom are still coerced to their normal defaults - the + // auto-fit computation happens later, during the plot step. + expect(layoutOut.map.center).toEqual({ lon: 0, lat: 0 }); + expect(layoutOut.map.zoom).toBe(1); + }); + + it('does not mark the view for auto-fit when the user set an explicit center', function() { + layoutIn = { map: { fitbounds: 'locations', center: { lon: 10, lat: 20 } } }; + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + + expect(layoutOut.map.fitbounds).toBe('locations'); + expect(layoutOut.map._fitboundsAuto).toBe(false); + expect(layoutOut.map.center).toEqual({ lon: 10, lat: 20 }); + }); + + it('does not mark the view for auto-fit when the user set an explicit zoom', function() { + layoutIn = { map: { fitbounds: 'geojson', zoom: 4 } }; + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + + expect(layoutOut.map.fitbounds).toBe('geojson'); + expect(layoutOut.map._fitboundsAuto).toBe(false); + expect(layoutOut.map.zoom).toBe(4); + }); + + it('accepts *locations* and *geojson* as the only truthy values', function() { + layoutIn = { map: { fitbounds: 'nope' } }; + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + expect(layoutOut.map.fitbounds).toBe(false); + expect(layoutOut.map._fitboundsAuto).toBe(false); + }); + }); }); describe('map plots', function() { @@ -1430,6 +1477,103 @@ describe('map plots', function() { } }); +describe('map fitbounds', function() { + var gd; + + beforeEach(function() { gd = createGraphDiv(); }); + + afterEach(function() { + Plotly.purge(gd); + destroyGraphDiv(); + }); + + it('@gl auto-computes center and zoom from scattermap trace data', function(done) { + Plotly.newPlot(gd, [{ + type: 'scattermap', + mode: 'markers', + lon: [10, 30], + lat: [40, 60] + }], { + map: { fitbounds: 'locations' }, + width: 700, + height: 500 + }) + .then(function() { + var mapLayout = gd._fullLayout.map; + + // the fitted center sits within the data's bounding box + expect(mapLayout.center.lon).toBeGreaterThan(10); + expect(mapLayout.center.lon).toBeLessThan(30); + expect(mapLayout.center.lat).toBeGreaterThan(40); + expect(mapLayout.center.lat).toBeLessThan(60); + + // zoom got auto-computed away from the layout default (1) + expect(mapLayout.zoom).toEqual(jasmine.any(Number)); + expect(isFinite(mapLayout.zoom)).toBe(true); + expect(mapLayout.zoom).not.toEqual(1); + }) + .then(done, done.fail); + }, LONG_TIMEOUT_INTERVAL); + + it('@gl leaves an explicit center/zoom untouched even when fitbounds is set', function(done) { + Plotly.newPlot(gd, [{ + type: 'scattermap', + mode: 'markers', + lon: [10, 30], + lat: [40, 60] + }], { + map: { + fitbounds: 'locations', + center: { lon: 5, lat: 5 }, + zoom: 3 + }, + width: 700, + height: 500 + }) + .then(function() { + var mapLayout = gd._fullLayout.map; + expect(mapLayout.center).toEqual({ lon: 5, lat: 5 }); + expect(mapLayout.zoom).toBe(3); + }) + .then(done, done.fail); + }, LONG_TIMEOUT_INTERVAL); + + it('@gl clears fitbounds after a user-driven pan/zoom so it does not keep overriding the view', function(done) { + var relayoutData; + + Plotly.newPlot(gd, [{ + type: 'scattermap', + mode: 'markers', + lon: [10, 30], + lat: [40, 60] + }], { + map: { fitbounds: 'locations' }, + width: 700, + height: 500 + }) + .then(function() { + expect(gd._fullLayout.map.fitbounds).toBe('locations'); + expect(gd._fullLayout.map._fitboundsAuto).toBe(true); + + gd.on('plotly_relayout', function(d) { relayoutData = d; }); + + // simulate a genuine user-driven pan/zoom (as opposed to an + // API-triggered setCenter/setZoom, which does not carry an + // `originalEvent` and must *not* clear fitbounds) + gd._fullLayout.map._subplot.map.fire('moveend', { + originalEvent: { type: 'mouseup' } + }); + }) + .then(function() { + expect(gd._fullLayout.map.fitbounds).toBe(false); + expect(gd._fullLayout.map._fitboundsAuto).toBe(false); + expect(gd.layout.map.fitbounds).toBe(false); + expect(relayoutData['map.fitbounds']).toBe(false); + }) + .then(done, done.fail); + }, LONG_TIMEOUT_INTERVAL); +}); + describe('map react', function() { var gd; diff --git a/test/plot-schema.json b/test/plot-schema.json index 78614b86693..5f08d44c005 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -3840,6 +3840,17 @@ } }, "editType": "plot", + "fitbounds": { + "description": "Determines if this subplot's view settings are auto-computed to fit trace data. This only takes effect when `center` and `zoom` are not explicitly set. If *locations*, only the trace's visible locations are considered in the `fitbounds` computations. If *geojson*, the entire trace input `geojson` (if provided) is considered in the `fitbounds` computations, Defaults to *false*.", + "dflt": false, + "editType": "plot", + "valType": "enumerated", + "values": [ + false, + "locations", + "geojson" + ] + }, "layers": { "items": { "layer": { From e789d865b190a5969a001ccd1d5fe177872dd615 Mon Sep 17 00:00:00 2001 From: kirthi_b Date: Sat, 18 Jul 2026 21:07:16 -0700 Subject: [PATCH 2/2] Add changelog entry for #7911 --- draftlogs/7911_add.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 draftlogs/7911_add.md diff --git a/draftlogs/7911_add.md b/draftlogs/7911_add.md new file mode 100644 index 00000000000..dc9906687dc --- /dev/null +++ b/draftlogs/7911_add.md @@ -0,0 +1 @@ +- Add `fitbounds` to `layout.map` to auto-fit center/zoom to trace data on map subplots [[#7911](https://github.com/plotly/plotly.js/pull/7911)]