Skip to content

Commit ff9d215

Browse files
authored
Merge pull request #7895 from plotly/cam/7855/update-geo-fitbounds-default
chore: Update geo trace fitbounds default to 'locations'
2 parents ccb2569 + e85457c commit ff9d215

40 files changed

Lines changed: 228 additions & 135 deletions

draftlogs/7895_change.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
- **Breaking:** Change `layout.geo.fitbounds` default from `false` to `'locations'` [[#7895](https://github.com/plotly/plotly.js/pull/7895)]
2+
- `geo` subplots will now auto-fit the initial view to the trace data
3+
- Set `fitbounds: false` explicitly to opt out

src/components/modebar/buttons.js

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,8 @@ modeBarButtons.hoverClosestGeo = {
594594
click: toggleHover
595595
};
596596

597+
const ZOOM_STEP_GEO = 2;
598+
597599
function handleGeo(gd, ev) {
598600
const button = ev.currentTarget;
599601
const attr = button.getAttribute('data-attr');
@@ -603,21 +605,38 @@ function handleGeo(gd, ev) {
603605

604606
for (const id of geoIds) {
605607
const geoLayout = fullLayout[id];
608+
const geoSubplot = geoLayout._subplot;
606609

607610
if (attr === 'zoom') {
608-
const { minscale, scale } = geoLayout.projection;
611+
// Under fitbounds, geoLayout.projection.scale is undefined; read the
612+
// effective view from the D3 projection state instead
613+
const projection = geoSubplot.projection;
614+
const effectiveScale = projection.scale() / geoSubplot.fitScale; // Convert to schema format (multiples of original scale)
615+
const [rotationLon, rotationLat] = projection.rotate().map((d) => -d); // Flip sign because D3 rotation is opposite of ours
616+
const [centerLon, centerLat] = projection.invert(geoSubplot.midPt);
617+
618+
const { minscale } = geoLayout.projection;
609619
const maxscale = geoLayout.projection.maxscale ?? Infinity;
610620
// swap if user supplied min > max so clamping is well-defined
611621
const min = Math.min(minscale, maxscale);
612622
const max = Math.max(minscale, maxscale);
613-
let newScale = val === 'in' ? 2 * scale : 0.5 * scale;
623+
let newScale = val === 'in' ? ZOOM_STEP_GEO * effectiveScale : (1 / ZOOM_STEP_GEO) * effectiveScale;
614624

615625
// clamp to [min, max]
616626
if (newScale > max) newScale = max;
617627
else if (newScale < min) newScale = min;
618628

619-
if (newScale !== scale) {
620-
Registry.call('_guiRelayout', gd, id + '.projection.scale', newScale);
629+
if (newScale !== effectiveScale) {
630+
// Persist the currently-effective view attrs with the new scale; turn
631+
// off fitbounds so auto-fit doesn't overwrite them on the ensuing replot
632+
Registry.call('_guiRelayout', gd, {
633+
[id + '.projection.scale']: newScale,
634+
[id + '.projection.rotation.lon']: rotationLon,
635+
[id + '.projection.rotation.lat']: rotationLat,
636+
[id + '.center.lon']: centerLon,
637+
[id + '.center.lat']: centerLat,
638+
[id + '.fitbounds']: false
639+
});
621640
}
622641
}
623642
}

src/plots/geo/constants.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ exports.lataxisSpan = {
141141
'*': 180
142142
};
143143

144+
// Projections whose math doesn't play well with fitbounds
145+
exports.fitboundsIncompatible = new Set(['albers usa', 'craig', 'satellite']);
146+
144147
// defaults for each scope
145148
exports.scopeDefaults = {
146149
world: {

src/plots/geo/geo.js

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -353,9 +353,12 @@ proto.updateProjection = function (geoCalcData, fullLayout) {
353353
// so here's this hack to make it respond to 'geoLayout.center'
354354
if (geoLayout._isAlbersUsa) {
355355
var centerPx = projection([center.lon, center.lat]);
356-
var tt = projection.translate();
357-
358-
projection.translate([tt[0] - (centerPx[0] - tt[0]), tt[1] - (centerPx[1] - tt[1])]);
356+
// If center isn't within the Albers USA bounds (clipped to the USA),
357+
// `projection(...)` returns null so skip the recentering
358+
if (centerPx) {
359+
var tt = projection.translate();
360+
projection.translate([tt[0] - (centerPx[0] - tt[0]), tt[1] - (centerPx[1] - tt[1])]);
361+
}
359362
}
360363
};
361364

@@ -644,7 +647,9 @@ proto.saveViewInitial = function (geoLayout) {
644647
} else if (geoLayout._isClipped) {
645648
extra = {
646649
'projection.rotation.lon': rotation.lon,
647-
'projection.rotation.lat': rotation.lat
650+
'projection.rotation.lat': rotation.lat,
651+
'center.lon': center.lon,
652+
'center.lat': center.lat
648653
};
649654
} else {
650655
extra = {

src/plots/geo/layout_attributes.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ var attrs = (module.exports = overrideAll(
5757
fitbounds: {
5858
valType: 'enumerated',
5959
values: [false, 'locations', 'geojson'],
60-
dflt: false,
60+
dflt: 'locations',
6161
editType: 'plot',
6262
description: [
6363
"Determines if this subplot's view settings are auto-computed to fit trace data.",
@@ -74,9 +74,9 @@ var attrs = (module.exports = overrideAll(
7474
// TODO we should auto-fill `projection.parallels` for maps
7575
// with conic projection, but how?
7676

77-
"If *locations*, only the trace's visible locations are considered in the `fitbounds` computations.",
78-
'If *geojson*, the entire trace input `geojson` (if provided) is considered in the `fitbounds` computations,',
79-
'Defaults to *false*.'
77+
"If *locations* (default), only the trace's visible locations are considered in the `fitbounds` computations.",
78+
'If *geojson*, the entire trace input `geojson` (if provided) is considered in the `fitbounds` computations.',
79+
'If *false*, the view settings are used as-is; set this to opt out of auto-fitting.'
8080
].join(' ')
8181
},
8282

src/plots/geo/layout_defaults.js

Lines changed: 72 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -22,24 +22,26 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) {
2222

2323
function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) {
2424
var subplotData = getSubplotData(opts.fullData, 'geo', opts.id);
25-
var traceIndices = subplotData.map(function(t) { return t.index; });
25+
var traceIndices = subplotData.map(function (t) {
26+
return t.index;
27+
});
2628

2729
var resolution = coerce('resolution');
2830
var scope = coerce('scope');
2931
var scopeParams = constants.scopeDefaults[scope];
3032

3133
var projType = coerce('projection.type', scopeParams.projType);
32-
var isAlbersUsa = geoLayoutOut._isAlbersUsa = projType === 'albers usa';
34+
var isAlbersUsa = (geoLayoutOut._isAlbersUsa = projType === 'albers usa');
3335

3436
// no other scopes are allowed for 'albers usa' projection
35-
if(isAlbersUsa) scope = geoLayoutOut.scope = 'usa';
37+
if (isAlbersUsa) scope = geoLayoutOut.scope = 'usa';
3638

37-
var isScoped = geoLayoutOut._isScoped = (scope !== 'world');
38-
var isSatellite = geoLayoutOut._isSatellite = projType === 'satellite';
39-
var isConic = geoLayoutOut._isConic = projType.indexOf('conic') !== -1 || projType === 'albers';
40-
var isClipped = geoLayoutOut._isClipped = !!constants.lonaxisSpan[projType];
39+
var isScoped = (geoLayoutOut._isScoped = scope !== 'world');
40+
var isSatellite = (geoLayoutOut._isSatellite = projType === 'satellite');
41+
var isConic = (geoLayoutOut._isConic = projType.indexOf('conic') !== -1 || projType === 'albers');
42+
var isClipped = (geoLayoutOut._isClipped = !!constants.lonaxisSpan[projType]);
4143

42-
if(geoLayoutIn.visible === false) {
44+
if (geoLayoutIn.visible === false) {
4345
// should override template.layout.geo.show* - see issue 4482
4446

4547
// make a copy
@@ -54,29 +56,26 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) {
5456
newTemplate.showocean = false;
5557
newTemplate.showrivers = false;
5658
newTemplate.showsubunits = false;
57-
if(newTemplate.lonaxis) newTemplate.lonaxis.showgrid = false;
58-
if(newTemplate.lataxis) newTemplate.lataxis.showgrid = false;
59+
if (newTemplate.lonaxis) newTemplate.lonaxis.showgrid = false;
60+
if (newTemplate.lataxis) newTemplate.lataxis.showgrid = false;
5961

6062
// set ref to copy
6163
geoLayoutOut._template = newTemplate;
6264
}
6365
var visible = coerce('visible');
6466

6567
var show;
66-
for(var i = 0; i < axesNames.length; i++) {
68+
for (var i = 0; i < axesNames.length; i++) {
6769
var axisName = axesNames[i];
6870
var dtickDflt = [30, 10][i];
6971
var rangeDflt;
7072

71-
if(isScoped) {
73+
if (isScoped) {
7274
rangeDflt = scopeParams[axisName + 'Range'];
7375
} else {
7476
var dfltSpans = constants[axisName + 'Span'];
7577
var hSpan = (dfltSpans[projType] || dfltSpans['*']) / 2;
76-
var rot = coerce(
77-
'projection.rotation.' + axisName.slice(0, 3),
78-
scopeParams.projRotate[i]
79-
);
78+
var rot = coerce('projection.rotation.' + axisName.slice(0, 3), scopeParams.projRotate[i]);
8079
rangeDflt = [rot - hSpan, rot + hSpan];
8180
}
8281

@@ -85,7 +84,7 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) {
8584
coerce(axisName + '.dtick', dtickDflt);
8685

8786
show = coerce(axisName + '.showgrid', !visible ? false : undefined);
88-
if(show) {
87+
if (show) {
8988
coerce(axisName + '.gridcolor');
9089
coerce(axisName + '.gridwidth');
9190
coerce(axisName + '.griddash');
@@ -114,27 +113,27 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) {
114113
var centerLon = (lon0 + lon1) / 2;
115114
var projLon;
116115

117-
if(!isAlbersUsa) {
116+
if (!isAlbersUsa) {
118117
var dfltProjRotate = isScoped ? scopeParams.projRotate : [centerLon, 0, 0];
119118

120119
projLon = coerce('projection.rotation.lon', dfltProjRotate[0]);
121120
coerce('projection.rotation.lat', dfltProjRotate[1]);
122121
coerce('projection.rotation.roll', dfltProjRotate[2]);
123122

124123
show = coerce('showcoastlines', !isScoped && visible);
125-
if(show) {
124+
if (show) {
126125
coerce('coastlinecolor');
127126
coerce('coastlinewidth');
128127
}
129128

130129
show = coerce('showocean', !visible ? false : undefined);
131-
if(show) coerce('oceancolor');
130+
if (show) coerce('oceancolor');
132131
}
133132

134133
var centerLonDflt;
135134
var centerLatDflt;
136135

137-
if(isAlbersUsa) {
136+
if (isAlbersUsa) {
138137
// 'albers usa' does not have a 'center',
139138
// these values were found using via:
140139
// projection.invert([geoLayout.center.lon, geoLayoutIn.center.lat])
@@ -148,12 +147,12 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) {
148147
coerce('center.lon', centerLonDflt);
149148
coerce('center.lat', centerLatDflt);
150149

151-
if(isSatellite) {
150+
if (isSatellite) {
152151
coerce('projection.tilt');
153152
coerce('projection.distance');
154153
}
155154

156-
if(isConic) {
155+
if (isConic) {
157156
var dfltProjParallels = scopeParams.projParallels || [0, 60];
158157
coerce('projection.parallels', dfltProjParallels);
159158
}
@@ -163,24 +162,24 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) {
163162
coerce('projection.maxscale');
164163

165164
show = coerce('showland', !visible ? false : undefined);
166-
if(show) coerce('landcolor');
165+
if (show) coerce('landcolor');
167166

168167
show = coerce('showlakes', !visible ? false : undefined);
169-
if(show) coerce('lakecolor');
168+
if (show) coerce('lakecolor');
170169

171170
show = coerce('showrivers', !visible ? false : undefined);
172-
if(show) {
171+
if (show) {
173172
coerce('rivercolor');
174173
coerce('riverwidth');
175174
}
176175

177176
show = coerce('showcountries', isScoped && scope !== 'usa' && visible);
178-
if(show) {
177+
if (show) {
179178
coerce('countrycolor');
180179
coerce('countrywidth');
181180
}
182181

183-
if(scope === 'usa' || (scope === 'north america' && resolution === 50)) {
182+
if (scope === 'usa' || (scope === 'north america' && resolution === 50)) {
184183
// Only works for:
185184
// USA states at 110m
186185
// USA states + Canada provinces at 50m
@@ -189,37 +188,61 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) {
189188
coerce('subunitwidth');
190189
}
191190

192-
if(!isScoped) {
191+
if (!isScoped) {
193192
// Does not work in non-world scopes
194193
show = coerce('showframe', visible);
195-
if(show) {
194+
if (show) {
196195
coerce('framecolor');
197196
coerce('framewidth');
198197
}
199198
}
200199

201200
coerce('bgcolor');
202201

203-
var fitBounds = coerce('fitbounds');
204-
205-
// clear attributes that will get auto-filled later
206-
if(fitBounds) {
207-
delete geoLayoutOut.projection.scale;
208-
209-
if(isScoped) {
210-
delete geoLayoutOut.center.lon;
211-
delete geoLayoutOut.center.lat;
212-
} else if(isClipped) {
213-
delete geoLayoutOut.center.lon;
214-
delete geoLayoutOut.center.lat;
215-
delete geoLayoutOut.projection.rotation.lon;
216-
delete geoLayoutOut.projection.rotation.lat;
217-
delete geoLayoutOut.lonaxis.range;
218-
delete geoLayoutOut.lataxis.range;
202+
// `fitbounds` updates a selection of view attributes, specific to the projection type.
203+
// Check to see if a user has set any of these. If they have, skip fitbounds. Otherwise
204+
// null out the proper attributes and run the fitting logic.
205+
coerce('fitbounds');
206+
if (geoLayoutOut.fitbounds) {
207+
const centerIn = geoLayoutIn.center || {};
208+
const projectionIn = geoLayoutIn.projection || {};
209+
const rotationIn = projectionIn.rotation || {};
210+
const lonaxisIn = geoLayoutIn.lonaxis || {};
211+
const lataxisIn = geoLayoutIn.lataxis || {};
212+
// All projection types will set these attributes
213+
const viewAttributes = [
214+
{ dst: geoLayoutOut.center, key: 'lon', src: centerIn },
215+
{ dst: geoLayoutOut.center, key: 'lat', src: centerIn },
216+
{ dst: geoLayoutOut.projection, key: 'scale', src: projectionIn }
217+
];
218+
// Branch order is important because scoped projections can also be clipped,
219+
// but these should be treated as scoped below
220+
if (isScoped) {
221+
// Scoped only sets center, so move on
222+
} else if (isClipped) {
223+
viewAttributes.push(
224+
{ dst: geoLayoutOut.projection.rotation, key: 'lon', src: rotationIn },
225+
{ dst: geoLayoutOut.projection.rotation, key: 'lat', src: rotationIn },
226+
{ dst: geoLayoutOut.lonaxis, key: 'range', src: lonaxisIn },
227+
{ dst: geoLayoutOut.lataxis, key: 'range', src: lataxisIn }
228+
);
229+
} else {
230+
viewAttributes.push({ dst: geoLayoutOut.projection.rotation, key: 'lon', src: rotationIn });
231+
}
232+
233+
// Add entries for axis ranges with no `dst` key for non-clipped projections.
234+
// These ranges signal user view-config intent, but non-clipped projections should
235+
// skip the null step because it would break the fit calc.
236+
if (!isClipped) {
237+
viewAttributes.push({ key: 'range', src: lonaxisIn }, { key: 'range', src: lataxisIn });
238+
}
239+
const hasUserView = viewAttributes.some(({ src, key }) => src[key] != null); // Use loose comparison so null/undefined count as unset
240+
if (hasUserView || constants.fitboundsIncompatible.has(projType)) {
241+
geoLayoutOut.fitbounds = false;
219242
} else {
220-
delete geoLayoutOut.center.lon;
221-
delete geoLayoutOut.center.lat;
222-
delete geoLayoutOut.projection.rotation.lon;
243+
// Set auto-filled view attributes to null so updateProjection can
244+
// compute the fit from scratch and fullLayout matches user input
245+
viewAttributes.forEach(({ dst, key }) => dst && (dst[key] = null));
223246
}
224247
}
225248
}

src/plots/geo/zoom.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ function sync(geo, projection, cb) {
6363

6464
cb(set);
6565
set('projection.scale', projection.scale() / geo.fitScale);
66+
// Turn off fitbounds so subsequent replays don't re-run the auto-fit and
67+
// overwrite the user's dragged/scrolled position
6668
set('fitbounds', false);
6769
gd.emit('plotly_relayout', eventData);
6870
}

src/types/generated/schema.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11704,8 +11704,8 @@ export interface GeoLayout {
1170411704
countrywidth?: number;
1170511705
domain?: Domain;
1170611706
/**
11707-
* Determines if this subplot's view settings are auto-computed to fit trace data. On scoped maps, setting `fitbounds` leads to `center.lon` and `center.lat` getting auto-filled. On maps with a non-clipped projection, setting `fitbounds` leads to `center.lon`, `center.lat`, and `projection.rotation.lon` getting auto-filled. On maps with a clipped projection, setting `fitbounds` leads to `center.lon`, `center.lat`, `projection.rotation.lon`, `projection.rotation.lat`, `lonaxis.range` and `lataxis.range` getting auto-filled. 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*.
11708-
* @default false
11707+
* Determines if this subplot's view settings are auto-computed to fit trace data. On scoped maps, setting `fitbounds` leads to `center.lon` and `center.lat` getting auto-filled. On maps with a non-clipped projection, setting `fitbounds` leads to `center.lon`, `center.lat`, and `projection.rotation.lon` getting auto-filled. On maps with a clipped projection, setting `fitbounds` leads to `center.lon`, `center.lat`, `projection.rotation.lon`, `projection.rotation.lat`, `lonaxis.range` and `lataxis.range` getting auto-filled. If *locations* (default), 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. If *false*, the view settings are used as-is; set this to opt out of auto-fitting.
11708+
* @default 'locations'
1170911709
*/
1171011710
fitbounds?: false | 'locations' | 'geojson';
1171111711
/**
15.9 KB
Loading
-4.81 KB
Loading

0 commit comments

Comments
 (0)