Skip to content

Commit 6397ab3

Browse files
committed
add locationmode 'geojson-id' to scattergeo and choropleth traces
- move extractTraceFeature & fetchTraceGeoData logic from choroplethmapbox/convert.js -> geo_location_utils.js - move feature2polygons from choropleth/plot -> geo_location_utils.js - use lib/loggers instead of Lib in geo_location_utils
1 parent 162afe4 commit 6397ab3

17 files changed

+622
-345
lines changed

src/lib/geo_location_utils.js

+263-6
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,22 @@
88

99
'use strict';
1010

11+
var d3 = require('d3');
1112
var countryRegex = require('country-regex');
12-
var Lib = require('../lib');
13+
var turfArea = require('@turf/area');
14+
var turfCentroid = require('@turf/centroid');
15+
16+
var identity = require('./identity');
17+
var loggers = require('./loggers');
18+
var isPlainObject = require('./is_plain_object');
19+
var polygon = require('./polygon');
1320

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

1724
var locationmodeToIdFinder = {
18-
'ISO-3': Lib.identity,
19-
'USA-states': Lib.identity,
25+
'ISO-3': identity,
26+
'USA-states': identity,
2027
'country names': countryNameToISO3
2128
};
2229

@@ -28,7 +35,7 @@ function countryNameToISO3(countryName) {
2835
if(regex.test(countryName.trim().toLowerCase())) return iso3;
2936
}
3037

31-
Lib.log('Unrecognized country name: ' + countryName + '.');
38+
loggers.log('Unrecognized country name: ' + countryName + '.');
3239

3340
return false;
3441
}
@@ -64,7 +71,7 @@ function locationToFeature(locationmode, location, features) {
6471
if(f.id === locationId) return f;
6572
}
6673

67-
Lib.log([
74+
loggers.log([
6875
'Location with id', locationId,
6976
'does not have a matching topojson feature at this resolution.'
7077
].join(' '));
@@ -73,6 +80,256 @@ function locationToFeature(locationmode, location, features) {
7380
return false;
7481
}
7582

83+
function feature2polygons(feature) {
84+
var geometry = feature.geometry;
85+
var coords = geometry.coordinates;
86+
var loc = feature.id;
87+
88+
var polygons = [];
89+
var appendPolygon, j, k, m;
90+
91+
function doesCrossAntiMerdian(pts) {
92+
for(var l = 0; l < pts.length - 1; l++) {
93+
if(pts[l][0] > 0 && pts[l + 1][0] < 0) return l;
94+
}
95+
return null;
96+
}
97+
98+
if(loc === 'RUS' || loc === 'FJI') {
99+
// Russia and Fiji have landmasses that cross the antimeridian,
100+
// we need to add +360 to their longitude coordinates, so that
101+
// polygon 'contains' doesn't get confused when crossing the antimeridian.
102+
//
103+
// Note that other countries have polygons on either side of the antimeridian
104+
// (e.g. some Aleutian island for the USA), but those don't confuse
105+
// the 'contains' method; these are skipped here.
106+
appendPolygon = function(_pts) {
107+
var pts;
108+
109+
if(doesCrossAntiMerdian(_pts) === null) {
110+
pts = _pts;
111+
} else {
112+
pts = new Array(_pts.length);
113+
for(m = 0; m < _pts.length; m++) {
114+
// do not mutate calcdata[i][j].geojson !!
115+
pts[m] = [
116+
_pts[m][0] < 0 ? _pts[m][0] + 360 : _pts[m][0],
117+
_pts[m][1]
118+
];
119+
}
120+
}
121+
122+
polygons.push(polygon.tester(pts));
123+
};
124+
} else if(loc === 'ATA') {
125+
// Antarctica has a landmass that wraps around every longitudes which
126+
// confuses the 'contains' methods.
127+
appendPolygon = function(pts) {
128+
var crossAntiMeridianIndex = doesCrossAntiMerdian(pts);
129+
130+
// polygon that do not cross anti-meridian need no special handling
131+
if(crossAntiMeridianIndex === null) {
132+
return polygons.push(polygon.tester(pts));
133+
}
134+
135+
// stitch polygon by adding pt over South Pole,
136+
// so that it covers the projected region covers all latitudes
137+
//
138+
// Note that the algorithm below only works for polygons that
139+
// start and end on longitude -180 (like the ones built by
140+
// https://github.com/etpinard/sane-topojson).
141+
var stitch = new Array(pts.length + 1);
142+
var si = 0;
143+
144+
for(m = 0; m < pts.length; m++) {
145+
if(m > crossAntiMeridianIndex) {
146+
stitch[si++] = [pts[m][0] + 360, pts[m][1]];
147+
} else if(m === crossAntiMeridianIndex) {
148+
stitch[si++] = pts[m];
149+
stitch[si++] = [pts[m][0], -90];
150+
} else {
151+
stitch[si++] = pts[m];
152+
}
153+
}
154+
155+
// polygon.tester by default appends pt[0] to the points list,
156+
// we must remove it here, to avoid a jump in longitude from 180 to -180,
157+
// that would confuse the 'contains' method
158+
var tester = polygon.tester(stitch);
159+
tester.pts.pop();
160+
polygons.push(tester);
161+
};
162+
} else {
163+
// otherwise using same array ref is fine
164+
appendPolygon = function(pts) {
165+
polygons.push(polygon.tester(pts));
166+
};
167+
}
168+
169+
switch(geometry.type) {
170+
case 'MultiPolygon':
171+
for(j = 0; j < coords.length; j++) {
172+
for(k = 0; k < coords[j].length; k++) {
173+
appendPolygon(coords[j][k]);
174+
}
175+
}
176+
break;
177+
case 'Polygon':
178+
for(j = 0; j < coords.length; j++) {
179+
appendPolygon(coords[j]);
180+
}
181+
break;
182+
}
183+
184+
return polygons;
185+
}
186+
187+
function extractTraceFeature(calcTrace) {
188+
var trace = calcTrace[0].trace;
189+
190+
var geojsonIn = typeof trace.geojson === 'string' ?
191+
(window.PlotlyGeoAssets || {})[trace.geojson] :
192+
trace.geojson;
193+
194+
// This should not happen, but just in case something goes
195+
// really wrong when fetching the GeoJSON
196+
if(!isPlainObject(geojsonIn)) {
197+
loggers.error('Oops ... something when wrong when fetching ' + trace.geojson);
198+
return false;
199+
}
200+
201+
var lookup = {};
202+
var featuresOut = [];
203+
var i;
204+
205+
for(i = 0; i < trace._length; i++) {
206+
var cdi = calcTrace[i];
207+
if(cdi.loc) lookup[cdi.loc] = cdi;
208+
}
209+
210+
function appendFeature(fIn) {
211+
var cdi = lookup[fIn.id];
212+
213+
if(cdi) {
214+
var geometry = fIn.geometry;
215+
216+
if(geometry.type === 'Polygon' || geometry.type === 'MultiPolygon') {
217+
var fOut = {
218+
type: 'Feature',
219+
geometry: geometry,
220+
properties: {}
221+
};
222+
223+
// Compute centroid, add it to the properties
224+
fOut.properties.ct = findCentroid(fOut);
225+
226+
// Mutate in in/out features into calcdata
227+
cdi.fIn = fIn;
228+
cdi.fOut = fOut;
229+
230+
featuresOut.push(fOut);
231+
} else {
232+
loggers.log([
233+
'Location with id', cdi.loc, 'does not have a valid GeoJSON geometry,',
234+
'choroplethmapbox traces only support *Polygon* and *MultiPolygon* geometries.'
235+
].join(' '));
236+
}
237+
}
238+
239+
// remove key from lookup, so that we can track (if any)
240+
// the locations that did not have a corresponding GeoJSON feature
241+
delete lookup[fIn.id];
242+
}
243+
244+
switch(geojsonIn.type) {
245+
case 'FeatureCollection':
246+
var featuresIn = geojsonIn.features;
247+
for(i = 0; i < featuresIn.length; i++) {
248+
appendFeature(featuresIn[i]);
249+
}
250+
break;
251+
case 'Feature':
252+
appendFeature(geojsonIn);
253+
break;
254+
default:
255+
loggers.warn([
256+
'Invalid GeoJSON type', (geojsonIn.type || 'none') + ',',
257+
'choroplethmapbox traces only support *FeatureCollection* and *Feature* types.'
258+
].join(' '));
259+
return false;
260+
}
261+
262+
for(var loc in lookup) {
263+
loggers.log('Location with id ' + loc + ' does not have a matching feature');
264+
}
265+
266+
return featuresOut;
267+
}
268+
269+
// TODO this find the centroid of the polygon of maxArea
270+
// (just like we currently do for geo choropleth polygons),
271+
// maybe instead it would make more sense to compute the centroid
272+
// of each polygon and consider those on hover/select
273+
function findCentroid(feature) {
274+
var geometry = feature.geometry;
275+
var poly;
276+
277+
if(geometry.type === 'MultiPolygon') {
278+
var coords = geometry.coordinates;
279+
var maxArea = 0;
280+
281+
for(var i = 0; i < coords.length; i++) {
282+
var polyi = {type: 'Polygon', coordinates: coords[i]};
283+
var area = turfArea.default(polyi);
284+
if(area > maxArea) {
285+
maxArea = area;
286+
poly = polyi;
287+
}
288+
}
289+
} else {
290+
poly = geometry;
291+
}
292+
293+
return turfCentroid.default(poly).geometry.coordinates;
294+
}
295+
296+
function fetchTraceGeoData(calcData) {
297+
var PlotlyGeoAssets = window.PlotlyGeoAssets || {};
298+
var promises = [];
299+
300+
function fetch(url) {
301+
return new Promise(function(resolve, reject) {
302+
d3.json(url, function(err, d) {
303+
if(err) {
304+
delete PlotlyGeoAssets[url];
305+
var msg = err.status === 404 ?
306+
('GeoJSON at URL "' + url + '" does not exist.') :
307+
('Unexpected error while fetching from ' + url);
308+
return reject(new Error(msg));
309+
}
310+
311+
PlotlyGeoAssets[url] = d;
312+
resolve(d);
313+
});
314+
});
315+
}
316+
317+
for(var i = 0; i < calcData.length; i++) {
318+
var trace = calcData[i][0].trace;
319+
var url = trace.geojson;
320+
321+
if(typeof url === 'string' && !PlotlyGeoAssets[url]) {
322+
PlotlyGeoAssets[url] = 'pending';
323+
promises.push(fetch(url));
324+
}
325+
}
326+
327+
return promises;
328+
}
329+
76330
module.exports = {
77-
locationToFeature: locationToFeature
331+
locationToFeature: locationToFeature,
332+
feature2polygons: feature2polygons,
333+
extractTraceFeature: extractTraceFeature,
334+
fetchTraceGeoData: fetchTraceGeoData
78335
};

src/plots/geo/geo.js

+25-22
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ var selectOnClick = require('../cartesian/select').selectOnClick;
2626
var createGeoZoom = require('./zoom');
2727
var constants = require('./constants');
2828

29+
var geoUtils = require('../../lib/geo_location_utils');
2930
var topojsonUtils = require('../../lib/topojson_utils');
3031
var topojsonFeature = require('topojson-client').feature;
3132

@@ -72,6 +73,7 @@ module.exports = function createGeo(opts) {
7273
proto.plot = function(geoCalcData, fullLayout, promises) {
7374
var _this = this;
7475
var geoLayout = fullLayout[this.id];
76+
var geoPromises = [];
7577

7678
var needsTopojson = false;
7779
for(var k in constants.layerNameToAdjective) {
@@ -86,35 +88,34 @@ proto.plot = function(geoCalcData, fullLayout, promises) {
8688
break;
8789
}
8890
}
89-
if(!needsTopojson) {
90-
return _this.update(geoCalcData, fullLayout);
91-
}
9291

93-
var topojsonNameNew = topojsonUtils.getTopojsonName(geoLayout);
92+
if(needsTopojson) {
93+
var topojsonNameNew = topojsonUtils.getTopojsonName(geoLayout);
94+
if(_this.topojson === null || topojsonNameNew !== _this.topojsonName) {
95+
_this.topojsonName = topojsonNameNew;
96+
97+
if(PlotlyGeoAssets.topojson[_this.topojsonName] === undefined) {
98+
geoPromises.push(_this.fetchTopojson());
99+
}
100+
}
101+
}
94102

95-
if(_this.topojson === null || topojsonNameNew !== _this.topojsonName) {
96-
_this.topojsonName = topojsonNameNew;
103+
geoPromises = geoPromises.concat(geoUtils.fetchTraceGeoData(geoCalcData));
97104

98-
if(PlotlyGeoAssets.topojson[_this.topojsonName] === undefined) {
99-
promises.push(_this.fetchTopojson().then(function(topojson) {
100-
PlotlyGeoAssets.topojson[_this.topojsonName] = topojson;
101-
_this.topojson = topojson;
102-
_this.update(geoCalcData, fullLayout);
103-
}));
104-
} else {
105+
promises.push(new Promise(function(resolve, reject) {
106+
Promise.all(geoPromises).then(function() {
105107
_this.topojson = PlotlyGeoAssets.topojson[_this.topojsonName];
106108
_this.update(geoCalcData, fullLayout);
107-
}
108-
} else {
109-
_this.update(geoCalcData, fullLayout);
110-
}
109+
resolve();
110+
})
111+
.catch(reject);
112+
}));
111113
};
112114

113115
proto.fetchTopojson = function() {
114-
var topojsonPath = topojsonUtils.getTopojsonPath(
115-
this.topojsonURL,
116-
this.topojsonName
117-
);
116+
var _this = this;
117+
var topojsonPath = topojsonUtils.getTopojsonPath(_this.topojsonURL, _this.topojsonName);
118+
118119
return new Promise(function(resolve, reject) {
119120
d3.json(topojsonPath, function(err, topojson) {
120121
if(err) {
@@ -132,7 +133,9 @@ proto.fetchTopojson = function() {
132133
].join(' ')));
133134
}
134135
}
135-
resolve(topojson);
136+
137+
PlotlyGeoAssets.topojson[_this.topojsonName] = topojson;
138+
resolve();
136139
});
137140
});
138141
};

0 commit comments

Comments
 (0)