Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions draftlogs/7895_change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- **Breaking:** Change `layout.geo.fitbounds` default from `false` to `'locations'` [[#7895](https://github.com/plotly/plotly.js/pull/7895)]
- `geo` subplots will now auto-fit the initial view to the trace data
- Set `fitbounds: false` explicitly to opt out
27 changes: 23 additions & 4 deletions src/components/modebar/buttons.js
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,8 @@ modeBarButtons.hoverClosestGeo = {
click: toggleHover
};

const ZOOM_STEP_GEO = 2;

function handleGeo(gd, ev) {
const button = ev.currentTarget;
const attr = button.getAttribute('data-attr');
Expand All @@ -603,21 +605,38 @@ function handleGeo(gd, ev) {

for (const id of geoIds) {
const geoLayout = fullLayout[id];
const geoSubplot = geoLayout._subplot;

if (attr === 'zoom') {
const { minscale, scale } = geoLayout.projection;
// Under fitbounds, geoLayout.projection.scale is undefined; read the
// effective view from the D3 projection state instead
const projection = geoSubplot.projection;
const effectiveScale = projection.scale() / geoSubplot.fitScale; // Convert to schema format (multiples of original scale)
const [rotationLon, rotationLat] = projection.rotate().map((d) => -d); // Flip sign because D3 rotation is opposite of ours
const [centerLon, centerLat] = projection.invert(geoSubplot.midPt);

const { minscale } = geoLayout.projection;
const maxscale = geoLayout.projection.maxscale ?? Infinity;
// swap if user supplied min > max so clamping is well-defined
const min = Math.min(minscale, maxscale);
const max = Math.max(minscale, maxscale);
let newScale = val === 'in' ? 2 * scale : 0.5 * scale;
let newScale = val === 'in' ? ZOOM_STEP_GEO * effectiveScale : (1 / ZOOM_STEP_GEO) * effectiveScale;

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

if (newScale !== scale) {
Registry.call('_guiRelayout', gd, id + '.projection.scale', newScale);
if (newScale !== effectiveScale) {
// Persist the currently-effective view attrs with the new scale; turn
// off fitbounds so auto-fit doesn't overwrite them on the ensuing replot
Registry.call('_guiRelayout', gd, {
[id + '.projection.scale']: newScale,
[id + '.projection.rotation.lon']: rotationLon,
[id + '.projection.rotation.lat']: rotationLat,
[id + '.center.lon']: centerLon,
[id + '.center.lat']: centerLat,
[id + '.fitbounds']: false
});
}
}
}
Expand Down
13 changes: 9 additions & 4 deletions src/plots/geo/geo.js
Original file line number Diff line number Diff line change
Expand Up @@ -353,9 +353,12 @@ proto.updateProjection = function (geoCalcData, fullLayout) {
// so here's this hack to make it respond to 'geoLayout.center'
if (geoLayout._isAlbersUsa) {
var centerPx = projection([center.lon, center.lat]);
var tt = projection.translate();

projection.translate([tt[0] - (centerPx[0] - tt[0]), tt[1] - (centerPx[1] - tt[1])]);
// If center isn't within the Albers USA bounds (clipped to the USA),
// `projection(...)` returns null so skip the recentering
if (centerPx) {
var tt = projection.translate();
projection.translate([tt[0] - (centerPx[0] - tt[0]), tt[1] - (centerPx[1] - tt[1])]);
}
}
};

Expand Down Expand Up @@ -644,7 +647,9 @@ proto.saveViewInitial = function (geoLayout) {
} else if (geoLayout._isClipped) {
extra = {
'projection.rotation.lon': rotation.lon,
'projection.rotation.lat': rotation.lat
'projection.rotation.lat': rotation.lat,
'center.lon': center.lon,
'center.lat': center.lat
};
} else {
extra = {
Expand Down
8 changes: 4 additions & 4 deletions src/plots/geo/layout_attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ var attrs = (module.exports = overrideAll(
fitbounds: {
valType: 'enumerated',
values: [false, 'locations', 'geojson'],
dflt: false,
dflt: 'locations',
editType: 'plot',
description: [
"Determines if this subplot's view settings are auto-computed to fit trace data.",
Expand All @@ -74,9 +74,9 @@ var attrs = (module.exports = overrideAll(
// TODO we should auto-fill `projection.parallels` for maps
// with conic projection, but how?

"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*.'
"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.'
].join(' ')
},

Expand Down
118 changes: 69 additions & 49 deletions src/plots/geo/layout_defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,26 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) {

function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) {
var subplotData = getSubplotData(opts.fullData, 'geo', opts.id);
var traceIndices = subplotData.map(function(t) { return t.index; });
var traceIndices = subplotData.map(function (t) {
return t.index;
});

var resolution = coerce('resolution');
var scope = coerce('scope');
var scopeParams = constants.scopeDefaults[scope];

var projType = coerce('projection.type', scopeParams.projType);
var isAlbersUsa = geoLayoutOut._isAlbersUsa = projType === 'albers usa';
var isAlbersUsa = (geoLayoutOut._isAlbersUsa = projType === 'albers usa');

// no other scopes are allowed for 'albers usa' projection
if(isAlbersUsa) scope = geoLayoutOut.scope = 'usa';
if (isAlbersUsa) scope = geoLayoutOut.scope = 'usa';

var isScoped = geoLayoutOut._isScoped = (scope !== 'world');
var isSatellite = geoLayoutOut._isSatellite = projType === 'satellite';
var isConic = geoLayoutOut._isConic = projType.indexOf('conic') !== -1 || projType === 'albers';
var isClipped = geoLayoutOut._isClipped = !!constants.lonaxisSpan[projType];
var isScoped = (geoLayoutOut._isScoped = scope !== 'world');
var isSatellite = (geoLayoutOut._isSatellite = projType === 'satellite');
var isConic = (geoLayoutOut._isConic = projType.indexOf('conic') !== -1 || projType === 'albers');
var isClipped = (geoLayoutOut._isClipped = !!constants.lonaxisSpan[projType]);

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

// make a copy
Expand All @@ -54,29 +56,26 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) {
newTemplate.showocean = false;
newTemplate.showrivers = false;
newTemplate.showsubunits = false;
if(newTemplate.lonaxis) newTemplate.lonaxis.showgrid = false;
if(newTemplate.lataxis) newTemplate.lataxis.showgrid = false;
if (newTemplate.lonaxis) newTemplate.lonaxis.showgrid = false;
if (newTemplate.lataxis) newTemplate.lataxis.showgrid = false;

// set ref to copy
geoLayoutOut._template = newTemplate;
}
var visible = coerce('visible');

var show;
for(var i = 0; i < axesNames.length; i++) {
for (var i = 0; i < axesNames.length; i++) {
var axisName = axesNames[i];
var dtickDflt = [30, 10][i];
var rangeDflt;

if(isScoped) {
if (isScoped) {
rangeDflt = scopeParams[axisName + 'Range'];
} else {
var dfltSpans = constants[axisName + 'Span'];
var hSpan = (dfltSpans[projType] || dfltSpans['*']) / 2;
var rot = coerce(
'projection.rotation.' + axisName.slice(0, 3),
scopeParams.projRotate[i]
);
var rot = coerce('projection.rotation.' + axisName.slice(0, 3), scopeParams.projRotate[i]);
rangeDflt = [rot - hSpan, rot + hSpan];
}

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

show = coerce(axisName + '.showgrid', !visible ? false : undefined);
if(show) {
if (show) {
coerce(axisName + '.gridcolor');
coerce(axisName + '.gridwidth');
coerce(axisName + '.griddash');
Expand Down Expand Up @@ -114,27 +113,27 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) {
var centerLon = (lon0 + lon1) / 2;
var projLon;

if(!isAlbersUsa) {
if (!isAlbersUsa) {
var dfltProjRotate = isScoped ? scopeParams.projRotate : [centerLon, 0, 0];

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

show = coerce('showcoastlines', !isScoped && visible);
if(show) {
if (show) {
coerce('coastlinecolor');
coerce('coastlinewidth');
}

show = coerce('showocean', !visible ? false : undefined);
if(show) coerce('oceancolor');
if (show) coerce('oceancolor');
}

var centerLonDflt;
var centerLatDflt;

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

if(isSatellite) {
if (isSatellite) {
coerce('projection.tilt');
coerce('projection.distance');
}

if(isConic) {
if (isConic) {
var dfltProjParallels = scopeParams.projParallels || [0, 60];
coerce('projection.parallels', dfltProjParallels);
}
Expand All @@ -163,24 +162,24 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) {
coerce('projection.maxscale');

show = coerce('showland', !visible ? false : undefined);
if(show) coerce('landcolor');
if (show) coerce('landcolor');

show = coerce('showlakes', !visible ? false : undefined);
if(show) coerce('lakecolor');
if (show) coerce('lakecolor');

show = coerce('showrivers', !visible ? false : undefined);
if(show) {
if (show) {
coerce('rivercolor');
coerce('riverwidth');
}

show = coerce('showcountries', isScoped && scope !== 'usa' && visible);
if(show) {
if (show) {
coerce('countrycolor');
coerce('countrywidth');
}

if(scope === 'usa' || (scope === 'north america' && resolution === 50)) {
if (scope === 'usa' || (scope === 'north america' && resolution === 50)) {
// Only works for:
// USA states at 110m
// USA states + Canada provinces at 50m
Expand All @@ -189,37 +188,58 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) {
coerce('subunitwidth');
}

if(!isScoped) {
if (!isScoped) {
// Does not work in non-world scopes
show = coerce('showframe', visible);
if(show) {
if (show) {
coerce('framecolor');
coerce('framewidth');
}
}

coerce('bgcolor');

var fitBounds = coerce('fitbounds');

// clear attributes that will get auto-filled later
if(fitBounds) {
delete geoLayoutOut.projection.scale;

if(isScoped) {
delete geoLayoutOut.center.lon;
delete geoLayoutOut.center.lat;
} else if(isClipped) {
delete geoLayoutOut.center.lon;
delete geoLayoutOut.center.lat;
delete geoLayoutOut.projection.rotation.lon;
delete geoLayoutOut.projection.rotation.lat;
delete geoLayoutOut.lonaxis.range;
delete geoLayoutOut.lataxis.range;
// Only use fitbounds if user hasn't set any view attributes. This prevents
// user-specified view info from being ignored.
coerce('fitbounds');
if (geoLayoutOut.fitbounds) {
const centerIn = geoLayoutIn.center || {};
const projectionIn = geoLayoutIn.projection || {};
const rotationIn = projectionIn.rotation || {};
const lonaxisIn = geoLayoutIn.lonaxis || {};
const lataxisIn = geoLayoutIn.lataxis || {};
const hasUserView = [
centerIn.lon,
centerIn.lat,
rotationIn.lon,
rotationIn.lat,
projectionIn.scale,
lonaxisIn.range,
lataxisIn.range
].some((d) => d !== undefined);
// The Albers projection doesn't need a fit, so skip here
if (hasUserView || isAlbersUsa) geoLayoutOut.fitbounds = false;
}

// Set auto-filled view attributes to null so updateProjection can
// compute the fit from scratch and fullLayout matches user input
if (geoLayoutOut.fitbounds) {
geoLayoutOut.projection.scale = null;

if (isScoped) {
geoLayoutOut.center.lon = null;
geoLayoutOut.center.lat = null;
} else if (isClipped) {
geoLayoutOut.center.lon = null;
geoLayoutOut.center.lat = null;
geoLayoutOut.projection.rotation.lon = null;
geoLayoutOut.projection.rotation.lat = null;
geoLayoutOut.lonaxis.range = null;
geoLayoutOut.lataxis.range = null;
} else {
delete geoLayoutOut.center.lon;
delete geoLayoutOut.center.lat;
delete geoLayoutOut.projection.rotation.lon;
geoLayoutOut.center.lon = null;
geoLayoutOut.center.lat = null;
geoLayoutOut.projection.rotation.lon = null;
}
}
}
2 changes: 2 additions & 0 deletions src/plots/geo/zoom.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ function sync(geo, projection, cb) {

cb(set);
set('projection.scale', projection.scale() / geo.fitScale);
// Turn off fitbounds so subsequent replays don't re-run the auto-fit and
// overwrite the user's dragged/scrolled position
set('fitbounds', false);
gd.emit('plotly_relayout', eventData);
}
Expand Down
4 changes: 2 additions & 2 deletions src/types/generated/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11694,8 +11694,8 @@ export interface GeoLayout {
countrywidth?: number;
domain?: Domain;
/**
* 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*.
* @default false
* 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.
* @default 'locations'
*/
fitbounds?: false | 'locations' | 'geojson';
/**
Expand Down
Loading
Loading