8000 Geo improvements: fitbounds, 'geojson-id' locationmode and 'featureidkey' by etpinard · Pull Request #4419 · plotly/plotly.js · GitHub
[go: up one dir, main page]

Skip to content

Geo improvements: fitbounds, 'geojson-id' locationmode and 'featureidkey' #4419

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Dec 20, 2019
Merged
Prev Previous commit
Next Next commit
add attribute geo.fitbounds
... with values false (the default and current behaviour),
   `'locations'` and `'geojson'`.

- add `_module.calcGeoJSON` method to the scattergeo and choropleth trace
  modules, use `@turf/bbox` to compute location bounding boxes (for
  now), call `calcGeoJSON` before during geo 'plot' before
  `updateProjection`
- add mock axes to geo lonaxis and lataxis, use them reuse doAutorange
- adjust projection settings using autorange values
- clear attributes that get auto-filled via `fitbounds` during the
  geo layout defaults
- add three mocks

TODOs
- find improvement for `@turf/bbox`
- improve fitbounds behaviour for conic projections
  • Loading branch information
etpinard committed Dec 11, 2019
commit 02d61434896b1ce0ada556230f7eb8c050df0fea
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"@plotly/d3-sankey": "0.7.2",
"@plotly/d3-sankey-circular": "0.33.1",
"@turf/area": "^6.0.1",
"@turf/bbox": "^6.0.1",
"@turf/centroid": "^6.0.2",
"alpha-shape": "^1.0.0",
"canvas-fit": "^1.5.0",
Expand Down
24 changes: 20 additions & 4 deletions src/lib/geo_location_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ var d3 = require('d3');
var countryRegex = require('country-regex');
var turfArea = require('@turf/area');
var turfCentroid = require('@turf/centroid');
var turfBbox = require('@turf/bbox');

var identity = require('./identity');
var loggers = require('./loggers');
Expand Down Expand Up @@ -185,9 +186,7 @@ function feature2polygons(feature) {
return polygons;
}

function extractTraceFeature(calcTrace) {
var trace = calcTrace[0].trace;

function getTraceGeojson(trace) {
var geojsonIn = typeof trace.geojson === 'string' ?
(window.PlotlyGeoAssets || {})[trace.geojson] :
trace.geojson;
Expand All @@ -199,6 +198,15 @@ function extractTraceFeature(calcTrace) {
return false;
}

return geojsonIn;
}

function extractTraceFeature(calcTrace) {
var trace = calcTrace[0].trace;

var geojsonIn = getTraceGeojson(trace);
if(!geojsonIn) return false;

var lookup = {};
var featuresOut = [];
var i;
Expand Down Expand Up @@ -336,9 +344,17 @@ function fetchTraceGeoData(calcData) {
return promises;
}

// TODO `turf/bbox` gives wrong result when the input feature/geometry
// crosses the anti-meridian. We should try to implement our own bbox logic.
function computeBbox(d) {
return turfBbox.default(d);
}

module.exports = {
locationToFeature: locationToFeature,
feature2polygons: feature2polygons,
getTraceGeojson: getTraceGeojson,
extractTraceFeature: extractTraceFeature,
fetchTraceGeoData: fetchTraceGeoData
fetchTraceGeoData: fetchTraceGeoData,
computeBbox: computeBbox
};
2 changes: 1 addition & 1 deletion src/plot_api/plot_api.js
Original file line number Diff line number Diff line change
Expand Up @@ -2442,7 +2442,7 @@ var layoutUIControlPatterns = [
{pattern: /(hover|drag)mode$/, attr: 'modebar.uirevision'},

{pattern: /^(scene\d*)\.camera/},
{pattern: /^(geo\d*)\.(projection|center)/},
{pattern: /^(geo\d*)\.(projection|center|fitbounds)/},
{pattern: /^(ternary\d*\.[abc]axis)\.(min|title\.text)$/},
{pattern: /^(polar\d*\.radialaxis)\.((auto)?range|angle|title\.text)/},
{pattern: /^(polar\d*\.angularaxis)\.rotation/},
Expand Down
111 changes: 83 additions & 28 deletions src/plots/geo/geo.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ var Drawing = require('../../components/drawing');
var Fx = require('../../components/fx');
var Plots = require('../plots');
var Axes = require('../cartesian/axes');
var getAutoRange = require('../cartesian/autorange').getAutoRange;
var dragElement = require('../../components/dragelement');
var prepSelect = require('../cartesian/select').prepSelect;
var selectOnClick = require('../cartesian/select').selectOnClick;
Expand Down Expand Up @@ -143,18 +144,24 @@ proto.fetchTopojson = function() {
proto.update = function(geoCalcData, fullLayout) {
var geoLayout = fullLayout[this.id];

var hasInvalidBounds = this.updateProjection(fullLayout, geoLayout);
if(hasInvalidBounds) return;

// important: maps with choropleth traces have a different layer order
this.hasChoropleth = false;

for(var i = 0; i < geoCalcData.length; i++) {
if(geoCalcData[i][0].trace.type === 'choropleth') {
var calcTrace = geoCalcData[i];
var trace = calcTrace[0].trace;

if(trace.type === 'choropleth') {
this.hasChoropleth = true;
break;
}
if(trace.visible === true && trace._length &g 341A t; 0) {
trace._module.calcGeoJSON(calcTrace, fullLayout);
}
}

var hasInvalidBounds = this.updateProjection(geoCalcData, fullLayout);
if(hasInvalidBounds) return;

if(!this.viewInitial || this.scope !== geoLayout.scope) {
this.saveViewInitial(geoLayout);
}
Expand All @@ -177,20 +184,19 @@ proto.update = function(geoCalcData, fullLayout) {
this.render();
};

proto.updateProjection = function(fullLayout, geoLayout) {
proto.updateProjection = function(geoCalcData, fullLayout) {
var gd = this.graphDiv;
var geoLayout = fullLayout[this.id];
var gs = fullLayout._size;
var domain = geoLayout.domain;
var projLayout = geoLayout.projection;
var rotation = projLayout.rotation || {};
var center = geoLayout.center || {};

var projection = this.projection = getProjection(geoLayout);
var lonaxis = geoLayout.lonaxis;
var lataxis = geoLayout.lataxis;
var axLon = lonaxis._ax;
var axLat = lataxis._ax;

// set 'pre-fit' projection
projection
.center([center.lon - rotation.lon, center.lat - rotation.lat])
.rotate([-rotation.lon, -rotation.lat, rotation.roll])
.parallels(projLayout.parallels);
var projection = this.projection = getProjection(geoLayout);

// setup subplot extent [[x0,y0], [x1,y1]]
var extent = [[
Expand All @@ -201,11 +207,46 @@ proto.updateProjection = function(fullLayout, geoLayout) {
gs.t + gs.h * (1 - domain.y[0])
]];

var lonaxis = geoLayout.lonaxis;
var lataxis = geoLayout.lataxis;
var rangeBox = makeRangeBox(lonaxis.range, lataxis.range);
var center = geoLayout.center || {};
var rotation = projLayout.rotation || {};
var lonaxisRange = lonaxis.range || [];
var lataxisRange = lataxis.range || [];

if(geoLayout.fitbounds) {
axLon._length = extent[1][0] - extent[0][0];
axLat._length = extent[1][1] - extent[0][1];
axLon.range = getAutoRange(gd, axLon);
axLat.range = getAutoRange(gd, axLat);

var midLon = (axLon.range[0] + axLon.range[1]) / 2;
var midLat = (axLat.range[0] + axLat.range[1]) / 2;

if(geoLayout._isScoped) {
center = {lon: midLon, lat: midLat};
} else if(geoLayout._isClipped) {
center = {lon: midLon, lat: midLat};
rotation = {lon: midLon, lat: midLat, roll: rotation.roll};

var projType = projLayout.type;
var lonHalfSpan = (constants.lonaxisSpan[projType] / 2) || 180;
var latHalfSpan = (constants.lataxisSpan[projType] / 2) || 180;

lonaxisRange = [midLon - lonHalfSpan, midLon + lonHalfSpan];
lataxisRange = [midLat - latHalfSpan, midLat + latHalfSpan];
} else {
center = {lon: midLon, lat: midLat};
rotation = {lon: midLon, lat: rotation.lat, roll: rotation.roll};
}
}

// set 'pre-fit' projection
projection
.center([center.lon - rotation.lon, center.lat - rotation.lat])
.rotate([-rotation.lon, -rotation.lat, rotation.roll])
.parallels(projLayout.parallels);

// fit projection 'scale' and 'translate' to set lon/lat ranges
var rangeBox = makeRangeBox(lonaxisRange, lataxisRange);
projection.fitExtent(extent, rangeBox);

var b = this.bounds = projection.getBounds(rangeBox);
Expand All @@ -217,12 +258,11 @@ proto.updateProjection = function(fullLayout, geoLayout) {
!isFinite(b[1][0]) || !isFinite(b[1][1]) ||
isNaN(t[0]) || isNaN(t[0])
) {
var gd = this.graphDiv;
var attrToUnset = ['projection.rotation', 'center', 'lonaxis.range', 'lataxis.range'];
var attrToUnset = ['fitbounds', 'projection.rotation', 'center', 'lonaxis.range', 'lataxis.range'];
var msg = 'Invalid geo settings, relayout\'ing to default view.';
var updateObj = {};

// clear all attribute that could cause invalid bounds,
// clear all attributes that could cause invalid bounds,
// clear viewInitial to update reset-view behavior

for(var i = 0; i < attrToUnset.length; i++) {
Expand All @@ -236,16 +276,26 @@ proto.updateProjection = function(fullLayout, geoLayout) {
return msg;
}

if(geoLayout.fitbounds) {
var b2 = projection.getBounds(makeRangeBox(axLon.range, axLat.range));
var k2 = Math.min(
(b[1][0] - b[0][0]) / (b2[1][0] - b2[0][0]),
(b[1][1] - b[0][1]) / (b2[1][1] - b2[0][1])
);
projection.scale(k2 * s);
} else {
// adjust projection to user setting
projection.scale(projLayout.scale * s);
}

// px coordinates of view mid-point,
// useful to update `geo.center` after interactions
var midPt = this.midPt = [
(b[0][0] + b[1][0]) / 2,
(b[0][1] + b[1][1]) / 2
];

// adjust projection to user setting
projection
.scale(projLayout.scale * s)
.translate([t[0] + (midPt[0] - t[0]), t[1] + (midPt[1] - t[1])])
.clipExtent(b);

Expand Down Expand Up @@ -540,26 +590,31 @@ proto.saveViewInitial = function(geoLayout) {
var projLayout = geoLayout.projection;
var rotation = projLayout.rotation || {};

this.viewInitial = {
'fitbounds': geoLayout.fitbounds,
'projection.scale': projLayout.scale
};

var extra;
if(geoLayout._isScoped) {
this.viewInitial = {
extra = {
'center.lon': center.lon,
'center.lat': center.lat,
'projection.scale': projLayout.scale
};
} else if(geoLayout._isClipped) {
this.viewInitial = {
'projection.scale': projLayout.scale,
extra = {
'projection.rotation.lon': rotation.lon,
'projection.rotation.lat': rotation.lat
};
} else {
this.viewInitial = {
extra = {
'center.lon': center.lon,
'center.lat': center.lat,
'projection.scale': projLayout.scale,
'projection.rotation.lon': rotation.lon
};
}

Lib.extendFlat(this.viewInitial, extra);
};

// [hot code path] (re)draw all paths which depend on the projection
Expand Down
27 changes: 27 additions & 0 deletions src/plots/geo/layout_attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,33 @@ var attrs = module.exports = overrideAll({
].join(' ')
}),

fitbounds: {
valType: 'enumerated',
values: [false, 'locations', 'geojson'],
dflt: false,
role: 'info',
editType: 'plot',
description: [
'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 `lonaxis.range`',
'getting auto-filled.',

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

resolution: {
valType: 'enumerated',
values: [110, 50],
Expand Down
Loading
0