Skip to content

Commit d3cbc3c

Browse files
committed
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
1 parent 437cb2b commit d3cbc3c

7 files changed

Lines changed: 317 additions & 10 deletions

File tree

src/plots/map/constants.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,5 +86,9 @@ module.exports = {
8686

8787
mapOnErrorMsg: 'Map error.',
8888

89+
// padding (in css pixels) applied around the computed data bounding box
90+
// when `fitbounds` is set, so that points/features near the edge of the
91+
// data aren't drawn flush against the subplot's edge.
92+
fitboundsPadding: 30,
8993

9094
};

src/plots/map/index.js

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,10 @@ exports.plot = function plot(gd) {
5555
fullLayout[id]._subplot = map;
5656
}
5757

58-
if(!map.viewInitial) {
59-
map.viewInitial = {
60-
center: Lib.extendFlat({}, opts.center),
61-
zoom: opts.zoom,
62-
bearing: opts.bearing,
63-
pitch: opts.pitch
64-
};
65-
}
58+
// N.B. `viewInitial` is captured inside `Map.prototype.createMap`,
59+
// *after* `fitbounds` (if any) has had a chance to compute
60+
// `center` / `zoom`, so that resetting the view lands back on the
61+
// auto-fit framing rather than the pre-fit default.
6662

6763
map.plot(subplotCalcData, fullLayout, gd._promises);
6864
}

src/plots/map/layout_attributes.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,20 @@ var attrs = module.exports = overrideAll({
7878
].join(' ')
7979
},
8080

81+
fitbounds: {
82+
valType: 'enumerated',
83+
values: [false, 'locations', 'geojson'],
84+
dflt: false,
85+
editType: 'plot',
86+
description: [
87+
'Determines if this subplot\'s view settings are auto-computed to fit trace data.',
88+
'This only takes effect when `center` and `zoom` are not explicitly set.',
89+
'If *locations*, only the trace\'s visible locations are considered in the `fitbounds` computations.',
90+
'If *geojson*, the entire trace input `geojson` (if provided) is considered in the `fitbounds` computations,',
91+
'Defaults to *false*.'
92+
].join(' ')
93+
},
94+
8195
bounds: {
8296
west: {
8397
valType: 'number',

src/plots/map/layout_defaults.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,24 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) {
1818

1919
function handleDefaults(containerIn, containerOut, coerce) {
2020
coerce('style');
21+
22+
// `fitbounds` only auto-computes `center` and `zoom` when the user has
23+
// not explicitly set either of them - mirrors the `geo.fitbounds` /
24+
// cartesian `autorange` convention of auto behavior being the absence
25+
// of an explicit value, rather than fitbounds overriding a user choice.
26+
var centerIn = containerIn.center;
27+
var hasExplicitCenter = !!centerIn && (centerIn.lon !== undefined || centerIn.lat !== undefined);
28+
var hasExplicitZoom = containerIn.zoom !== undefined;
29+
2130
coerce('center.lon');
2231
coerce('center.lat');
2332
coerce('zoom');
2433
coerce('bearing');
2534
coerce('pitch');
2635

36+
var fitbounds = coerce('fitbounds');
37+
containerOut._fitboundsAuto = Boolean(fitbounds) && !hasExplicitCenter && !hasExplicitZoom;
38+
2739
var west = coerce('bounds.west');
2840
var east = coerce('bounds.east');
2941
var south = coerce('bounds.south');

src/plots/map/map.js

Lines changed: 128 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict';
22

33
var maplibregl = require('maplibre-gl');
4+
var isNumeric = require('fast-isnumeric');
45

56
var Lib = require('../../lib');
67
var geoUtils = require('../../lib/geo_location_utils');
@@ -149,6 +150,20 @@ proto.createMap = function(calcData, fullLayout, resolve, reject) {
149150
Promise.all(promises).then(function() {
150151
self.fillBelowLookup(calcData, fullLayout);
151152
self.updateData(calcData);
153+
self.updateFitbounds(calcData, fullLayout);
154+
155+
// capture the view *after* fitbounds has had a chance to compute
156+
// center/zoom, so that double-clicking to reset the view lands back
157+
// on the auto-fit framing rather than the pre-fit default.
158+
if(!self.viewInitial) {
159+
self.viewInitial = {
160+
center: Lib.extendFlat({}, opts.center),
161+
zoom: opts.zoom,
162+
bearing: opts.bearing,
163+
pitch: opts.pitch
164+
};
165+
}
166+
152167
self.updateLayout(fullLayout);
153168
self.resolveOnRender(resolve);
154169
}).catch(reject);
@@ -182,6 +197,7 @@ proto.updateMap = function(calcData, fullLayout, resolve, reject) {
182197
Promise.all(promises).then(function() {
183198
self.fillBelowLookup(calcData, fullLayout);
184199
self.updateData(calcData);
200+
self.updateFitbounds(calcData, fullLayout);
185201
self.updateLayout(fullLayout);
186202
self.resolveOnRender(resolve);
187203
}).catch(reject);
@@ -327,6 +343,34 @@ proto.updateData = function(calcData) {
327343
}
328344
};
329345

346+
// Auto-compute `center` / `zoom` from the visible trace data on this
347+
// subplot, mirroring `geo.fitbounds`. Only takes effect when the user has
348+
// not explicitly set `center` or `zoom` (see `_fitboundsAuto`, computed in
349+
// layout_defaults.js) and leaves the manual pan/zoom values in place once
350+
// the user has interacted with the map - see the `moveend` handler in
351+
// `initFx` below, which clears `fitbounds` on GUI edits.
352+
proto.updateFitbounds = function(calcData, fullLayout) {
353+
var opts = fullLayout[this.id];
354+
if(!opts._fitboundsAuto) return;
355+
356+
var box = computeFitboundsBox(calcData, opts.fitbounds);
357+
if(!box) return;
358+
359+
var bounds = [[box.lonMin, box.latMin], [box.lonMax, box.latMax]];
360+
361+
var camera;
362+
try {
363+
camera = this.map.cameraForBounds(bounds, {padding: constants.fitboundsPadding});
364+
} catch(e) {
365+
Lib.warn('Something went wrong during ' + this.id + ' fitbounds computations.');
366+
return;
367+
}
368+
if(!camera) return;
369+
370+
opts.center = {lon: camera.center.lng, lat: camera.center.lat};
371+
opts.zoom = camera.zoom;
372+
};
373+
330374
proto.updateLayout = function(fullLayout) {
331375
var map = this.map;
332376
var opts = fullLayout[this.id];
@@ -429,14 +473,28 @@ proto.initFx = function(calcData, fullLayout) {
429473

430474
if(evt.originalEvent || self.wheeling) {
431475
var optsNow = fullLayoutNow[self.id];
432-
Registry.call('_storeDirectGUIEdit', gd.layout, fullLayoutNow._preGUI, self.getViewEdits(optsNow));
476+
477+
// once the user has manually panned/zoomed, `fitbounds` must
478+
// stop recomputing `center` / `zoom` on subsequent draws -
479+
// mirrors `geo.fitbounds` getting cleared on GUI zoom/pan.
480+
var hadFitbounds = Boolean(optsNow.fitbounds);
481+
var viewEdits = self.getViewEdits(optsNow);
482+
if(hadFitbounds) viewEdits[self.id + '.fitbounds'] = false;
483+
Registry.call('_storeDirectGUIEdit', gd.layout, fullLayoutNow._preGUI, viewEdits);
433484

434485
var viewNow = self.getView();
435486
optsNow._input.center = optsNow.center = viewNow.center;
436487
optsNow._input.zoom = optsNow.zoom = viewNow.zoom;
437488
optsNow._input.bearing = optsNow.bearing = viewNow.bearing;
438489
optsNow._input.pitch = optsNow.pitch = viewNow.pitch;
439-
gd.emit('plotly_relayout', self.getViewEditsWithDerived(viewNow));
490+
491+
var eventEdits = self.getViewEditsWithDerived(viewNow);
492+
if(hadFitbounds) {
493+
optsNow._input.fitbounds = optsNow.fitbounds = false;
494+
optsNow._fitboundsAuto = false;
495+
eventEdits[self.id + '.fitbounds'] = false;
496+
}
497+
gd.emit('plotly_relayout', eventEdits);
440498
}
441499
if(evt.originalEvent && evt.originalEvent.type === 'mouseup') {
442500
self.dragging = false;
@@ -811,4 +869,72 @@ function convertCenter(center) {
811869
return [center.lon, center.lat];
812870
}
813871

872+
function extendLonLatBounds(box, lon, lat) {
873+
if(!isNumeric(lon) || !isNumeric(lat)) return;
874+
if(lon < box.lonMin) box.lonMin = lon;
875+
if(lon > box.lonMax) box.lonMax = lon;
876+
if(lat < box.latMin) box.latMin = lat;
877+
if(lat > box.latMax) box.latMax = lat;
878+
box.any = true;
879+
}
880+
881+
// Compute a lon/lat bounding box from the visible map-type traces
882+
// (scattermap, densitymap, choroplethmap) on this subplot, to feed into
883+
// MapLibre's `cameraForBounds`. Point-based traces (scattermap,
884+
// densitymap) contribute their `lon`/`lat` values directly; choroplethmap
885+
// traces contribute either the bounding box of their matched geometries
886+
// (`fitbounds: 'locations'`) or of the entire input `geojson`
887+
// (`fitbounds: 'geojson'`), analogous to `geo.fitbounds`.
888+
function computeFitboundsBox(calcData, fitbounds) {
889+
var box = {lonMin: Infinity, lonMax: -Infinity, latMin: Infinity, latMax: -Infinity, any: false};
890+
891+
for(var i = 0; i < calcData.length; i++) {
892+
var calcTrace = calcData[i];
893+
var trace = calcTrace[0].trace;
894+
if(trace.visible !== true) continue;
895+
896+
if(trace.type === 'choroplethmap') {
897+
if(fitbounds === 'geojson') {
898+
var geojsonIn = geoUtils.getTraceGeojson(trace);
899+
if(geojsonIn) {
900+
var bbox = geoUtils.computeBbox(geojsonIn);
901+
extendLonLatBounds(box, bbox[0], bbox[1]);
902+
extendLonLatBounds(box, bbox[2], bbox[3]);
903+
}
904+
} else {
905+
for(var j = 0; j < calcTrace.length; j++) {
906+
var fOut = calcTrace[j].fOut;
907+
if(fOut) {
908+
var fbbox = geoUtils.computeBbox(fOut);
909+
extendLonLatBounds(box, fbbox[0], fbbox[1]);
910+
extendLonLatBounds(box, fbbox[2], fbbox[3]);
911+
}
912+
}
913+
}
914+
} else {
915+
for(var k = 0; k < calcTrace.length; k++) {
916+
var lonlat = calcTrace[k].lonlat;
917+
if(lonlat) extendLonLatBounds(box, lonlat[0], lonlat[1]);
918+
}
919+
}
920+
}
921+
922+
if(!box.any) return null;
923+
924+
// a single point (or multiple coincident points) collapses the box to
925+
// zero width/height, which some `cameraForBounds` implementations turn
926+
// into a non-finite zoom - pad it out to a small but non-degenerate
927+
// region centered on the point.
928+
if(box.lonMin === box.lonMax) {
929+
box.lonMin -= 0.1;
930+
box.lonMax += 0.1;
931+
}
932+
if(box.latMin === box.latMax) {
933+
box.latMin -= 0.1;
934+
box.latMax += 0.1;
935+
}
936+
937+
return box;
938+
}
939+
814940
module.exports = Map;

0 commit comments

Comments
 (0)