plotly.js
Version:
The open source javascript graphing library that powers plotly
384 lines (326 loc) • 12.3 kB
JavaScript
/**
* Copyright 2012-2020, Plotly, Inc.
* All rights reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
;
var d3 = require('d3');
var countryRegex = require('country-regex');
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': identity,
'USA-states': identity,
'country names': countryNameToISO3
};
function countryNameToISO3(countryName) {
for(var i = 0; i < countryIds.length; i++) {
var iso3 = countryIds[i];
var regex = new RegExp(countryRegex[iso3]);
if(regex.test(countryName.trim().toLowerCase())) return iso3;
}
loggers.log('Unrecognized country name: ' + countryName + '.');
return false;
}
function locationToFeature(locationmode, location, features) {
if(!location || typeof location !== 'string') return false;
var locationId = locationmodeToIdFinder[locationmode](location);
var filteredFeatures;
var f, i;
if(locationId) {
if(locationmode === 'USA-states') {
// Filter out features out in USA
//
// This is important as the Natural Earth files
// include state/provinces from USA, Canada, Australia and Brazil
// which have some overlay in their two-letter ids. For example,
// 'WA' is used for both Washington state and Western Australia.
filteredFeatures = [];
for(i = 0; i < features.length; i++) {
f = features[i];
if(f.properties && f.properties.gu && f.properties.gu === 'USA') {
filteredFeatures.push(f);
}
}
} else {
filteredFeatures = features;
}
for(i = 0; i < filteredFeatures.length; i++) {
f = filteredFeatures[i];
if(f.id === locationId) return f;
}
loggers.log([
'Location with id', locationId,
'does not have a matching topojson feature at this resolution.'
].join(' '));
}
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 g = trace.geojson;
var PlotlyGeoAssets = window.PlotlyGeoAssets || {};
var geojsonIn = typeof g === 'string' ? PlotlyGeoAssets[g] : g;
// This should not happen, but just in case something goes
// really wrong when fetching the GeoJSON
if(!isPlainObject(geojsonIn)) {
loggers.error('Oops ... something went wrong when fetching ' + g);
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,
feature2polygons: feature2polygons,
getTraceGeojson: getTraceGeojson,
extractTraceFeature: extractTraceFeature,
fetchTraceGeoData: fetchTraceGeoData,
computeBbox: computeBbox
};