Skip to content
Merged
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