Skip to content

Commit 02d6143

Browse files
committed
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
1 parent 5709d1b commit 02d6143

18 files changed

+2031
-256
lines changed

package-lock.json

+9
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"@plotly/d3-sankey": "0.7.2",
6060
"@plotly/d3-sankey-circular": "0.33.1",
6161
"@turf/area": "^6.0.1",
62+
"@turf/bbox": "^6.0.1",
6263
"@turf/centroid": "^6.0.2",
6364
"alpha-shape": "^1.0.0",
6465
"canvas-fit": "^1.5.0",

src/lib/geo_location_utils.js

+20-4
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ var d3 = require('d3');
1212
var countryRegex = require('country-regex');
1313
var turfArea = require('@turf/area');
1414
var turfCentroid = require('@turf/centroid');
15+
var turfBbox = require('@turf/bbox');
1516

1617
var identity = require('./identity');
1718
var loggers = require('./loggers');
@@ -185,9 +186,7 @@ function feature2polygons(feature) {
185186
return polygons;
186187
}
187188

188-
function extractTraceFeature(calcTrace) {
189-
var trace = calcTrace[0].trace;
190-
189+
function getTraceGeojson(trace) {
191190
var geojsonIn = typeof trace.geojson === 'string' ?
192191
(window.PlotlyGeoAssets || {})[trace.geojson] :
193192
trace.geojson;
@@ -199,6 +198,15 @@ function extractTraceFeature(calcTrace) {
199198
return false;
200199
}
201200

201+
return geojsonIn;
202+
}
203+
204+
function extractTraceFeature(calcTrace) {
205+
var trace = calcTrace[0].trace;
206+
207+
var geojsonIn = getTraceGeojson(trace);
208+
if(!geojsonIn) return false;
209+
202210
var lookup = {};
203211
var featuresOut = [];
204212
var i;
@@ -336,9 +344,17 @@ function fetchTraceGeoData(calcData) {
336344
return promises;
337345
}
338346

347+
// TODO `turf/bbox` gives wrong result when the input feature/geometry
348+
// crosses the anti-meridian. We should try to implement our own bbox logic.
349+
function computeBbox(d) {
350+
return turfBbox.default(d);
351+
}
352+
339353
module.exports = {
340354
locationToFeature: locationToFeature,
341355
feature2polygons: feature2polygons,
356+
getTraceGeojson: getTraceGeojson,
342357
extractTraceFeature: extractTraceFeature,
343-
fetchTraceGeoData: fetchTraceGeoData
358+
fetchTraceGeoData: fetchTraceGeoData,
359+
computeBbox: computeBbox
344360
};

src/plot_api/plot_api.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -2442,7 +2442,7 @@ var layoutUIControlPatterns = [
24422442
{pattern: /(hover|drag)mode$/, attr: 'modebar.uirevision'},
24432443

24442444
{pattern: /^(scene\d*)\.camera/},
2445-
{pattern: /^(geo\d*)\.(projection|center)/},
2445+
{pattern: /^(geo\d*)\.(projection|center|fitbounds)/},
24462446
{pattern: /^(ternary\d*\.[abc]axis)\.(min|title\.text)$/},
24472447
{pattern: /^(polar\d*\.radialaxis)\.((auto)?range|angle|title\.text)/},
24482448
{pattern: /^(polar\d*\.angularaxis)\.rotation/},

src/plots/geo/geo.js

+83-28
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ var Drawing = require('../../components/drawing');
1919
var Fx = require('../../components/fx');
2020
var Plots = require('../plots');
2121
var Axes = require('../cartesian/axes');
22+
var getAutoRange = require('../cartesian/autorange').getAutoRange;
2223
var dragElement = require('../../components/dragelement');
2324
var prepSelect = require('../cartesian/select').prepSelect;
2425
var selectOnClick = require('../cartesian/select').selectOnClick;
@@ -143,18 +144,24 @@ proto.fetchTopojson = function() {
143144
proto.update = function(geoCalcData, fullLayout) {
144145
var geoLayout = fullLayout[this.id];
145146

146-
var hasInvalidBounds = this.updateProjection(fullLayout, geoLayout);
147-
if(hasInvalidBounds) return;
148-
149147
// important: maps with choropleth traces have a different layer order
150148
this.hasChoropleth = false;
149+
151150
for(var i = 0; i < geoCalcData.length; i++) {
152-
if(geoCalcData[i][0].trace.type === 'choropleth') {
151+
var calcTrace = geoCalcData[i];
152+
var trace = calcTrace[0].trace;
153+
154+
if(trace.type === 'choropleth') {
153155
this.hasChoropleth = true;
154-
break;
156+
}
157+
if(trace.visible === true && trace._length > 0) {
158+
trace._module.calcGeoJSON(calcTrace, fullLayout);
155159
}
156160
}
157161

162+
var hasInvalidBounds = this.updateProjection(geoCalcData, fullLayout);
163+
if(hasInvalidBounds) return;
164+
158165
if(!this.viewInitial || this.scope !== geoLayout.scope) {
159166
this.saveViewInitial(geoLayout);
160167
}
@@ -177,20 +184,19 @@ proto.update = function(geoCalcData, fullLayout) {
177184
this.render();
178185
};
179186

180-
proto.updateProjection = function(fullLayout, geoLayout) {
187+
proto.updateProjection = function(geoCalcData, fullLayout) {
188+
var gd = this.graphDiv;
189+
var geoLayout = fullLayout[this.id];
181190
var gs = fullLayout._size;
182191
var domain = geoLayout.domain;
183192
var projLayout = geoLayout.projection;
184-
var rotation = projLayout.rotation || {};
185-
var center = geoLayout.center || {};
186193

187-
var projection = this.projection = getProjection(geoLayout);
194+
var lonaxis = geoLayout.lonaxis;
195+
var lataxis = geoLayout.lataxis;
196+
var axLon = lonaxis._ax;
197+
var axLat = lataxis._ax;
188198

189-
// set 'pre-fit' projection
190-
projection
191-
.center([center.lon - rotation.lon, center.lat - rotation.lat])
192-
.rotate([-rotation.lon, -rotation.lat, rotation.roll])
193-
.parallels(projLayout.parallels);
199+
var projection = this.projection = getProjection(geoLayout);
194200

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

204-
var lonaxis = geoLayout.lonaxis;
205-
var lataxis = geoLayout.lataxis;
206-
var rangeBox = makeRangeBox(lonaxis.range, lataxis.range);
210+
var center = geoLayout.center || {};
211+
var rotation = projLayout.rotation || {};
212+
var lonaxisRange = lonaxis.range || [];
213+
var lataxisRange = lataxis.range || [];
214+
215+
if(geoLayout.fitbounds) {
216+
axLon._length = extent[1][0] - extent[0][0];
217+
axLat._length = extent[1][1] - extent[0][1];
218+
axLon.range = getAutoRange(gd, axLon);
219+
axLat.range = getAutoRange(gd, axLat);
220+
221+
var midLon = (axLon.range[0] + axLon.range[1]) / 2;
222+
var midLat = (axLat.range[0] + axLat.range[1]) / 2;
223+
224+
if(geoLayout._isScoped) {
225+
center = {lon: midLon, lat: midLat};
226+
} else if(geoLayout._isClipped) {
227+
center = {lon: midLon, lat: midLat};
228+
rotation = {lon: midLon, lat: midLat, roll: rotation.roll};
229+
230+
var projType = projLayout.type;
231+
var lonHalfSpan = (constants.lonaxisSpan[projType] / 2) || 180;
232+
var latHalfSpan = (constants.lataxisSpan[projType] / 2) || 180;
233+
234+
lonaxisRange = [midLon - lonHalfSpan, midLon + lonHalfSpan];
235+
lataxisRange = [midLat - latHalfSpan, midLat + latHalfSpan];
236+
} else {
237+
center = {lon: midLon, lat: midLat};
238+
rotation = {lon: midLon, lat: rotation.lat, roll: rotation.roll};
239+
}
240+
}
241+
242+
// set 'pre-fit' projection
243+
projection
244+
.center([center.lon - rotation.lon, center.lat - rotation.lat])
245+
.rotate([-rotation.lon, -rotation.lat, rotation.roll])
246+
.parallels(projLayout.parallels);
207247

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

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

225-
// clear all attribute that could cause invalid bounds,
265+
// clear all attributes that could cause invalid bounds,
226266
// clear viewInitial to update reset-view behavior
227267

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

279+
if(geoLayout.fitbounds) {
280+
var b2 = projection.getBounds(makeRangeBox(axLon.range, axLat.range));
281+
var k2 = Math.min(
282+
(b[1][0] - b[0][0]) / (b2[1][0] - b2[0][0]),
283+
(b[1][1] - b[0][1]) / (b2[1][1] - b2[0][1])
284+
);
285+
projection.scale(k2 * s);
286+
} else {
287+
// adjust projection to user setting
288+
projection.scale(projLayout.scale * s);
289+
}
290+
239291
// px coordinates of view mid-point,
240292
// useful to update `geo.center` after interactions
241293
var midPt = this.midPt = [
242294
(b[0][0] + b[1][0]) / 2,
243295
(b[0][1] + b[1][1]) / 2
244296
];
245297

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

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

593+
this.viewInitial = {
594+
'fitbounds': geoLayout.fitbounds,
595+
'projection.scale': projLayout.scale
596+
};
597+
598+
var extra;
543599
if(geoLayout._isScoped) {
544-
this.viewInitial = {
600+
extra = {
545601
'center.lon': center.lon,
546602
'center.lat': center.lat,
547-
'projection.scale': projLayout.scale
548603
};
549604
} else if(geoLayout._isClipped) {
550-
this.viewInitial = {
551-
'projection.scale': projLayout.scale,
605+
extra = {
552606
'projection.rotation.lon': rotation.lon,
553607
'projection.rotation.lat': rotation.lat
554608
};
555609
} else {
556-
this.viewInitial = {
610+
extra = {
557611
'center.lon': center.lon,
558612
'center.lat': center.lat,
559-
'projection.scale': projLayout.scale,
560613
'projection.rotation.lon': rotation.lon
561614
};
562615
}
616+
617+
Lib.extendFlat(this.viewInitial, extra);
563618
};
564619

565620
// [hot code path] (re)draw all paths which depend on the projection

src/plots/geo/layout_attributes.js

+27
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,33 @@ var attrs = module.exports = overrideAll({
7575
].join(' ')
7676
}),
7777

78+
fitbounds: {
79+
valType: 'enumerated',
80+
values: [false, 'locations', 'geojson'],
81+
dflt: false,
82+
role: 'info',
83+
editType: 'plot',
84+
description: [
85+
'Determines if this subplot\'s view settings are auto-computed to fit trace data.',
86+
87+
'On scoped maps, setting `fitbounds` leads to `center.lon` and `center.lat` getting auto-filled.',
88+
89+
'On maps with a non-clipped projection, setting `fitbounds` leads to `center.lon`, `center.lat`,',
90+
'and `projection.rotation.lon` getting auto-filled.',
91+
92+
'On maps with a clipped projection, setting `fitbounds` leads to `center.lon`, `center.lat`,',
93+
'`projection.rotation.lon`, `projection.rotation.lat`, `lonaxis.range` and `lonaxis.range`',
94+
'getting auto-filled.',
95+
96+
// TODO we should auto-fill `projection.parallels` for maps
97+
// with conic projection, but how?
98+
99+
'If *locations*, only the trace\'s visible locations are considered in the `fitbounds` computations.',
100+
'If *geojson*, the entire trace input `geojson` (if provided) is considered in the `fitbounds` computations,',
101+
'Defaults to *false*.'
102+
].join(' ')
103+
},
104+
78105
resolution: {
79106
valType: 'enumerated',
80107
values: [110, 50],

0 commit comments

Comments
 (0)