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
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
6 changes: 4 additions & 2 deletions src/components/modebar/buttons.js
Original file line number Diff line number Diff line change
Expand Up @@ -479,10 +479,12 @@ function handleGeo(gd, ev) {
var newScale = (val === 'in') ? 2 * scale : 0.5 * scale;

Registry.call('_guiRelayout', gd, id + '.projection.scale', newScale);
} else if(attr === 'reset') {
resetView(gd, 'geo');
}
}

if(attr === 'reset') {
resetView(gd, 'geo');
}
}

modeBarButtons.hoverClosestGl2d = {
Expand Down
317 changes: 311 additions & 6 deletions src/lib/geo_location_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,24 @@

'use strict';

var d3 = require('d3');
var countryRegex = require('country-regex');
var Lib = require('../lib');
var turfArea = require('@turf/area');
var turfCentroid = require('@turf/centroid');
var turfBbox = require('@turf/bbox');

var identity = require('./identity');
var loggers = require('./loggers');
var isPlainObject = require('./is_plain_object');
var nestedProperty = require('./nested_property');
var polygon = require('./polygon');

// make list of all country iso3 ids from at runtime
var countryIds = Object.keys(countryRegex);

var locationmodeToIdFinder = {
'ISO-3': Lib.identity,
'USA-states': Lib.identity,
'ISO-3': identity,
'USA-states': identity,
'country names': countryNameToISO3
};

Expand All @@ -28,7 +37,7 @@ function countryNameToISO3(countryName) {
if(regex.test(countryName.trim().toLowerCase())) return iso3;
}

Lib.log('Unrecognized country name: ' + countryName + '.');
loggers.log('Unrecognized country name: ' + countryName + '.');

return false;
}
Expand Down Expand Up @@ -64,7 +73,7 @@ function locationToFeature(locationmode, location, features) {
if(f.id === locationId) return f;
}

Lib.log([
loggers.log([
'Location with id', locationId,
'does not have a matching topojson feature at this resolution.'
].join(' '));
Expand All @@ -73,6 +82,302 @@ function locationToFeature(locationmode, location, features) {
return false;
}

function feature2polygons(feature) {
var geometry = feature.geometry;
var coords = geometry.coordinates;
var loc = feature.id;

var polygons = [];
var appendPolygon, j, k, m;

function doesCrossAntiMerdian(pts) {
for(var l = 0; l < pts.length - 1; l++) {
if(pts[l][0] > 0 && pts[l + 1][0] < 0) return l;
}
return null;
}

if(loc === 'RUS' || loc === 'FJI') {
// Russia and Fiji have landmasses that cross the antimeridian,
// we need to add +360 to their longitude coordinates, so that
// polygon 'contains' doesn't get confused when crossing the antimeridian.
//
// Note that other countries have polygons on either side of the antimeridian
// (e.g. some Aleutian island for the USA), but those don't confuse
// the 'contains' method; these are skipped here.
appendPolygon = function(_pts) {
var pts;

if(doesCrossAntiMerdian(_pts) === null) {
pts = _pts;
} else {
pts = new Array(_pts.length);
for(m = 0; m < _pts.length; m++) {
// do not mutate calcdata[i][j].geojson !!
pts[m] = [
_pts[m][0] < 0 ? _pts[m][0] + 360 : _pts[m][0],
_pts[m][1]
];
}
}

polygons.push(polygon.tester(pts));
};
} else if(loc === 'ATA') {
// Antarctica has a landmass that wraps around every longitudes which
// confuses the 'contains' methods.
appendPolygon = function(pts) {
var crossAntiMeridianIndex = doesCrossAntiMerdian(pts);

// polygon that do not cross anti-meridian need no special handling
if(crossAntiMeridianIndex === null) {
return polygons.push(polygon.tester(pts));
}

// stitch polygon by adding pt over South Pole,
// so that it covers the projected region covers all latitudes
//
// Note that the algorithm below only works for polygons that
// start and end on longitude -180 (like the ones built by
// https://github.com/etpinard/sane-topojson).
var stitch = new Array(pts.length + 1);
var si = 0;

for(m = 0; m < pts.length; m++) {
if(m > crossAntiMeridianIndex) {
stitch[si++] = [pts[m][0] + 360, pts[m][1]];
} else if(m === crossAntiMeridianIndex) {
stitch[si++] = pts[m];
stitch[si++] = [pts[m][0], -90];
} else {
stitch[si++] = pts[m];
}
}

// polygon.tester by default appends pt[0] to the points list,
// we must remove it here, to avoid a jump in longitude from 180 to -180,
// that would confuse the 'contains' method
var tester = polygon.tester(stitch);
tester.pts.pop();
polygons.push(tester);
};
} else {
// otherwise using same array ref is fine
appendPolygon = function(pts) {
polygons.push(polygon.tester(pts));
};
}

switch(geometry.type) {
case 'MultiPolygon':
for(j = 0; j < coords.length; j++) {
for(k = 0; k < coords[j].length; k++) {
appendPolygon(coords[j][k]);
}
}
break;
case 'Polygon':
for(j = 0; j < coords.length; j++) {
appendPolygon(coords[j]);
}
break;
}

return polygons;
}

function getTraceGeojson(trace) {
var geojsonIn = typeof trace.geojson === 'string' ?
(window.PlotlyGeoAssets || {})[trace.geojson] :
trace.geojson;

// This should not happen, but just in case something goes
// really wrong when fetching the GeoJSON
if(!isPlainObject(geojsonIn)) {
loggers.error('Oops ... something when wrong when fetching ' + trace.geojson);
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;

for(i = 0; i < trace._length; i++) {
var cdi = calcTrace[i];
if(cdi.loc || cdi.loc === 0) {
lookup[cdi.loc] = cdi;
}
}

function appendFeature(fIn) {
var id = nestedProperty(fIn, trace.featureidkey || 'id').get();
var cdi = lookup[id];

if(cdi) {
var geometry = fIn.geometry;

if(geometry.type === 'Polygon' || geometry.type === 'MultiPolygon') {
var fOut = {
type: 'Feature',
id: id,
geometry: geometry,
properties: {}
};

// Compute centroid, add it to the properties
fOut.properties.ct = findCentroid(fOut);

// Mutate in in/out features into calcdata
cdi.fIn = fIn;
cdi.fOut = fOut;

featuresOut.push(fOut);
} else {
loggers.log([
'Location', cdi.loc, 'does not have a valid GeoJSON geometry.',
'Traces with locationmode *geojson-id* only support',
'*Polygon* and *MultiPolygon* geometries.'
].join(' '));
}
}

// remove key from lookup, so that we can track (if any)
// the locations that did not have a corresponding GeoJSON feature
delete lookup[id];
}

switch(geojsonIn.type) {
case 'FeatureCollection':
var featuresIn = geojsonIn.features;
for(i = 0; i < featuresIn.length; i++) {
appendFeature(featuresIn[i]);
}
break;
case 'Feature':
appendFeature(geojsonIn);
break;
default:
loggers.warn([
'Invalid GeoJSON type', (geojsonIn.type || 'none') + '.',
'Traces with locationmode *geojson-id* only support',
'*FeatureCollection* and *Feature* types.'
].join(' '));
return false;
}

for(var loc in lookup) {
loggers.log([
'Location *' + loc + '*',
'does not have a matching feature with id-key',
'*' + trace.featureidkey + '*.'
].join(' '));
}

return featuresOut;
}

// TODO this find the centroid of the polygon of maxArea
// (just like we currently do for geo choropleth polygons),
// maybe instead it would make more sense to compute the centroid
// of each polygon and consider those on hover/select
function findCentroid(feature) {
var geometry = feature.geometry;
var poly;

if(geometry.type === 'MultiPolygon') {
var coords = geometry.coordinates;
var maxArea = 0;

for(var i = 0; i < coords.length; i++) {
var polyi = {type: 'Polygon', coordinates: coords[i]};
var area = turfArea.default(polyi);
if(area > maxArea) {
maxArea = area;
poly = polyi;
}
}
} else {
poly = geometry;
}

return turfCentroid.default(poly).geometry.coordinates;
}

function fetchTraceGeoData(calcData) {
var PlotlyGeoAssets = window.PlotlyGeoAssets || {};
var promises = [];

function fetch(url) {
return new Promise(function(resolve, reject) {
d3.json(url, function(err, d) {
if(err) {
delete PlotlyGeoAssets[url];
var msg = err.status === 404 ?
('GeoJSON at URL "' + url + '" does not exist.') :
('Unexpected error while fetching from ' + url);
return reject(new Error(msg));
}

PlotlyGeoAssets[url] = d;
return resolve(d);
});
});
}

function wait(url) {
return new Promise(function(resolve, reject) {
var cnt = 0;
var interval = setInterval(function() {
if(PlotlyGeoAssets[url] && PlotlyGeoAssets[url] !== 'pending') {
clearInterval(interval);
return resolve(PlotlyGeoAssets[url]);
}
if(cnt > 100) {
clearInterval(interval);
return reject('Unexpected error while fetching from ' + url);
}
cnt++;
}, 50);
});
}

for(var i = 0; i < calcData.length; i++) {
var trace = calcData[i][0].trace;
var url = trace.geojson;

if(typeof url === 'string') {
if(!PlotlyGeoAssets[url]) {
PlotlyGeoAssets[url] = 'pending';
promises.push(fetch(url));
} else if(PlotlyGeoAssets[url] === 'pending') {
promises.push(wait(url));
}
}
}

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
locationToFeature: locationToFeature,
feature2polygons: feature2polygons,
getTraceGeojson: getTraceGeojson,
extractTraceFeature: extractTraceFeature,
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
Loading