plotly.js
Version:
The open source javascript graphing library that powers plotly
518 lines (449 loc) • 17.8 kB
JavaScript
;
var d3 = require('@plotly/d3');
const { COUNTRIES, createLookup } = require('country-iso-search');
var { area: turfArea } = require('@turf/area');
var { centroid: turfCentroid } = require('@turf/centroid');
const { coordAll } = require('@turf/meta');
const { geoBounds } = require('d3-geo');
var identity = require('./identity');
var loggers = require('./loggers');
var isPlainObject = require('./is_plain_object');
var nestedProperty = require('./nested_property');
var polygon = require('./polygon');
const { usaLocationAbbreviations, usaLocationList } = require('./usa_location_names');
const { COUNTRIES_X } = require('./custom_country_codes');
const { lookupAlpha3 } = createLookup([...COUNTRIES, ...COUNTRIES_X]);
var locationmodeToIdFinder = {
'ISO-3': identity,
'USA-states': usaLocationToAbbreviation,
'country names': countryNameToISO3
};
function countryNameToISO3(countryName) {
const iso3 = lookupAlpha3(countryName);
if (iso3) return iso3;
loggers.log('Unrecognized country name: ' + countryName + '.');
return false;
}
function usaLocationToAbbreviation(loc) {
loc = loc.trim();
const abbreviation = usaLocationAbbreviations.has(loc.toUpperCase())
? loc.toUpperCase()
: usaLocationList[loc.toLowerCase()];
if (abbreviation) return abbreviation;
loggers.log('Unrecognized US location: ' + loc + '.');
return false;
}
function locationToFeature(locationmode, location, features) {
if (!location || typeof location !== 'string') return false;
const locationId = locationmodeToIdFinder[locationmode](location);
if (locationId) {
let filteredFeatures;
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 (const f of features) {
if (f?.properties?.gu === 'USA') filteredFeatures.push(f);
}
} else {
filteredFeatures = features;
}
for (const f of filteredFeatures) {
if (f.id === locationId) return f;
}
loggers.log(`Location with id ${locationId} does not have a matching topojson feature at this resolution.`);
}
return false;
}
// Offset used to lift negative longitudes (-180..0) into a continuous frame
// (180..360) so polygons and points that straddle the antimeridian can be
// compared with linear math. Shared between polygon stitching and hover
// hit-testing so both sides stay in sync.
const ANTIMERIDIAN_LON_SHIFT = 360;
/**
* Find the first index where a polygon ring crosses the antimeridian
* (a transition from positive to negative longitude between consecutive
* points). Returns null when no crossing is found.
*
* @param {Array<Array<number>>} pts - polygon points as [lon, lat] pairs
* @return {number|null} index of the segment that crosses, or null
*/
function doesCrossAntiMeridian(pts) {
for (let l = 0; l < pts.length - 1; l++) {
if (pts[l][0] > 0 && pts[l + 1][0] < 0) return l;
}
return null;
}
function feature2polygons(feature) {
var geometry = feature.geometry;
var coords = geometry.coordinates;
var loc = feature.id;
var polygons = [];
var appendPolygon, j, k, m;
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 (doesCrossAntiMeridian(_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] + ANTIMERIDIAN_LON_SHIFT : _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 = doesCrossAntiMeridian(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] + ANTIMERIDIAN_LON_SHIFT, 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: {}
};
fOut.properties.ct = findCentroid(fOut);
if (isNaN(fOut.properties.ct[0])) {
loggers.log(
[
'Location',
cdi.loc,
'has no polygon with positive area.',
'Its centroid could not be computed,',
'so hover and selection will not work for it.'
].join(' ')
);
}
// 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(polyi);
if (area > maxArea) {
maxArea = area;
poly = polyi;
}
}
} else {
poly = geometry;
}
// Guard against MultiPolygons that don't contain a positive-area polygon
// (collapsed rings measure zero, malformed ring ordering measures negative)
// and when either geometry type has rings holding no points at all.
if (!poly || !poly.coordinates.some((ring) => ring.length > 0)) return [NaN, NaN];
return turfCentroid(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;
}
/**
* Compute a `[west, south, east, north]` bounding box for a GeoJSON object
* (Feature, Geometry, FeatureCollection, or GeometryCollection). This function
* handles geometry that crosses the antimeridian. `north`/`south` will be in the
* range `[-90, 90]`; `west` will typically be in the range `[-180, 180]`; `east`
* will typically be in the range `[-180, 180]`, but when the input crosses the
* antimeridian, it will be shifted by +360° so the range will be `[180, west + 360)`.
*
* @param {object} d - a GeoJSON Feature, Geometry, FeatureCollection, or
* GeometryCollection.
* @return {[number, number, number, number]|null} `[west, south, east, north]`
* in degrees; `east` may exceed 180° when the input crosses ±180°.
* Returns `null` for input with no extractable coordinates (e.g. `Sphere`,
* empty FeatureCollection).
*/
const computeBbox = (d) => boundsOfCoords(coordsOf(d));
/**
* Return every coordinate contained in a GeoJSON object.
*
* @param {object} d - a GeoJSON Feature, Geometry, FeatureCollection, or
* GeometryCollection.
* @return {Array} `[lon, lat]` pairs. Empty for input with nothing extractable:
* coordAll throws on a Sphere, on malformed input and on nullish values, and
* returns nothing for an empty collection.
*/
function coordsOf(d) {
try {
return coordAll(d);
} catch (_) {
return [];
}
}
/**
* Bounding box of a list of coordinates, as `computeBbox` describes.
*
* @param {Array} points - `[lon, lat]` pairs
* @return {[number, number, number, number]|null} `[west, south, east, north]`,
* or null when there are no points.
*/
function boundsOfCoords(points) {
if (points.length === 0) return null;
if (points.length === 1) {
const [lon, lat] = points[0];
return [lon, lat, lon, lat];
}
// Pass as MultiPoint (just a bunch of vertices) to avoid
// geobounds treating collection as polygons
const [[west, south], [east, north]] = geoBounds({ type: 'MultiPoint', coordinates: points });
return [
west,
south,
unwrapLonRange([west, east])[1], // Unwrap antimeridian crossing; east may exceed 180°
north
];
}
const usesFitGeojson = (trace, geoLayout) => geoLayout.fitbounds === 'geojson' && trace.locationmode === 'geojson-id';
/**
* Coordinates of a trace's whole geojson, for the `fitbounds: 'geojson'` mode.
*
* @param {object} trace - a `fullData` trace
* @param {object} geoLayout - the subplot's `fullLayout` entry
* @return {Array} `[lon, lat]` pairs. Empty when the trace is in another mode, or
* when the geojson has nothing extractable.
*/
const fitGeojsonCoords = (trace, geoLayout) =>
usesFitGeojson(trace, geoLayout) ? coordsOf(getTraceGeojson(trace)) : [];
/**
* Bounding box of a trace's whole geojson, for the `fitbounds: 'geojson'` mode.
*
* @param {object} trace - a `fullData` trace
* @param {object} geoLayout - the subplot's `fullLayout` entry
* @return {Array|null} `[west, south, east, north]`, or null whenever
* `fitGeojsonCoords` is empty.
*/
const fitGeojsonBbox = (trace, geoLayout) => boundsOfCoords(fitGeojsonCoords(trace, geoLayout));
/**
* Return an unwrapped version of a `[lon0, lon1]` longitude range.
* When the range crosses the antimeridian (`lon0 > lon1`),
* 360 is added to `lon1` to produce a continuous range;
* otherwise the input pair is returned unchanged. Function assumes
* `lon0` is west of `lon1`.
*
* @example
* unwrapLonRange([170, -170]) // → [170, 190] (span = 20°, midpoint = 180°)
* unwrapLonRange([-10, 20]) // → [-10, 20] (no crossing, passthrough)
* unwrapLonRange([-5, -170]) // → [-5, 190] (mixed-sign crossing, e.g. from geoBounds)
*
* @param {[number, number]} lonRange - `[lon0, lon1]`, each in the range [-180, 180]
* @return {[number, number]} The unwrapped range; when the input contract is
* respected, `lon1` falls in the range `[lon0, lon0 + 360)`.
*/
function unwrapLonRange([lon0, lon1]) {
return [lon0, lon0 > lon1 ? lon1 + ANTIMERIDIAN_LON_SHIFT : lon1];
}
module.exports = {
locationToFeature,
feature2polygons,
getTraceGeojson,
extractTraceFeature,
fetchTraceGeoData,
boundsOfCoords,
computeBbox,
coordsOf,
doesCrossAntiMeridian,
fitGeojsonBbox,
fitGeojsonCoords,
unwrapLonRange,
ANTIMERIDIAN_LON_SHIFT
};