Skip to content

Commit 7aac171

Browse files
authored
Merge pull request #7731 from plotly/cam/7437/add-us-location-name-lookup
feat: Add US location name lookup choropleth, scattergeo traces
2 parents 2dcd2c9 + 4f29b0a commit 7aac171

File tree

7 files changed

+339
-97
lines changed

7 files changed

+339
-97
lines changed

draftlogs/7731_add.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Update USA location lookup for `scattergeo` and `choropleth` traces to use both location names and abbreviations [[7731](https://github.com/plotly/plotly.js/pull/7731)]

src/lib/geo_location_utils.js

Lines changed: 97 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -11,64 +11,69 @@ var loggers = require('./loggers');
1111
var isPlainObject = require('./is_plain_object');
1212
var nestedProperty = require('./nested_property');
1313
var polygon = require('./polygon');
14+
const { usaLocationAbbreviations, usaLocationList } = require('./usa_location_names');
1415

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

1819
var locationmodeToIdFinder = {
1920
'ISO-3': identity,
20-
'USA-states': identity,
21+
'USA-states': usaLocationToAbbreviation,
2122
'country names': countryNameToISO3
2223
};
2324

2425
function countryNameToISO3(countryName) {
25-
for(var i = 0; i < countryIds.length; i++) {
26+
for (var i = 0; i < countryIds.length; i++) {
2627
var iso3 = countryIds[i];
2728
var regex = new RegExp(countryRegex[iso3]);
2829

29-
if(regex.test(countryName.trim().toLowerCase())) return iso3;
30+
if (regex.test(countryName.trim().toLowerCase())) return iso3;
3031
}
3132

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

3435
return false;
3536
}
3637

37-
function locationToFeature(locationmode, location, features) {
38-
if(!location || typeof location !== 'string') return false;
38+
function usaLocationToAbbreviation(loc) {
39+
loc = loc.trim();
40+
const abbreviation = usaLocationAbbreviations.has(loc.toUpperCase())
41+
? loc.toUpperCase()
42+
: usaLocationList[loc.toLowerCase()];
43+
44+
if (abbreviation) return abbreviation;
45+
46+
loggers.log('Unrecognized US location: ' + loc + '.');
47+
48+
return false;
49+
}
3950

40-
var locationId = locationmodeToIdFinder[locationmode](location);
41-
var filteredFeatures;
42-
var f, i;
51+
function locationToFeature(locationmode, location, features) {
52+
if (!location || typeof location !== 'string') return false;
4353

44-
if(locationId) {
45-
if(locationmode === 'USA-states') {
54+
const locationId = locationmodeToIdFinder[locationmode](location);
55+
if (locationId) {
56+
let filteredFeatures;
57+
if (locationmode === 'USA-states') {
4658
// Filter out features out in USA
4759
//
4860
// This is important as the Natural Earth files
4961
// include state/provinces from USA, Canada, Australia and Brazil
5062
// which have some overlay in their two-letter ids. For example,
5163
// 'WA' is used for both Washington state and Western Australia.
5264
filteredFeatures = [];
53-
for(i = 0; i < features.length; i++) {
54-
f = features[i];
55-
if(f.properties && f.properties.gu && f.properties.gu === 'USA') {
56-
filteredFeatures.push(f);
57-
}
65+
for (const f of features) {
66+
if (f?.properties?.gu === 'USA') filteredFeatures.push(f);
5867
}
5968
} else {
6069
filteredFeatures = features;
6170
}
6271

63-
for(i = 0; i < filteredFeatures.length; i++) {
64-
f = filteredFeatures[i];
65-
if(f.id === locationId) return f;
72+
for (const f of filteredFeatures) {
73+
if (f.id === locationId) return f;
6674
}
6775

68-
loggers.log([
69-
'Location with id', locationId,
70-
'does not have a matching topojson feature at this resolution.'
71-
].join(' '));
76+
loggers.log(`Location with id ${locationId} does not have a matching topojson feature at this resolution.`);
7277
}
7378

7479
return false;
@@ -83,46 +88,43 @@ function feature2polygons(feature) {
8388
var appendPolygon, j, k, m;
8489

8590
function doesCrossAntiMerdian(pts) {
86-
for(var l = 0; l < pts.length - 1; l++) {
87-
if(pts[l][0] > 0 && pts[l + 1][0] < 0) return l;
91+
for (var l = 0; l < pts.length - 1; l++) {
92+
if (pts[l][0] > 0 && pts[l + 1][0] < 0) return l;
8893
}
8994
return null;
9095
}
9196

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

103-
if(doesCrossAntiMerdian(_pts) === null) {
108+
if (doesCrossAntiMerdian(_pts) === null) {
104109
pts = _pts;
105110
} else {
106111
pts = new Array(_pts.length);
107-
for(m = 0; m < _pts.length; m++) {
112+
for (m = 0; m < _pts.length; m++) {
108113
// do not mutate calcdata[i][j].geojson !!
109-
pts[m] = [
110-
_pts[m][0] < 0 ? _pts[m][0] + 360 : _pts[m][0],
111-
_pts[m][1]
112-
];
114+
pts[m] = [_pts[m][0] < 0 ? _pts[m][0] + 360 : _pts[m][0], _pts[m][1]];
113115
}
114116
}
115117

116118
polygons.push(polygon.tester(pts));
117119
};
118-
} else if(loc === 'ATA') {
120+
} else if (loc === 'ATA') {
119121
// Antarctica has a landmass that wraps around every longitudes which
120122
// confuses the 'contains' methods.
121-
appendPolygon = function(pts) {
123+
appendPolygon = function (pts) {
122124
var crossAntiMeridianIndex = doesCrossAntiMerdian(pts);
123125

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

@@ -135,10 +137,10 @@ function feature2polygons(feature) {
135137
var stitch = new Array(pts.length + 1);
136138
var si = 0;
137139

138-
for(m = 0; m < pts.length; m++) {
139-
if(m > crossAntiMeridianIndex) {
140+
for (m = 0; m < pts.length; m++) {
141+
if (m > crossAntiMeridianIndex) {
140142
stitch[si++] = [pts[m][0] + 360, pts[m][1]];
141-
} else if(m === crossAntiMeridianIndex) {
143+
} else if (m === crossAntiMeridianIndex) {
142144
stitch[si++] = pts[m];
143145
stitch[si++] = [pts[m][0], -90];
144146
} else {
@@ -155,21 +157,21 @@ function feature2polygons(feature) {
155157
};
156158
} else {
157159
// otherwise using same array ref is fine
158-
appendPolygon = function(pts) {
160+
appendPolygon = function (pts) {
159161
polygons.push(polygon.tester(pts));
160162
};
161163
}
162164

163-
switch(geometry.type) {
165+
switch (geometry.type) {
164166
case 'MultiPolygon':
165-
for(j = 0; j < coords.length; j++) {
166-
for(k = 0; k < coords[j].length; k++) {
167+
for (j = 0; j < coords.length; j++) {
168+
for (k = 0; k < coords[j].length; k++) {
167169
appendPolygon(coords[j][k]);
168170
}
169171
}
170172
break;
171173
case 'Polygon':
172-
for(j = 0; j < coords.length; j++) {
174+
for (j = 0; j < coords.length; j++) {
173175
appendPolygon(coords[j]);
174176
}
175177
break;
@@ -185,7 +187,7 @@ function getTraceGeojson(trace) {
185187

186188
// This should not happen, but just in case something goes
187189
// really wrong when fetching the GeoJSON
188-
if(!isPlainObject(geojsonIn)) {
190+
if (!isPlainObject(geojsonIn)) {
189191
loggers.error('Oops ... something went wrong when fetching ' + g);
190192
return false;
191193
}
@@ -197,15 +199,15 @@ function extractTraceFeature(calcTrace) {
197199
var trace = calcTrace[0].trace;
198200

199201
var geojsonIn = getTraceGeojson(trace);
200-
if(!geojsonIn) return false;
202+
if (!geojsonIn) return false;
201203

202204
var lookup = {};
203205
var featuresOut = [];
204206
var i;
205207

206-
for(i = 0; i < trace._length; i++) {
208+
for (i = 0; i < trace._length; i++) {
207209
var cdi = calcTrace[i];
208-
if(cdi.loc || cdi.loc === 0) {
210+
if (cdi.loc || cdi.loc === 0) {
209211
lookup[cdi.loc] = cdi;
210212
}
211213
}
@@ -214,10 +216,10 @@ function extractTraceFeature(calcTrace) {
214216
var id = nestedProperty(fIn, trace.featureidkey || 'id').get();
215217
var cdi = lookup[id];
216218

217-
if(cdi) {
219+
if (cdi) {
218220
var geometry = fIn.geometry;
219221

220-
if(geometry.type === 'Polygon' || geometry.type === 'MultiPolygon') {
222+
if (geometry.type === 'Polygon' || geometry.type === 'MultiPolygon') {
221223
var fOut = {
222224
type: 'Feature',
223225
id: id,
@@ -238,11 +240,15 @@ function extractTraceFeature(calcTrace) {
238240

239241
featuresOut.push(fOut);
240242
} else {
241-
loggers.log([
242-
'Location', cdi.loc, 'does not have a valid GeoJSON geometry.',
243-
'Traces with locationmode *geojson-id* only support',
244-
'*Polygon* and *MultiPolygon* geometries.'
245-
].join(' '));
243+
loggers.log(
244+
[
245+
'Location',
246+
cdi.loc,
247+
'does not have a valid GeoJSON geometry.',
248+
'Traces with locationmode *geojson-id* only support',
249+
'*Polygon* and *MultiPolygon* geometries.'
250+
].join(' ')
251+
);
246252
}
247253
}
248254

@@ -251,31 +257,36 @@ function extractTraceFeature(calcTrace) {
251257
delete lookup[id];
252258
}
253259

254-
switch(geojsonIn.type) {
260+
switch (geojsonIn.type) {
255261
case 'FeatureCollection':
256262
var featuresIn = geojsonIn.features;
257-
for(i = 0; i < featuresIn.length; i++) {
263+
for (i = 0; i < featuresIn.length; i++) {
258264
appendFeature(featuresIn[i]);
259265
}
260266
break;
261267
case 'Feature':
262268
appendFeature(geojsonIn);
263269
break;
264270
default:
265-
loggers.warn([
266-
'Invalid GeoJSON type', (geojsonIn.type || 'none') + '.',
267-
'Traces with locationmode *geojson-id* only support',
268-
'*FeatureCollection* and *Feature* types.'
269-
].join(' '));
271+
loggers.warn(
272+
[
273+
'Invalid GeoJSON type',
274+
(geojsonIn.type || 'none') + '.',
275+
'Traces with locationmode *geojson-id* only support',
276+
'*FeatureCollection* and *Feature* types.'
277+
].join(' ')
278+
);
270279
return false;
271280
}
272281

273-
for(var loc in lookup) {
274-
loggers.log([
275-
'Location *' + loc + '*',
276-
'does not have a matching feature with id-key',
277-
'*' + trace.featureidkey + '*.'
278-
].join(' '));
282+
for (var loc in lookup) {
283+
loggers.log(
284+
[
285+
'Location *' + loc + '*',
286+
'does not have a matching feature with id-key',
287+
'*' + trace.featureidkey + '*.'
288+
].join(' ')
289+
);
279290
}
280291

281292
return featuresOut;
@@ -289,14 +300,14 @@ function findCentroid(feature) {
289300
var geometry = feature.geometry;
290301
var poly;
291302

292-
if(geometry.type === 'MultiPolygon') {
303+
if (geometry.type === 'MultiPolygon') {
293304
var coords = geometry.coordinates;
294305
var maxArea = 0;
295306

296-
for(var i = 0; i < coords.length; i++) {
297-
var polyi = {type: 'Polygon', coordinates: coords[i]};
307+
for (var i = 0; i < coords.length; i++) {
308+
var polyi = { type: 'Polygon', coordinates: coords[i] };
298309
var area = turfArea(polyi);
299-
if(area > maxArea) {
310+
if (area > maxArea) {
300311
maxArea = area;
301312
poly = polyi;
302313
}
@@ -313,13 +324,14 @@ function fetchTraceGeoData(calcData) {
313324
var promises = [];
314325

315326
function fetch(url) {
316-
return new Promise(function(resolve, reject) {
317-
d3.json(url, function(err, d) {
318-
if(err) {
327+
return new Promise(function (resolve, reject) {
328+
d3.json(url, function (err, d) {
329+
if (err) {
319330
delete PlotlyGeoAssets[url];
320-
var msg = err.status === 404 ?
321-
('GeoJSON at URL "' + url + '" does not exist.') :
322-
('Unexpected error while fetching from ' + url);
331+
var msg =
332+
err.status === 404
333+
? 'GeoJSON at URL "' + url + '" does not exist.'
334+
: 'Unexpected error while fetching from ' + url;
323335
return reject(new Error(msg));
324336
}
325337

@@ -330,14 +342,14 @@ function fetchTraceGeoData(calcData) {
330342
}
331343

332344
function wait(url) {
333-
return new Promise(function(resolve, reject) {
345+
return new Promise(function (resolve, reject) {
334346
var cnt = 0;
335-
var interval = setInterval(function() {
336-
if(PlotlyGeoAssets[url] && PlotlyGeoAssets[url] !== 'pending') {
347+
var interval = setInterval(function () {
348+
if (PlotlyGeoAssets[url] && PlotlyGeoAssets[url] !== 'pending') {
337349
clearInterval(interval);
338350
return resolve(PlotlyGeoAssets[url]);
339351
}
340-
if(cnt > 100) {
352+
if (cnt > 100) {
341353
clearInterval(interval);
342354
return reject('Unexpected error while fetching from ' + url);
343355
}
@@ -346,15 +358,15 @@ function fetchTraceGeoData(calcData) {
346358
});
347359
}
348360

349-
for(var i = 0; i < calcData.length; i++) {
361+
for (var i = 0; i < calcData.length; i++) {
350362
var trace = calcData[i][0].trace;
351363
var url = trace.geojson;
352364

353-
if(typeof url === 'string') {
354-
if(!PlotlyGeoAssets[url]) {
365+
if (typeof url === 'string') {
366+
if (!PlotlyGeoAssets[url]) {
355367
PlotlyGeoAssets[url] = 'pending';
356368
promises.push(fetch(url));
357-
} else if(PlotlyGeoAssets[url] === 'pending') {
369+
} else if (PlotlyGeoAssets[url] === 'pending') {
358370
promises.push(wait(url));
359371
}
360372
}

0 commit comments

Comments
 (0)