diff --git a/package-lock.json b/package-lock.json index 086aee34d0f..1187a57c025 100644 --- a/package-lock.json +++ b/package-lock.json @@ -140,6 +140,15 @@ "@turf/meta": "6.x" } }, + "@turf/bbox": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.0.1.tgz", + "integrity": "sha512-EGgaRLettBG25Iyx7VyUINsPpVj1x3nFQFiGS3ER8KCI1MximzNLsam3eXRabqQDjyAKyAE1bJ4EZEpGvspQxw==", + "requires": { + "@turf/helpers": "6.x", + "@turf/meta": "6.x" + } + }, "@turf/centroid": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-6.0.2.tgz", diff --git a/package.json b/package.json index 70d0c3ea466..435c1d80bf3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js index bc9e259f1fc..a569748cbb4 100644 --- a/src/components/modebar/buttons.js +++ b/src/components/modebar/buttons.js @@ -303,7 +303,7 @@ function handleDrag3d(gd, ev) { var button = ev.currentTarget; var attr = button.getAttribute('data-attr'); var val = button.getAttribute('data-val') || true; - var sceneIds = gd._fullLayout._subplots.gl3d; + var sceneIds = gd._fullLayout._subplots.gl3d || []; var layoutUpdate = {}; var parts = attr.split('.'); @@ -468,7 +468,7 @@ function handleGeo(gd, ev) { var attr = button.getAttribute('data-attr'); var val = button.getAttribute('data-val') || true; var fullLayout = gd._fullLayout; - var geoIds = fullLayout._subplots.geo; + var geoIds = fullLayout._subplots.geo || []; for(var i = 0; i < geoIds.length; i++) { var id = geoIds[i]; @@ -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 = { diff --git a/src/lib/geo_location_utils.js b/src/lib/geo_location_utils.js index 9790c3c7dee..85840ee2658 100644 --- a/src/lib/geo_location_utils.js +++ b/src/lib/geo_location_utils.js @@ -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 }; @@ -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; } @@ -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(' ')); @@ -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 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 + locationToFeature: locationToFeature, + feature2polygons: feature2polygons, + getTraceGeojson: getTraceGeojson, + extractTraceFeature: extractTraceFeature, + fetchTraceGeoData: fetchTraceGeoData, + computeBbox: computeBbox }; diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index b675866b517..9e4bf22f47b 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -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/}, diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js index 25f84dc5a8d..9d4df261169 100644 --- a/src/plots/geo/geo.js +++ b/src/plots/geo/geo.js @@ -19,6 +19,7 @@ var Drawing = require('../../components/drawing'); var Fx = require('../../components/fx'); var Plots = require('../plots'); var Axes = require('../cartesian/axes'); +var getAutoRange = require('../cartesian/autorange').getAutoRange; var dragElement = require('../../components/dragelement'); var prepSelect = require('../cartesian/select').prepSelect; var selectOnClick = require('../cartesian/select').selectOnClick; @@ -26,6 +27,7 @@ var selectOnClick = require('../cartesian/select').selectOnClick; var createGeoZoom = require('./zoom'); var constants = require('./constants'); +var geoUtils = require('../../lib/geo_location_utils'); var topojsonUtils = require('../../lib/topojson_utils'); var topojsonFeature = require('topojson-client').feature; @@ -72,6 +74,7 @@ module.exports = function createGeo(opts) { proto.plot = function(geoCalcData, fullLayout, promises) { var _this = this; var geoLayout = fullLayout[this.id]; + var geoPromises = []; var needsTopojson = false; for(var k in constants.layerNameToAdjective) { @@ -86,35 +89,34 @@ proto.plot = function(geoCalcData, fullLayout, promises) { break; } } - if(!needsTopojson) { - return _this.update(geoCalcData, fullLayout); - } - var topojsonNameNew = topojsonUtils.getTopojsonName(geoLayout); + if(needsTopojson) { + var topojsonNameNew = topojsonUtils.getTopojsonName(geoLayout); + if(_this.topojson === null || topojsonNameNew !== _this.topojsonName) { + _this.topojsonName = topojsonNameNew; - if(_this.topojson === null || topojsonNameNew !== _this.topojsonName) { - _this.topojsonName = topojsonNameNew; + if(PlotlyGeoAssets.topojson[_this.topojsonName] === undefined) { + geoPromises.push(_this.fetchTopojson()); + } + } + } - if(PlotlyGeoAssets.topojson[_this.topojsonName] === undefined) { - promises.push(_this.fetchTopojson().then(function(topojson) { - PlotlyGeoAssets.topojson[_this.topojsonName] = topojson; - _this.topojson = topojson; - _this.update(geoCalcData, fullLayout); - })); - } else { + geoPromises = geoPromises.concat(geoUtils.fetchTraceGeoData(geoCalcData)); + + promises.push(new Promise(function(resolve, reject) { + Promise.all(geoPromises).then(function() { _this.topojson = PlotlyGeoAssets.topojson[_this.topojsonName]; _this.update(geoCalcData, fullLayout); - } - } else { - _this.update(geoCalcData, fullLayout); - } + resolve(); + }) + .catch(reject); + })); }; proto.fetchTopojson = function() { - var topojsonPath = topojsonUtils.getTopojsonPath( - this.topojsonURL, - this.topojsonName - ); + var _this = this; + var topojsonPath = topojsonUtils.getTopojsonPath(_this.topojsonURL, _this.topojsonName); + return new Promise(function(resolve, reject) { d3.json(topojsonPath, function(err, topojson) { if(err) { @@ -132,7 +134,9 @@ proto.fetchTopojson = function() { ].join(' '))); } } - resolve(topojson); + + PlotlyGeoAssets.topojson[_this.topojsonName] = topojson; + resolve(); }); }); }; @@ -140,18 +144,24 @@ proto.fetchTopojson = function() { proto.update = function(geoCalcData, fullLayout) { var geoLayout = fullLayout[this.id]; - var hasInvalidBounds = this.updateProjection(fullLayout, geoLayout); - if(hasInvalidBounds) return; - // important: maps with choropleth traces have a different layer order this.hasChoropleth = false; + for(var i = 0; i < geoCalcData.length; i++) { - if(geoCalcData[i][0].trace.type === 'choropleth') { + var calcTrace = geoCalcData[i]; + var trace = calcTrace[0].trace; + + if(trace.type === 'choropleth') { this.hasChoropleth = true; - break; + } + if(trace.visible === true && trace._length > 0) { + trace._module.calcGeoJSON(calcTrace, fullLayout); } } + var hasInvalidBounds = this.updateProjection(geoCalcData, fullLayout); + if(hasInvalidBounds) return; + if(!this.viewInitial || this.scope !== geoLayout.scope) { this.saveViewInitial(geoLayout); } @@ -174,20 +184,19 @@ proto.update = function(geoCalcData, fullLayout) { this.render(); }; -proto.updateProjection = function(fullLayout, geoLayout) { +proto.updateProjection = function(geoCalcData, fullLayout) { + var gd = this.graphDiv; + var geoLayout = fullLayout[this.id]; var gs = fullLayout._size; var domain = geoLayout.domain; var projLayout = geoLayout.projection; - var rotation = projLayout.rotation || {}; - var center = geoLayout.center || {}; - var projection = this.projection = getProjection(geoLayout); + var lonaxis = geoLayout.lonaxis; + var lataxis = geoLayout.lataxis; + var axLon = lonaxis._ax; + var axLat = lataxis._ax; - // set 'pre-fit' projection - projection - .center([center.lon - rotation.lon, center.lat - rotation.lat]) - .rotate([-rotation.lon, -rotation.lat, rotation.roll]) - .parallels(projLayout.parallels); + var projection = this.projection = getProjection(geoLayout); // setup subplot extent [[x0,y0], [x1,y1]] var extent = [[ @@ -198,11 +207,46 @@ proto.updateProjection = function(fullLayout, geoLayout) { gs.t + gs.h * (1 - domain.y[0]) ]]; - var lonaxis = geoLayout.lonaxis; - var lataxis = geoLayout.lataxis; - var rangeBox = makeRangeBox(lonaxis.range, lataxis.range); + var center = geoLayout.center || {}; + var rotation = projLayout.rotation || {}; + var lonaxisRange = lonaxis.range || []; + var lataxisRange = lataxis.range || []; + + if(geoLayout.fitbounds) { + axLon._length = extent[1][0] - extent[0][0]; + axLat._length = extent[1][1] - extent[0][1]; + axLon.range = getAutoRange(gd, axLon); + axLat.range = getAutoRange(gd, axLat); + + var midLon = (axLon.range[0] + axLon.range[1]) / 2; + var midLat = (axLat.range[0] + axLat.range[1]) / 2; + + if(geoLayout._isScoped) { + center = {lon: midLon, lat: midLat}; + } else if(geoLayout._isClipped) { + center = {lon: midLon, lat: midLat}; + rotation = {lon: midLon, lat: midLat, roll: rotation.roll}; + + var projType = projLayout.type; + var lonHalfSpan = (constants.lonaxisSpan[projType] / 2) || 180; + var latHalfSpan = (constants.lataxisSpan[projType] / 2) || 180; + + lonaxisRange = [midLon - lonHalfSpan, midLon + lonHalfSpan]; + lataxisRange = [midLat - latHalfSpan, midLat + latHalfSpan]; + } else { + center = {lon: midLon, lat: midLat}; + rotation = {lon: midLon, lat: rotation.lat, roll: rotation.roll}; + } + } + + // set 'pre-fit' projection + projection + .center([center.lon - rotation.lon, center.lat - rotation.lat]) + .rotate([-rotation.lon, -rotation.lat, rotation.roll]) + .parallels(projLayout.parallels); // fit projection 'scale' and 'translate' to set lon/lat ranges + var rangeBox = makeRangeBox(lonaxisRange, lataxisRange); projection.fitExtent(extent, rangeBox); var b = this.bounds = projection.getBounds(rangeBox); @@ -214,12 +258,11 @@ proto.updateProjection = function(fullLayout, geoLayout) { !isFinite(b[1][0]) || !isFinite(b[1][1]) || isNaN(t[0]) || isNaN(t[0]) ) { - var gd = this.graphDiv; - var attrToUnset = ['projection.rotation', 'center', 'lonaxis.range', 'lataxis.range']; + var attrToUnset = ['fitbounds', 'projection.rotation', 'center', 'lonaxis.range', 'lataxis.range']; var msg = 'Invalid geo settings, relayout\'ing to default view.'; var updateObj = {}; - // clear all attribute that could cause invalid bounds, + // clear all attributes that could cause invalid bounds, // clear viewInitial to update reset-view behavior for(var i = 0; i < attrToUnset.length; i++) { @@ -233,6 +276,23 @@ proto.updateProjection = function(fullLayout, geoLayout) { return msg; } + if(geoLayout.fitbounds) { + var b2 = projection.getBounds(makeRangeBox(axLon.range, axLat.range)); + var k2 = Math.min( + (b[1][0] - b[0][0]) / (b2[1][0] - b2[0][0]), + (b[1][1] - b[0][1]) / (b2[1][1] - b2[0][1]) + ); + + if(isFinite(k2)) { + projection.scale(k2 * s); + } else { + Lib.warn('Something went wrong during' + this.id + 'fitbounds computations.'); + } + } else { + // adjust projection to user setting + projection.scale(projLayout.scale * s); + } + // px coordinates of view mid-point, // useful to update `geo.center` after interactions var midPt = this.midPt = [ @@ -240,9 +300,7 @@ proto.updateProjection = function(fullLayout, geoLayout) { (b[0][1] + b[1][1]) / 2 ]; - // adjust projection to user setting projection - .scale(projLayout.scale * s) .translate([t[0] + (midPt[0] - t[0]), t[1] + (midPt[1] - t[1])]) .clipExtent(b); @@ -537,26 +595,31 @@ proto.saveViewInitial = function(geoLayout) { var projLayout = geoLayout.projection; var rotation = projLayout.rotation || {}; + this.viewInitial = { + 'fitbounds': geoLayout.fitbounds, + 'projection.scale': projLayout.scale + }; + + var extra; if(geoLayout._isScoped) { - this.viewInitial = { + extra = { 'center.lon': center.lon, 'center.lat': center.lat, - 'projection.scale': projLayout.scale }; } else if(geoLayout._isClipped) { - this.viewInitial = { - 'projection.scale': projLayout.scale, + extra = { 'projection.rotation.lon': rotation.lon, 'projection.rotation.lat': rotation.lat }; } else { - this.viewInitial = { + extra = { 'center.lon': center.lon, 'center.lat': center.lat, - 'projection.scale': projLayout.scale, 'projection.rotation.lon': rotation.lon }; } + + Lib.extendFlat(this.viewInitial, extra); }; // [hot code path] (re)draw all paths which depend on the projection diff --git a/src/plots/geo/layout_attributes.js b/src/plots/geo/layout_attributes.js index 6232c40fbf3..d21bded596d 100644 --- a/src/plots/geo/layout_attributes.js +++ b/src/plots/geo/layout_attributes.js @@ -75,6 +75,33 @@ var attrs = module.exports = overrideAll({ ].join(' ') }), + fitbounds: { + valType: 'enumerated', + values: [false, 'locations', 'geojson'], + dflt: false, + role: 'info', + editType: 'plot', + description: [ + 'Determines if this subplot\'s view settings are auto-computed to fit trace data.', + + 'On scoped maps, setting `fitbounds` leads to `center.lon` and `center.lat` getting auto-filled.', + + 'On maps with a non-clipped projection, setting `fitbounds` leads to `center.lon`, `center.lat`,', + 'and `projection.rotation.lon` getting auto-filled.', + + 'On maps with a clipped projection, setting `fitbounds` leads to `center.lon`, `center.lat`,', + '`projection.rotation.lon`, `projection.rotation.lat`, `lonaxis.range` and `lonaxis.range`', + 'getting auto-filled.', + + // TODO we should auto-fill `projection.parallels` for maps + // with conic projection, but how? + + 'If *locations*, only the trace\'s visible locations are considered in the `fitbounds` computations.', + 'If *geojson*, the entire trace input `geojson` (if provided) is considered in the `fitbounds` computations,', + 'Defaults to *false*.' + ].join(' ') + }, + resolution: { valType: 'enumerated', values: [110, 50], @@ -173,6 +200,12 @@ var attrs = module.exports = overrideAll({ ].join(' ') } }, + visible: { + valType: 'boolean', + role: 'info', + dflt: true, + description: 'Sets the default visibility of the base layers.' + }, showcoastlines: { valType: 'boolean', role: 'info', diff --git a/src/plots/geo/layout_defaults.js b/src/plots/geo/layout_defaults.js index 34da1d42edf..c169b050009 100644 --- a/src/plots/geo/layout_defaults.js +++ b/src/plots/geo/layout_defaults.js @@ -6,10 +6,12 @@ * LICENSE file in the root directory of this source tree. */ - 'use strict'; +var Lib = require('../../lib'); var handleSubplotDefaults = require('../subplot_defaults'); +var getSubplotData = require('../get_data').getSubplotData; + var constants = require('./constants'); var layoutAttributes = require('./layout_attributes'); @@ -20,12 +22,14 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { type: 'geo', attributes: layoutAttributes, handleDefaults: handleGeoDefaults, + fullData: fullData, partition: 'y' }); }; -function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce) { - var show; +function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) { + var subplotData = getSubplotData(opts.fullData, 'geo', opts.id); + var traceIndices = subplotData.map(function(t) { return t._expandedIndex; }); var resolution = coerce('resolution'); var scope = coerce('scope'); @@ -39,7 +43,10 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce) { var isScoped = geoLayoutOut._isScoped = (scope !== 'world'); var isConic = geoLayoutOut._isConic = projType.indexOf('conic') !== -1; - geoLayoutOut._isClipped = !!constants.lonaxisSpan[projType]; + var isClipped = geoLayoutOut._isClipped = !!constants.lonaxisSpan[projType]; + + var visible = coerce('visible'); + var show; for(var i = 0; i < axesNames.length; i++) { var axisName = axesNames[i]; @@ -58,15 +65,29 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce) { rangeDflt = [rot - hSpan, rot + hSpan]; } - coerce(axisName + '.range', rangeDflt); + var range = coerce(axisName + '.range', rangeDflt); coerce(axisName + '.tick0'); coerce(axisName + '.dtick', dtickDflt); - show = coerce(axisName + '.showgrid'); + show = coerce(axisName + '.showgrid', !visible ? false : undefined); if(show) { coerce(axisName + '.gridcolor'); coerce(axisName + '.gridwidth'); } + + // mock axis for autorange computations + geoLayoutOut[axisName]._ax = { + type: 'linear', + _id: axisName.slice(0, 3), + _traceIndices: traceIndices, + setScale: Lib.identity, + c2l: Lib.identity, + r2l: Lib.identity, + autorange: true, + range: range.slice(), + _m: 1, + _input: {} + }; } var lonRange = geoLayoutOut.lonaxis.range; @@ -87,13 +108,13 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce) { coerce('projection.rotation.lat', dfltProjRotate[1]); coerce('projection.rotation.roll', dfltProjRotate[2]); - show = coerce('showcoastlines', !isScoped); + show = coerce('showcoastlines', !isScoped && visible); if(show) { coerce('coastlinecolor'); coerce('coastlinewidth'); } - show = coerce('showocean'); + show = coerce('showocean', !visible ? false : undefined); if(show) coerce('oceancolor'); } @@ -121,19 +142,19 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce) { coerce('projection.scale'); - show = coerce('showland'); + show = coerce('showland', !visible ? false : undefined); if(show) coerce('landcolor'); - show = coerce('showlakes'); + show = coerce('showlakes', !visible ? false : undefined); if(show) coerce('lakecolor'); - show = coerce('showrivers'); + show = coerce('showrivers', !visible ? false : undefined); if(show) { coerce('rivercolor'); coerce('riverwidth'); } - show = coerce('showcountries', isScoped && scope !== 'usa'); + show = coerce('showcountries', isScoped && scope !== 'usa' && visible); if(show) { coerce('countrycolor'); coerce('countrywidth'); @@ -143,14 +164,14 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce) { // Only works for: // USA states at 110m // USA states + Canada provinces at 50m - coerce('showsubunits', true); + coerce('showsubunits', visible); coerce('subunitcolor'); coerce('subunitwidth'); } if(!isScoped) { // Does not work in non-world scopes - show = coerce('showframe', true); + show = coerce('showframe', visible); if(show) { coerce('framecolor'); coerce('framewidth'); @@ -158,4 +179,27 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce) { } coerce('bgcolor'); + + var fitBounds = coerce('fitbounds'); + + // clear attributes that will get auto-filled later + if(fitBounds) { + delete geoLayoutOut.projection.scale; + + if(isScoped) { + delete geoLayoutOut.center.lon; + delete geoLayoutOut.center.lat; + } else if(isClipped) { + delete geoLayoutOut.center.lon; + delete geoLayoutOut.center.lat; + delete geoLayoutOut.projection.rotation.lon; + delete geoLayoutOut.projection.rotation.lat; + delete geoLayoutOut.lonaxis.range; + delete geoLayoutOut.lataxis.range; + } else { + delete geoLayoutOut.center.lon; + delete geoLayoutOut.center.lat; + delete geoLayoutOut.projection.rotation.lon; + } + } } diff --git a/src/plots/geo/zoom.js b/src/plots/geo/zoom.js index 66ef7d15fd0..5a8eb4a737a 100644 --- a/src/plots/geo/zoom.js +++ b/src/plots/geo/zoom.js @@ -70,6 +70,7 @@ function sync(geo, projection, cb) { cb(set); set('projection.scale', projection.scale() / geo.fitScale); + set('fitbounds', false); gd.emit('plotly_relayout', eventData); } @@ -179,7 +180,6 @@ function zoomNonClipped(geo, projection) { 'geo.center.lon': center[0], 'geo.center.lat': center[1], 'geo.projection.rotation.lon': -rotate[0] - }); } diff --git a/src/plots/mapbox/mapbox.js b/src/plots/mapbox/mapbox.js index c8ab4b1e650..a435e17e38a 100644 --- a/src/plots/mapbox/mapbox.js +++ b/src/plots/mapbox/mapbox.js @@ -8,18 +8,17 @@ 'use strict'; -/* global PlotlyGeoAssets:false */ - var mapboxgl = require('mapbox-gl'); -var d3 = require('d3'); var Fx = require('../../components/fx'); var Lib = require('../../lib'); +var geoUtils = require('../../lib/geo_location_utils'); var Registry = require('../../registry'); var Axes = require('../cartesian/axes'); var dragElement = require('../../components/dragelement'); var prepSelect = require('../cartesian/select').prepSelect; var selectOnClick = require('../cartesian/select').selectOnClick; + var constants = require('./constants'); var createMapboxLayer = require('./layers'); @@ -130,7 +129,7 @@ proto.createMap = function(calcData, fullLayout, resolve, reject) { map.once('load', resolve); })); - promises = promises.concat(self.fetchMapData(calcData, fullLayout)); + promises = promises.concat(geoUtils.fetchTraceGeoData(calcData)); Promise.all(promises).then(function() { self.fillBelowLookup(calcData, fullLayout); @@ -140,39 +139,6 @@ proto.createMap = function(calcData, fullLayout, resolve, reject) { }).catch(reject); }; -proto.fetchMapData = function(calcData) { - 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; - resolve(d); - }); - }); - } - - for(var i = 0; i < calcData.length; i++) { - var trace = calcData[i][0].trace; - var url = trace.geojson; - - if(typeof url === 'string' && !PlotlyGeoAssets[url]) { - PlotlyGeoAssets[url] = 'pending'; - promises.push(fetch(url)); - } - } - - return promises; -}; - proto.updateMap = function(calcData, fullLayout, resolve, reject) { var self = this; var map = self.map; @@ -196,7 +162,7 @@ proto.updateMap = function(calcData, fullLayout, resolve, reject) { })); } - promises = promises.concat(self.fetchMapData(calcData, fullLayout)); + promises = promises.concat(geoUtils.fetchTraceGeoData(calcData)); Promise.all(promises).then(function() { self.fillBelowLookup(calcData, fullLayout); diff --git a/src/traces/choropleth/attributes.js b/src/traces/choropleth/attributes.js index 413d2438fc5..e57d827760e 100644 --- a/src/traces/choropleth/attributes.js +++ b/src/traces/choropleth/attributes.js @@ -33,6 +33,21 @@ module.exports = extendFlat({ editType: 'calc', description: 'Sets the color values.' }, + geojson: extendFlat({}, scatterGeoAttrs.geojson, { + description: [ + 'Sets optional GeoJSON data associated with this trace.', + 'If not given, the features on the base map are used.', + + 'It can be set as a valid GeoJSON object or as a URL string.', + 'Note that we only accept GeoJSONs of type *FeatureCollection* or *Feature*', + 'with geometries of type *Polygon* or *MultiPolygon*.' + + // TODO add topojson support with additional 'topojsonobject' attr? + // https://github.com/topojson/topojson-specification/blob/master/README.md + ].join(' ') + }), + featureidkey: scatterGeoAttrs.featureidkey, + text: extendFlat({}, scatterGeoAttrs.text, { description: 'Sets the text elements associated with each location.' }), diff --git a/src/traces/choropleth/defaults.js b/src/traces/choropleth/defaults.js index 71a7b39ca7d..71656dc08bb 100644 --- a/src/traces/choropleth/defaults.js +++ b/src/traces/choropleth/defaults.js @@ -6,7 +6,6 @@ * LICENSE file in the root directory of this source tree. */ - 'use strict'; var Lib = require('../../lib'); @@ -28,7 +27,18 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout traceOut._length = Math.min(locations.length, z.length); - coerce('locationmode'); + var geojson = coerce('geojson'); + + var locationmodeDflt; + if((typeof geojson === 'string' && geojson !== '') || Lib.isPlainObject(geojson)) { + locationmodeDflt = 'geojson-id'; + } + + var locationMode = coerce('locationmode', locationmodeDflt); + + if(locationMode === 'geojson-id') { + coerce('featureidkey'); + } coerce('text'); coerce('hovertext'); diff --git a/src/traces/choropleth/event_data.js b/src/traces/choropleth/event_data.js index b672186b1ee..5f260bf39f7 100644 --- a/src/traces/choropleth/event_data.js +++ b/src/traces/choropleth/event_data.js @@ -12,10 +12,12 @@ module.exports = function eventData(out, pt, trace, cd, pointNumber) { out.location = pt.location; out.z = pt.z; + // include feature properties from input geojson var cdi = cd[pointNumber]; - if(cdi.fIn) { + if(cdi.fIn && cdi.fIn.properties) { out.properties = cdi.fIn.properties; } + out.ct = cdi.ct; return out; }; diff --git a/src/traces/choropleth/index.js b/src/traces/choropleth/index.js index 06a265df6c0..4214cf801a1 100644 --- a/src/traces/choropleth/index.js +++ b/src/traces/choropleth/index.js @@ -13,6 +13,7 @@ module.exports = { supplyDefaults: require('./defaults'), colorbar: require('../heatmap/colorbar'), calc: require('./calc'), + calcGeoJSON: require('./plot').calcGeoJSON, plot: require('./plot').plot, style: require('./style').style, styleOnSelect: require('./style').styleOnSelect, diff --git a/src/traces/choropleth/plot.js b/src/traces/choropleth/plot.js index dfb2762fdef..237598b12a5 100644 --- a/src/traces/choropleth/plot.js +++ b/src/traces/choropleth/plot.js @@ -11,18 +11,15 @@ var d3 = require('d3'); var Lib = require('../../lib'); -var polygon = require('../../lib/polygon'); - +var geoUtils = require('../../lib/geo_location_utils'); var getTopojsonFeatures = require('../../lib/topojson_utils').getTopojsonFeatures; -var locationToFeature = require('../../lib/geo_location_utils').locationToFeature; +var findExtremes = require('../../plots/cartesian/autorange').findExtremes; + var style = require('./style').style; function plot(gd, geo, calcData) { - for(var i = 0; i < calcData.length; i++) { - calcGeoJSON(calcData[i], geo.topojson); - } - var choroplethLayer = geo.layers.backplot.select('.choroplethlayer'); + Lib.makeTraceGroups(choroplethLayer, calcData, 'trace choropleth').each(function(calcTrace) { var sel = d3.select(this); @@ -39,131 +36,51 @@ function plot(gd, geo, calcData) { }); } -function calcGeoJSON(calcTrace, topojson) { +function calcGeoJSON(calcTrace, fullLayout) { var trace = calcTrace[0].trace; - var len = calcTrace.length; - var features = getTopojsonFeatures(trace, topojson); + var geoLayout = fullLayout[trace.geo]; + var geo = geoLayout._subplot; + var locationmode = trace.locationmode; + var len = trace._length; + + var features = locationmode === 'geojson-id' ? + geoUtils.extractTraceFeature(calcTrace) : + getTopojsonFeatures(trace, geo.topojson); + + var lonArray = []; + var latArray = []; for(var i = 0; i < len; i++) { var calcPt = calcTrace[i]; - var feature = locationToFeature(trace.locationmode, calcPt.loc, features); - - if(!feature) { + var feature = locationmode === 'geojson-id' ? + calcPt.fOut : + geoUtils.locationToFeature(locationmode, calcPt.loc, features); + + if(feature) { + calcPt.geojson = feature; + calcPt.ct = feature.properties.ct; + calcPt._polygons = geoUtils.feature2polygons(feature); + + var bboxFeature = geoUtils.computeBbox(feature); + lonArray.push(bboxFeature[0], bboxFeature[2]); + latArray.push(bboxFeature[1], bboxFeature[3]); + } else { calcPt.geojson = null; - continue; } - - calcPt.geojson = feature; - calcPt.ct = feature.properties.ct; - calcPt._polygons = feature2polygons(feature); - } -} - -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 nut 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; + if(geoLayout.fitbounds === 'geojson' && locationmode === 'geojson-id') { + var bboxGeojson = geoUtils.computeBbox(geoUtils.getTraceGeojson(trace)); + lonArray = [bboxGeojson[0], bboxGeojson[2]]; + latArray = [bboxGeojson[1], bboxGeojson[3]]; } - return polygons; + var opts = {padded: true}; + trace._extremes.lon = findExtremes(geoLayout.lonaxis._ax, lonArray, opts); + trace._extremes.lat = findExtremes(geoLayout.lataxis._ax, latArray, opts); } module.exports = { - plot: plot, - feature2polygons: feature2polygons + calcGeoJSON: calcGeoJSON, + plot: plot }; diff --git a/src/traces/choroplethmapbox/attributes.js b/src/traces/choroplethmapbox/attributes.js index 08e54b7d830..3191cf8bf0d 100644 --- a/src/traces/choroplethmapbox/attributes.js +++ b/src/traces/choroplethmapbox/attributes.js @@ -28,9 +28,7 @@ module.exports = extendFlat({ // Maybe start with only one value (that we could name e.g. 'geojson-id'), // but eventually: // - we could also support for our own dist/topojson/* - // - some people might want `geojson-properties-name` to map data arrays to - // GeoJSON features - // locationmode: choroplethAttrs.locationmode, + // .. and locationmode: choroplethAttrs.locationmode, z: { valType: 'data_array', @@ -47,11 +45,19 @@ module.exports = extendFlat({ editType: 'calc', description: [ 'Sets the GeoJSON data associated with this trace.', - 'Can be set as a valid GeoJSON object or as URL string', - 'Note that we only accept GeoJSON of type *FeatureCollection* and *Feature*', - 'with geometries of type *Polygon* and *MultiPolygon*.' + + 'It can be set as a valid GeoJSON object or as a URL string.', + 'Note that we only accept GeoJSONs of type *FeatureCollection* or *Feature*', + 'with geometries of type *Polygon* or *MultiPolygon*.' ].join(' ') }, + featureidkey: extendFlat({}, choroplethAttrs.featureidkey, { + description: [ + 'Sets the key in GeoJSON features which is used as id to match the items', + 'included in the `locations` array.', + 'Support nested property, for example *properties.name*.' + ].join(' ') + }), // TODO agree on name / behaviour // diff --git a/src/traces/choroplethmapbox/convert.js b/src/traces/choroplethmapbox/convert.js index 8c1b00fb566..01e21a123aa 100644 --- a/src/traces/choroplethmapbox/convert.js +++ b/src/traces/choroplethmapbox/convert.js @@ -9,15 +9,13 @@ 'use strict'; var isNumeric = require('fast-isnumeric'); -var turfArea = require('@turf/area'); -var turfCentroid = require('@turf/centroid'); var Lib = require('../../lib'); var Colorscale = require('../../components/colorscale'); var Drawing = require('../../components/drawing'); var makeBlank = require('../../lib/geojson_utils').makeBlank; -var feature2polygons = require('../choropleth/plot').feature2polygons; +var geoUtils = require('../../lib/geo_location_utils'); /* N.B. * @@ -52,25 +50,9 @@ function convert(calcTrace) { if(!isVisible) return opts; - var geojsonIn = typeof trace.geojson === 'string' ? - (window.PlotlyGeoAssets || {})[trace.geojson] : - trace.geojson; + var features = geoUtils.extractTraceFeature(calcTrace); - // This should not happen, but just in case something goes - // really wrong when fetching the GeoJSON - if(!Lib.isPlainObject(geojsonIn)) { - Lib.error('Oops ... something when wrong when fetching ' + trace.geojson); - return opts; - } - - var lookup = {}; - var featuresOut = []; - var i; - - for(i = 0; i < calcTrace.length; i++) { - var cdi = calcTrace[i]; - if(cdi.loc) lookup[cdi.loc] = cdi; - } + if(!features) return opts; var sclFunc = Colorscale.makeColorScaleFuncFromTrace(trace); var marker = trace.marker; @@ -94,63 +76,19 @@ function convert(calcTrace) { lineWidthFn = function(d) { return d.mlw; }; } - function appendFeature(fIn) { - var cdi = lookup[fIn.id]; - - if(cdi) { - var geometry = fIn.geometry; - - if(geometry.type === 'Polygon' || geometry.type === 'MultiPolygon') { - var props = {fc: sclFunc(cdi.z)}; - - if(opacityFn) props.mo = opacityFn(cdi); - if(lineColorFn) props.mlc = lineColorFn(cdi); - if(lineWidthFn) props.mlw = lineWidthFn(cdi); - - var fOut = { - type: 'Feature', - geometry: geometry, - properties: props - }; - - cdi._polygons = feature2polygons(fOut); - cdi.ct = findCentroid(fOut); - cdi.fIn = fIn; - cdi.fOut = fOut; - featuresOut.push(fOut); - } else { - Lib.log([ - 'Location with id', cdi.loc, 'does not have a valid GeoJSON geometry,', - 'choroplethmapbox traces only support *Polygon* and *MultiPolygon* geometries.' - ].join(' ')); - } + for(var i = 0; i < calcTrace.length; i++) { + var cdi = calcTrace[i]; + var fOut = cdi.fOut; + + if(fOut) { + var props = fOut.properties; + props.fc = sclFunc(cdi.z); + if(opacityFn) props.mo = opacityFn(cdi); + if(lineColorFn) props.mlc = lineColorFn(cdi); + if(lineWidthFn) props.mlw = lineWidthFn(cdi); + cdi.ct = props.ct; + cdi._polygons = geoUtils.feature2polygons(fOut); } - - // remove key from lookup, so that we can track (if any) - // the locations that did not have a corresponding GeoJSON feature - delete lookup[fIn.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: - Lib.warn([ - 'Invalid GeoJSON type', (geojsonIn.type || 'none') + ',', - 'choroplethmapbox traces only support *FeatureCollection* and *Feature* types.' - ].join(' ')); - return opts; - } - - for(var loc in lookup) { - Lib.log('Location with id ' + loc + ' does not have a matching feature'); } var opacitySetting = opacityFn ? @@ -175,7 +113,7 @@ function convert(calcTrace) { fill.layout.visibility = 'visible'; line.layout.visibility = 'visible'; - opts.geojson = {type: 'FeatureCollection', features: featuresOut}; + opts.geojson = {type: 'FeatureCollection', features: features}; convertOnSelect(calcTrace); @@ -210,33 +148,6 @@ function convertOnSelect(calcTrace) { return opts; } -// 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; -} - module.exports = { convert: convert, convertOnSelect: convertOnSelect diff --git a/src/traces/choroplethmapbox/defaults.js b/src/traces/choroplethmapbox/defaults.js index 050015ce5e1..d75aa3f9b5f 100644 --- a/src/traces/choroplethmapbox/defaults.js +++ b/src/traces/choroplethmapbox/defaults.js @@ -29,6 +29,8 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout return; } + coerce('featureidkey'); + traceOut._length = Math.min(locations.length, z.length); coerce('below'); diff --git a/src/traces/scattergeo/attributes.js b/src/traces/scattergeo/attributes.js index 6c2cfa54a88..0723ea4cfb6 100644 --- a/src/traces/scattergeo/attributes.js +++ b/src/traces/scattergeo/attributes.js @@ -42,12 +42,44 @@ module.exports = overrideAll({ }, locationmode: { valType: 'enumerated', - values: ['ISO-3', 'USA-states', 'country names'], + values: ['ISO-3', 'USA-states', 'country names', 'geojson-id'], role: 'info', dflt: 'ISO-3', description: [ 'Determines the set of locations used to match entries in `locations`', - 'to regions on the map.' + 'to regions on the map.', + 'Values *ISO-3*, *USA-states*, *country names* correspond to features on', + 'the base map and value *geojson-id* corresponds to features from a custom', + 'GeoJSON linked to the `geojson` attribute.' + ].join(' ') + }, + + geojson: { + valType: 'any', + role: 'info', + editType: 'calc', + description: [ + 'Sets optional GeoJSON data associated with this trace.', + 'If not given, the features on the base map are used when `locations` is set.', + + 'It can be set as a valid GeoJSON object or as a URL string.', + 'Note that we only accept GeoJSONs of type *FeatureCollection* or *Feature*', + 'with geometries of type *Polygon* or *MultiPolygon*.' + + // TODO add topojson support with additional 'topojsonobject' attr? + // https://github.com/topojson/topojson-specification/blob/master/README.md + ].join(' ') + }, + featureidkey: { + valType: 'string', + role: 'info', + editType: 'calc', + dflt: 'id', + description: [ + 'Sets the key in GeoJSON features which is used as id to match the items', + 'included in the `locations` array.', + 'Only has an effect when `geojson` is set.', + 'Support nested property, for example *properties.name*.' ].join(' ') }, diff --git a/src/traces/scattergeo/calc.js b/src/traces/scattergeo/calc.js index 537b0562307..c24e811e1c1 100644 --- a/src/traces/scattergeo/calc.js +++ b/src/traces/scattergeo/calc.js @@ -18,17 +18,28 @@ var calcSelection = require('../scatter/calc_selection'); var _ = require('../../lib')._; +function isNonBlankString(v) { + return v && typeof v === 'string'; +} + module.exports = function calc(gd, trace) { var hasLocationData = Array.isArray(trace.locations); var len = hasLocationData ? trace.locations.length : trace._length; var calcTrace = new Array(len); + var isValidLoc; + if(trace.geojson) { + isValidLoc = function(v) { return isNonBlankString(v) || isNumeric(v); }; + } else { + isValidLoc = isNonBlankString; + } + for(var i = 0; i < len; i++) { var calcPt = calcTrace[i] = {}; if(hasLocationData) { var loc = trace.locations[i]; - calcPt.loc = typeof loc === 'string' ? loc : null; + calcPt.loc = isValidLoc(loc) ? loc : null; } else { var lon = trace.lon[i]; var lat = trace.lat[i]; diff --git a/src/traces/scattergeo/defaults.js b/src/traces/scattergeo/defaults.js index f4c8c8fad4f..f706ffd4e69 100644 --- a/src/traces/scattergeo/defaults.js +++ b/src/traces/scattergeo/defaults.js @@ -6,7 +6,6 @@ * LICENSE file in the root directory of this source tree. */ - 'use strict'; var Lib = require('../../lib'); @@ -19,18 +18,41 @@ var handleFillColorDefaults = require('../scatter/fillcolor_defaults'); var attributes = require('./attributes'); - module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout) { function coerce(attr, dflt) { return Lib.coerce(traceIn, traceOut, attributes, attr, dflt); } - var len = handleLonLatLocDefaults(traceIn, traceOut, coerce); + var locations = coerce('locations'); + var len; + + if(locations && locations.length) { + var geojson = coerce('geojson'); + var locationmodeDflt; + if((typeof geojson === 'string' && geojson !== '') || Lib.isPlainObject(geojson)) { + locationmodeDflt = 'geojson-id'; + } + + var locationMode = coerce('locationmode', locationmodeDflt); + + if(locationMode === 'geojson-id') { + coerce('featureidkey'); + } + + len = locations.length; + } else { + var lon = coerce('lon') || []; + var lat = coerce('lat') || []; + len = Math.min(lon.length, lat.length); + } + if(!len) { traceOut.visible = false; return; } + traceOut._length = len; + coerce('text'); coerce('hovertext'); coerce('hovertemplate'); @@ -57,23 +79,3 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout Lib.coerceSelectionMarkerOpacity(traceOut, coerce); }; - -function handleLonLatLocDefaults(traceIn, traceOut, coerce) { - var len = 0; - var locations = coerce('locations'); - - var lon, lat; - - if(locations) { - coerce('locationmode'); - len = locations.length; - return len; - } - - lon = coerce('lon') || []; - lat = coerce('lat') || []; - len = Math.min(lon.length, lat.length); - traceOut._length = len; - - return len; -} diff --git a/src/traces/scattergeo/event_data.js b/src/traces/scattergeo/event_data.js index f52decf21ef..a4b197a6d12 100644 --- a/src/traces/scattergeo/event_data.js +++ b/src/traces/scattergeo/event_data.js @@ -10,10 +10,16 @@ 'use strict'; -module.exports = function eventData(out, pt) { +module.exports = function eventData(out, pt, trace, cd, pointNumber) { out.lon = pt.lon; out.lat = pt.lat; out.location = pt.loc ? pt.loc : null; + // include feature properties from input geojson + var cdi = cd[pointNumber]; + if(cdi.fIn && cdi.fIn.properties) { + out.properties = cdi.fIn.properties; + } + return out; }; diff --git a/src/traces/scattergeo/index.js b/src/traces/scattergeo/index.js index 27a6fe010e2..ad80eef949f 100644 --- a/src/traces/scattergeo/index.js +++ b/src/traces/scattergeo/index.js @@ -14,7 +14,8 @@ module.exports = { colorbar: require('../scatter/marker_colorbar'), formatLabels: require('./format_labels'), calc: require('./calc'), - plot: require('./plot'), + calcGeoJSON: require('./plot').calcGeoJSON, + plot: require('./plot').plot, style: require('./style'), styleOnSelect: require('../scatter/style').styleOnSelect, hoverPoints: require('./hover'), diff --git a/src/traces/scattergeo/plot.js b/src/traces/scattergeo/plot.js index 224ddd9903d..be9f7a4c083 100644 --- a/src/traces/scattergeo/plot.js +++ b/src/traces/scattergeo/plot.js @@ -6,23 +6,24 @@ * LICENSE file in the root directory of this source tree. */ - 'use strict'; var d3 = require('d3'); var Lib = require('../../lib'); -var BADNUM = require('../../constants/numerical').BADNUM; var getTopojsonFeatures = require('../../lib/topojson_utils').getTopojsonFeatures; -var locationToFeature = require('../../lib/geo_location_utils').locationToFeature; var geoJsonUtils = require('../../lib/geojson_utils'); +var geoUtils = require('../../lib/geo_location_utils'); +var findExtremes = require('../../plots/cartesian/autorange').findExtremes; +var BADNUM = require('../../constants/numerical').BADNUM; + +var calcMarkerSize = require('../scatter/calc').calcMarkerSize; var subTypes = require('../scatter/subtypes'); var style = require('./style'); -module.exports = function plot(gd, geo, calcData) { - for(var i = 0; i < calcData.length; i++) { - calcGeoJSON(calcData[i], geo.topojson); - } +function plot(gd, geo, calcData) { + var scatterLayer = geo.layers.frontplot.select('.scatterlayer'); + var gTraces = Lib.makeTraceGroups(scatterLayer, calcData, 'trace scattergeo'); function removeBADNUM(d, node) { if(d.lonlat[0] === BADNUM) { @@ -30,9 +31,6 @@ module.exports = function plot(gd, geo, calcData) { } } - var scatterLayer = geo.layers.frontplot.select('.scatterlayer'); - var gTraces = Lib.makeTraceGroups(scatterLayer, calcData, 'trace scattergeo'); - // TODO find a way to order the inner nodes on update gTraces.selectAll('*').remove(); @@ -73,20 +71,57 @@ module.exports = function plot(gd, geo, calcData) { // call style here within topojson request callback style(gd, calcTrace); }); -}; +} -function calcGeoJSON(calcTrace, topojson) { +function calcGeoJSON(calcTrace, fullLayout) { var trace = calcTrace[0].trace; + var geoLayout = fullLayout[trace.geo]; + var geo = geoLayout._subplot; + var len = trace._length; + var i, calcPt; + + if(Array.isArray(trace.locations)) { + var locationmode = trace.locationmode; + var features = locationmode === 'geojson-id' ? + geoUtils.extractTraceFeature(calcTrace) : + getTopojsonFeatures(trace, geo.topojson); - if(!Array.isArray(trace.locations)) return; + for(i = 0; i < len; i++) { + calcPt = calcTrace[i]; - var features = getTopojsonFeatures(trace, topojson); - var locationmode = trace.locationmode; + var feature = locationmode === 'geojson-id' ? + calcPt.fOut : + geoUtils.locationToFeature(locationmode, calcPt.loc, features); + + calcPt.lonlat = feature ? feature.properties.ct : [BADNUM, BADNUM]; + } + } - for(var i = 0; i < calcTrace.length; i++) { - var calcPt = calcTrace[i]; - var feature = locationToFeature(locationmode, calcPt.loc, features); + var opts = {padded: true}; + var lonArray; + var latArray; + + if(geoLayout.fitbounds === 'geojson' && trace.locationmode === 'geojson-id') { + var bboxGeojson = geoUtils.computeBbox(geoUtils.getTraceGeojson(trace)); + lonArray = [bboxGeojson[0], bboxGeojson[2]]; + latArray = [bboxGeojson[1], bboxGeojson[3]]; + } else { + lonArray = new Array(len); + latArray = new Array(len); + for(i = 0; i < len; i++) { + calcPt = calcTrace[i]; + lonArray[i] = calcPt.lonlat[0]; + latArray[i] = calcPt.lonlat[1]; + } - calcPt.lonlat = feature ? feature.properties.ct : [BADNUM, BADNUM]; + opts.ppad = calcMarkerSize(trace, len); } + + trace._extremes.lon = findExtremes(geoLayout.lonaxis._ax, lonArray, opts); + trace._extremes.lat = findExtremes(geoLayout.lataxis._ax, latArray, opts); } + +module.exports = { + calcGeoJSON: calcGeoJSON, + plot: plot +}; diff --git a/test/image/baselines/geo_custom-geojson.png b/test/image/baselines/geo_custom-geojson.png new file mode 100644 index 00000000000..c5287659445 Binary files /dev/null and b/test/image/baselines/geo_custom-geojson.png differ diff --git a/test/image/baselines/geo_featureidkey.png b/test/image/baselines/geo_featureidkey.png new file mode 100644 index 00000000000..34d2922a3d4 Binary files /dev/null and b/test/image/baselines/geo_featureidkey.png differ diff --git a/test/image/baselines/geo_fitbounds-geojson.png b/test/image/baselines/geo_fitbounds-geojson.png new file mode 100644 index 00000000000..d6c565042ad Binary files /dev/null and b/test/image/baselines/geo_fitbounds-geojson.png differ diff --git a/test/image/baselines/geo_fitbounds-locations.png b/test/image/baselines/geo_fitbounds-locations.png new file mode 100644 index 00000000000..da9bd72700a Binary files /dev/null and b/test/image/baselines/geo_fitbounds-locations.png differ diff --git a/test/image/baselines/geo_fitbounds-scopes.png b/test/image/baselines/geo_fitbounds-scopes.png new file mode 100644 index 00000000000..43b48b5df62 Binary files /dev/null and b/test/image/baselines/geo_fitbounds-scopes.png differ diff --git a/test/image/baselines/mapbox_choropleth-raw-geojson.png b/test/image/baselines/mapbox_choropleth-raw-geojson.png index acb1be264e4..c1b5bc08c5d 100644 Binary files a/test/image/baselines/mapbox_choropleth-raw-geojson.png and b/test/image/baselines/mapbox_choropleth-raw-geojson.png differ diff --git a/test/image/compare_pixels_test.js b/test/image/compare_pixels_test.js index 71973e3e86d..2ada885b019 100644 --- a/test/image/compare_pixels_test.js +++ b/test/image/compare_pixels_test.js @@ -101,7 +101,9 @@ if(allMock || argv.filter) { } var FLAKY_LIST = [ - 'treemap_textposition' + 'treemap_textposition', + 'trace_metatext', + 'gl3d_directions-streamtube1' ]; console.log(''); diff --git a/test/image/mocks/geo_custom-geojson.json b/test/image/mocks/geo_custom-geojson.json new file mode 100644 index 00000000000..1744a0fe629 --- /dev/null +++ b/test/image/mocks/geo_custom-geojson.json @@ -0,0 +1,88 @@ +{ + "data": [ + { + "type": "scattergeo", + "name": "scattergeo + RAW", + "locations": ["AL"], + "geojson": { + "type": "Feature", + "id": "AL", + "geometry": { + "type": "Polygon", + "coordinates": [[ + [-87.359296, 35.00118], [-85.606675, 34.984749], [-85.431413, 34.124869], [-85.184951, 32.859696], + [-85.069935, 32.580372], [-84.960397, 32.421541], [-85.004212, 32.322956], [-84.889196, 32.262709], + [-85.058981, 32.13674], [-85.053504, 32.01077], [-85.141136, 31.840985], [-85.042551, 31.539753], + [-85.113751, 31.27686], [-85.004212, 31.003013], [-85.497137, 30.997536], [-87.600282, 30.997536], + [-87.633143, 30.86609], [-87.408589, 30.674397], [-87.446927, 30.510088], [-87.37025, 30.427934], + [-87.518128, 30.280057], [-87.655051, 30.247195], [-87.90699, 30.411504], [-87.934375, 30.657966], + [-88.011052, 30.685351], [-88.10416, 30.499135], [-88.137022, 30.318396], [-88.394438, 30.367688], + [-88.471115, 31.895754], [-88.241084, 33.796253], [-88.098683, 34.891641], [-88.202745, 34.995703], + [-87.359296, 35.00118] + ]] + } + } + }, + { + "type": "choropleth", + "name": "choropleth + URL", + "locations": ["NY", "MA", "VT"], + "z": [10, 20, 30], + "showscale": false, + "geojson": "https://raw.githubusercontent.com/python-visualization/folium/master/examples/data/us-states.json" + }, + + { + "type": "choropleth", + "name": "choropleth + RAW", + "locations": ["AL"], + "z": [10], + "showscale": false, + "geojson": { + "type": "Feature", + "id": "AL", + "geometry": { + "type": "Polygon", + "coordinates": [[ + [-87.359296, 35.00118], [-85.606675, 34.984749], [-85.431413, 34.124869], [-85.184951, 32.859696], + [-85.069935, 32.580372], [-84.960397, 32.421541], [-85.004212, 32.322956], [-84.889196, 32.262709], + [-85.058981, 32.13674], [-85.053504, 32.01077], [-85.141136, 31.840985], [-85.042551, 31.539753], + [-85.113751, 31.27686], [-85.004212, 31.003013], [-85.497137, 30.997536], [-87.600282, 30.997536], + [-87.633143, 30.86609], [-87.408589, 30.674397], [-87.446927, 30.510088], [-87.37025, 30.427934], + [-87.518128, 30.280057], [-87.655051, 30.247195], [-87.90699, 30.411504], [-87.934375, 30.657966], + [-88.011052, 30.685351], [-88.10416, 30.499135], [-88.137022, 30.318396], [-88.394438, 30.367688], + [-88.471115, 31.895754], [-88.241084, 33.796253], [-88.098683, 34.891641], [-88.202745, 34.995703], + [-87.359296, 35.00118] + ]] + } + }, + "geo": "geo2" + }, + { + "type": "scattergeo", + "name": "scattergeo + URL", + "locations": ["NY", "MA", "VT"], + "geojson": "https://raw.githubusercontent.com/python-visualization/folium/master/examples/data/us-states.json", + "geo": "geo2" + } + ], + "layout": { + "geo": { + "center": { "lon": -74.22, "lat": 42.35 }, + "projection": {"scale": 3} + }, + "geo2": { + "center": { "lon": -74.22, "lat": 42.35 }, + "projection": {"scale": 3} + }, + "width": 600, + "height": 500, + "showlegend": false, + "title": { + "text": "Geo subplots with custom GeoJSON features", + "x": 0.1, + "xref": "container", + "xanchor": "left" + } + } +} diff --git a/test/image/mocks/geo_featureidkey.json b/test/image/mocks/geo_featureidkey.json new file mode 100644 index 00000000000..e9f5a52acc4 --- /dev/null +++ b/test/image/mocks/geo_featureidkey.json @@ -0,0 +1,73 @@ +{ + "data": [ + { + "type": "scattergeo", + "locations": ["AL"], + "featureidkey": "properties.name", + "geojson": { + "type": "Feature", + "properties": { + "name": "AL" + }, + "geometry": { + "type": "Polygon", + "coordinates": [[ + [-87.359296, 35.00118], [-85.606675, 34.984749], [-85.431413, 34.124869], [-85.184951, 32.859696], + [-85.069935, 32.580372], [-84.960397, 32.421541], [-85.004212, 32.322956], [-84.889196, 32.262709], + [-85.058981, 32.13674], [-85.053504, 32.01077], [-85.141136, 31.840985], [-85.042551, 31.539753], + [-85.113751, 31.27686], [-85.004212, 31.003013], [-85.497137, 30.997536], [-87.600282, 30.997536], + [-87.633143, 30.86609], [-87.408589, 30.674397], [-87.446927, 30.510088], [-87.37025, 30.427934], + [-87.518128, 30.280057], [-87.655051, 30.247195], [-87.90699, 30.411504], [-87.934375, 30.657966], + [-88.011052, 30.685351], [-88.10416, 30.499135], [-88.137022, 30.318396], [-88.394438, 30.367688], + [-88.471115, 31.895754], [-88.241084, 33.796253], [-88.098683, 34.891641], [-88.202745, 34.995703], + [-87.359296, 35.00118] + ]] + } + } + }, + { + "type": "choropleth", + "name": "choropleth + RAW", + "locations": ["AL"], + "featureidkey": "properties.id", + "z": [10], + "showscale": false, + "geojson": { + "type": "Feature", + "properties": { + "id": "AL" + }, + "geometry": { + "type": "Polygon", + "coordinates": [[ + [-87.359296, 35.00118], [-85.606675, 34.984749], [-85.431413, 34.124869], [-85.184951, 32.859696], + [-85.069935, 32.580372], [-84.960397, 32.421541], [-85.004212, 32.322956], [-84.889196, 32.262709], + [-85.058981, 32.13674], [-85.053504, 32.01077], [-85.141136, 31.840985], [-85.042551, 31.539753], + [-85.113751, 31.27686], [-85.004212, 31.003013], [-85.497137, 30.997536], [-87.600282, 30.997536], + [-87.633143, 30.86609], [-87.408589, 30.674397], [-87.446927, 30.510088], [-87.37025, 30.427934], + [-87.518128, 30.280057], [-87.655051, 30.247195], [-87.90699, 30.411504], [-87.934375, 30.657966], + [-88.011052, 30.685351], [-88.10416, 30.499135], [-88.137022, 30.318396], [-88.394438, 30.367688], + [-88.471115, 31.895754], [-88.241084, 33.796253], [-88.098683, 34.891641], [-88.202745, 34.995703], + [-87.359296, 35.00118] + ]] + } + } + } + ], + "layout": { + "geo": { + "center": { "lon": -86, "lat": 33 }, + "projection": {"scale": 30}, + "visible": false + }, + "width": 600, + "height": 400, + "showlegend": false, + "title": { + "text": "Geo traces with set featureidkey", + "x": 0.1, + "xref": "container", + "xanchor": "left" + } + } +} diff --git a/test/image/mocks/geo_fitbounds-geojson.json b/test/image/mocks/geo_fitbounds-geojson.json new file mode 100644 index 00000000000..82d3651426a --- /dev/null +++ b/test/image/mocks/geo_fitbounds-geojson.json @@ -0,0 +1,180 @@ +{ + "data": [{ + "type": "choropleth", + "locations": [0], + "z": [10], + "showscale": false, + "geojson": { + "type": "FeatureCollection", + "features": [{ + "type": "Feature", + "id": 0, + "geometry": { + "type": "Polygon", + "coordinates": [[ + [-87.359296, 35.00118], [-85.606675, 34.984749], [-85.431413, 34.124869], [-85.184951, 32.859696], + [-85.069935, 32.580372], [-84.960397, 32.421541], [-85.004212, 32.322956], [-84.889196, 32.262709], + [-85.058981, 32.13674], [-85.053504, 32.01077], [-85.141136, 31.840985], [-85.042551, 31.539753], + [-85.113751, 31.27686], [-85.004212, 31.003013], [-85.497137, 30.997536], [-87.600282, 30.997536], + [-87.633143, 30.86609], [-87.408589, 30.674397], [-87.446927, 30.510088], [-87.37025, 30.427934], + [-87.518128, 30.280057], [-87.655051, 30.247195], [-87.90699, 30.411504], [-87.934375, 30.657966], + [-88.011052, 30.685351], [-88.10416, 30.499135], [-88.137022, 30.318396], [-88.394438, 30.367688], + [-88.471115, 31.895754], [-88.241084, 33.796253], [-88.098683, 34.891641], [-88.202745, 34.995703], + [-87.359296, 35.00118] + ]] + } + }, { + "type": "Feature", + "id": 1, + "geometry": { + "type": "Polygon", + "coordinates": [[[-83.109191,35.00118],[-83.322791,34.787579],[-83.339222,34.683517],[-83.005129,34.469916],[-82.901067,34.486347],[-82.747713,34.26727],[-82.714851,34.152254],[-82.55602,33.94413],[-82.325988,33.81816],[-82.194542,33.631944],[-81.926172,33.462159],[-81.937125,33.347144],[-81.761863,33.160928],[-81.493493,33.007573],[-81.42777,32.843265],[-81.416816,32.629664],[-81.279893,32.558464],[-81.121061,32.290094],[-81.115584,32.120309],[-80.885553,32.032678],[-81.132015,31.693108],[-81.175831,31.517845],[-81.279893,31.364491],[-81.290846,31.20566],[-81.400385,31.13446],[-81.444201,30.707258],[-81.718048,30.745597],[-81.948079,30.827751],[-82.041187,30.751074],[-82.002849,30.564858],[-82.046664,30.362211],[-82.167157,30.356734],[-82.216449,30.570335],[-83.498053,30.647012],[-84.867289,30.712735],[-85.004212,31.003013],[-85.113751,31.27686],[-85.042551,31.539753],[-85.141136,31.840985],[-85.053504,32.01077],[-85.058981,32.13674],[-84.889196,32.262709],[-85.004212,32.322956],[-84.960397,32.421541],[-85.069935,32.580372],[-85.184951,32.859696],[-85.431413,34.124869],[-85.606675,34.984749],[-84.319594,34.990226],[-83.618546,34.984749],[-83.109191,35.00118]]] + } + }] + } + }, { + "type": "scattergeo", + "locations": [0], + "marker": {"size": 40}, + "geo": "geo2", + "geojson": { + "type": "FeatureCollection", + "features": [{ + "type": "Feature", + "id": 0, + "geometry": { + "type": "Polygon", + "coordinates": [[ + [-87.359296, 35.00118], [-85.606675, 34.984749], [-85.431413, 34.124869], [-85.184951, 32.859696], + [-85.069935, 32.580372], [-84.960397, 32.421541], [-85.004212, 32.322956], [-84.889196, 32.262709], + [-85.058981, 32.13674], [-85.053504, 32.01077], [-85.141136, 31.840985], [-85.042551, 31.539753], + [-85.113751, 31.27686], [-85.004212, 31.003013], [-85.497137, 30.997536], [-87.600282, 30.997536], + [-87.633143, 30.86609], [-87.408589, 30.674397], [-87.446927, 30.510088], [-87.37025, 30.427934], + [-87.518128, 30.280057], [-87.655051, 30.247195], [-87.90699, 30.411504], [-87.934375, 30.657966], + [-88.011052, 30.685351], [-88.10416, 30.499135], [-88.137022, 30.318396], [-88.394438, 30.367688], + [-88.471115, 31.895754], [-88.241084, 33.796253], [-88.098683, 34.891641], [-88.202745, 34.995703], + [-87.359296, 35.00118] + ]] + } + }, { + "type": "Feature", + "id": 1, + "geometry": { + "type": "Polygon", + "coordinates": [[[-83.109191,35.00118],[-83.322791,34.787579],[-83.339222,34.683517],[-83.005129,34.469916],[-82.901067,34.486347],[-82.747713,34.26727],[-82.714851,34.152254],[-82.55602,33.94413],[-82.325988,33.81816],[-82.194542,33.631944],[-81.926172,33.462159],[-81.937125,33.347144],[-81.761863,33.160928],[-81.493493,33.007573],[-81.42777,32.843265],[-81.416816,32.629664],[-81.279893,32.558464],[-81.121061,32.290094],[-81.115584,32.120309],[-80.885553,32.032678],[-81.132015,31.693108],[-81.175831,31.517845],[-81.279893,31.364491],[-81.290846,31.20566],[-81.400385,31.13446],[-81.444201,30.707258],[-81.718048,30.745597],[-81.948079,30.827751],[-82.041187,30.751074],[-82.002849,30.564858],[-82.046664,30.362211],[-82.167157,30.356734],[-82.216449,30.570335],[-83.498053,30.647012],[-84.867289,30.712735],[-85.004212,31.003013],[-85.113751,31.27686],[-85.042551,31.539753],[-85.141136,31.840985],[-85.053504,32.01077],[-85.058981,32.13674],[-84.889196,32.262709],[-85.004212,32.322956],[-84.960397,32.421541],[-85.069935,32.580372],[-85.184951,32.859696],[-85.431413,34.124869],[-85.606675,34.984749],[-84.319594,34.990226],[-83.618546,34.984749],[-83.109191,35.00118]]] + } + }] + } + }, { + "type": "choropleth", + "locations": [0], + "z": [10], + "showscale": false, + "geo": "geo3", + "geojson": { + "type": "FeatureCollection", + "features": [{ + "type": "Feature", + "id": 0, + "geometry": { + "type": "Polygon", + "coordinates": [[ + [-87.359296, 35.00118], [-85.606675, 34.984749], [-85.431413, 34.124869], [-85.184951, 32.859696], + [-85.069935, 32.580372], [-84.960397, 32.421541], [-85.004212, 32.322956], [-84.889196, 32.262709], + [-85.058981, 32.13674], [-85.053504, 32.01077], [-85.141136, 31.840985], [-85.042551, 31.539753], + [-85.113751, 31.27686], [-85.004212, 31.003013], [-85.497137, 30.997536], [-87.600282, 30.997536], + [-87.633143, 30.86609], [-87.408589, 30.674397], [-87.446927, 30.510088], [-87.37025, 30.427934], + [-87.518128, 30.280057], [-87.655051, 30.247195], [-87.90699, 30.411504], [-87.934375, 30.657966], + [-88.011052, 30.685351], [-88.10416, 30.499135], [-88.137022, 30.318396], [-88.394438, 30.367688], + [-88.471115, 31.895754], [-88.241084, 33.796253], [-88.098683, 34.891641], [-88.202745, 34.995703], + [-87.359296, 35.00118] + ]] + } + }, { + "type": "Feature", + "id": 1, + "geometry": { + "type": "Polygon", + "coordinates": [[[-83.109191,35.00118],[-83.322791,34.787579],[-83.339222,34.683517],[-83.005129,34.469916],[-82.901067,34.486347],[-82.747713,34.26727],[-82.714851,34.152254],[-82.55602,33.94413],[-82.325988,33.81816],[-82.194542,33.631944],[-81.926172,33.462159],[-81.937125,33.347144],[-81.761863,33.160928],[-81.493493,33.007573],[-81.42777,32.843265],[-81.416816,32.629664],[-81.279893,32.558464],[-81.121061,32.290094],[-81.115584,32.120309],[-80.885553,32.032678],[-81.132015,31.693108],[-81.175831,31.517845],[-81.279893,31.364491],[-81.290846,31.20566],[-81.400385,31.13446],[-81.444201,30.707258],[-81.718048,30.745597],[-81.948079,30.827751],[-82.041187,30.751074],[-82.002849,30.564858],[-82.046664,30.362211],[-82.167157,30.356734],[-82.216449,30.570335],[-83.498053,30.647012],[-84.867289,30.712735],[-85.004212,31.003013],[-85.113751,31.27686],[-85.042551,31.539753],[-85.141136,31.840985],[-85.053504,32.01077],[-85.058981,32.13674],[-84.889196,32.262709],[-85.004212,32.322956],[-84.960397,32.421541],[-85.069935,32.580372],[-85.184951,32.859696],[-85.431413,34.124869],[-85.606675,34.984749],[-84.319594,34.990226],[-83.618546,34.984749],[-83.109191,35.00118]]] + } + }] + } + }, { + "type": "scattergeo", + "locations": [0], + "marker": {"size": 40}, + "geo": "geo4", + "geojson": { + "type": "FeatureCollection", + "features": [{ + "type": "Feature", + "id": 0, + "geometry": { + "type": "Polygon", + "coordinates": [[ + [-87.359296, 35.00118], [-85.606675, 34.984749], [-85.431413, 34.124869], [-85.184951, 32.859696], + [-85.069935, 32.580372], [-84.960397, 32.421541], [-85.004212, 32.322956], [-84.889196, 32.262709], + [-85.058981, 32.13674], [-85.053504, 32.01077], [-85.141136, 31.840985], [-85.042551, 31.539753], + [-85.113751, 31.27686], [-85.004212, 31.003013], [-85.497137, 30.997536], [-87.600282, 30.997536], + [-87.633143, 30.86609], [-87.408589, 30.674397], [-87.446927, 30.510088], [-87.37025, 30.427934], + [-87.518128, 30.280057], [-87.655051, 30.247195], [-87.90699, 30.411504], [-87.934375, 30.657966], + [-88.011052, 30.685351], [-88.10416, 30.499135], [-88.137022, 30.318396], [-88.394438, 30.367688], + [-88.471115, 31.895754], [-88.241084, 33.796253], [-88.098683, 34.891641], [-88.202745, 34.995703], + [-87.359296, 35.00118] + ]] + } + }, { + "type": "Feature", + "id": 1, + "geometry": { + "type": "Polygon", + "coordinates": [[[-83.109191,35.00118],[-83.322791,34.787579],[-83.339222,34.683517],[-83.005129,34.469916],[-82.901067,34.486347],[-82.747713,34.26727],[-82.714851,34.152254],[-82.55602,33.94413],[-82.325988,33.81816],[-82.194542,33.631944],[-81.926172,33.462159],[-81.937125,33.347144],[-81.761863,33.160928],[-81.493493,33.007573],[-81.42777,32.843265],[-81.416816,32.629664],[-81.279893,32.558464],[-81.121061,32.290094],[-81.115584,32.120309],[-80.885553,32.032678],[-81.132015,31.693108],[-81.175831,31.517845],[-81.279893,31.364491],[-81.290846,31.20566],[-81.400385,31.13446],[-81.444201,30.707258],[-81.718048,30.745597],[-81.948079,30.827751],[-82.041187,30.751074],[-82.002849,30.564858],[-82.046664,30.362211],[-82.167157,30.356734],[-82.216449,30.570335],[-83.498053,30.647012],[-84.867289,30.712735],[-85.004212,31.003013],[-85.113751,31.27686],[-85.042551,31.539753],[-85.141136,31.840985],[-85.053504,32.01077],[-85.058981,32.13674],[-84.889196,32.262709],[-85.004212,32.322956],[-84.960397,32.421541],[-85.069935,32.580372],[-85.184951,32.859696],[-85.431413,34.124869],[-85.606675,34.984749],[-84.319594,34.990226],[-83.618546,34.984749],[-83.109191,35.00118]]] + } + }] + } + }], + "layout": { + "width": 600, + "height": 400, + "showlegend": false, + "grid": {"rows": 2, "columns": 2}, + "geo": { + "domain": {"row": 0, "column": 0}, + "fitbounds": "geojson" + }, + "geo2": { + "domain": {"row": 1, "column": 0}, + "fitbounds": "geojson" + }, + "geo3": { + "domain": {"row": 0, "column": 1}, + "fitbounds": "locations" + }, + "geo4": { + "domain": {"row": 1, "column": 1}, + "fitbounds": "locations" + }, + "annotations": [{ + "text": "fitbounds: 'geojson'", + "font": {"size": 20}, + "showarrow": false, + "xref": "paper", + "x": 0, + "xanchor": "left", + "yref": "paper", + "y": 1.02, + "yanchor": "bottom" + }, { + "text": "fitbounds: 'locations'", + "font": {"size": 20}, + "showarrow": false, + "xref": "paper", + "x": 1, + "xanchor": "right", + "yref": "paper", + "y": 1.02, + "yanchor": "bottom" + }], + "margin": {"l": 20, "r": 20, "b": 20, "t": 40} + } +} diff --git a/test/image/mocks/geo_fitbounds-locations.json b/test/image/mocks/geo_fitbounds-locations.json new file mode 100644 index 00000000000..afc408a140d --- /dev/null +++ b/test/image/mocks/geo_fitbounds-locations.json @@ -0,0 +1,763 @@ +{ + "data": [ + { + "name": "equirectangular", + "geo": "geo", + "type": "choropleth", + "locations": [ + "AUS" + ], + "z": [ + 10 + ], + "showscale": false, + "hovertemplate": "equirectangular" + }, + { + "geo": "geo", + "type": "scattergeo", + "mode": "text", + "text": "equirectangular", + "locations": [ + "AUS" + ], + "hoverinfo": "skip" + }, + { + "name": "mercator", + "geo": "geo2", + "type": "choropleth", + "locations": [ + "AUS" + ], + "z": [ + 10 + ], + "showscale": false, + "hovertemplate": "mercator" + }, + { + "geo": "geo2", + "type": "scattergeo", + "mode": "text", + "text": "mercator", + "locations": [ + "AUS" + ], + "hoverinfo": "skip" + }, + { + "name": "orthographic", + "geo": "geo3", + "type": "choropleth", + "locations": [ + "AUS" + ], + "z": [ + 10 + ], + "showscale": false, + "hovertemplate": "orthographic" + }, + { + "geo": "geo3", + "type": "scattergeo", + "mode": "text", + "text": "orthographic", + "locations": [ + "AUS" + ], + "hoverinfo": "skip" + }, + { + "name": "natural earth", + "geo": "geo4", + "type": "choropleth", + "locations": [ + "AUS" + ], + "z": [ + 10 + ], + "showscale": false, + "hovertemplate": "natural earth" + }, + { + "geo": "geo4", + "type": "scattergeo", + "mode": "text", + "text": "natural
earth", + "locations": [ + "AUS" + ], + "hoverinfo": "skip" + }, + { + "name": "kavrayskiy7", + "geo": "geo5", + "type": "choropleth", + "locations": [ + "AUS" + ], + "z": [ + 10 + ], + "showscale": false, + "hovertemplate": "kavrayskiy7" + }, + { + "geo": "geo5", + "type": "scattergeo", + "mode": "text", + "text": "kavrayskiy7", + "locations": [ + "AUS" + ], + "hoverinfo": "skip" + }, + { + "name": "miller", + "geo": "geo6", + "type": "choropleth", + "locations": [ + "AUS" + ], + "z": [ + 10 + ], + "showscale": false, + "hovertemplate": "miller" + }, + { + "geo": "geo6", + "type": "scattergeo", + "mode": "text", + "text": "miller", + "locations": [ + "AUS" + ], + "hoverinfo": "skip" + }, + { + "name": "robinson", + "geo": "geo7", + "type": "choropleth", + "locations": [ + "AUS" + ], + "z": [ + 10 + ], + "showscale": false, + "hovertemplate": "robinson" + }, + { + "geo": "geo7", + "type": "scattergeo", + "mode": "text", + "text": "robinson", + "locations": [ + "AUS" + ], + "hoverinfo": "skip" + }, + { + "name": "eckert4", + "geo": "geo8", + "type": "choropleth", + "locations": [ + "AUS" + ], + "z": [ + 10 + ], + "showscale": false, + "hovertemplate": "eckert4" + }, + { + "geo": "geo8", + "type": "scattergeo", + "mode": "text", + "text": "eckert4", + "locations": [ + "AUS" + ], + "hoverinfo": "skip" + }, + { + "name": "azimuthal equal area", + "geo": "geo9", + "type": "choropleth", + "locations": [ + "AUS" + ], + "z": [ + 10 + ], + "showscale": false, + "hovertemplate": "azimuthal equal area" + }, + { + "geo": "geo9", + "type": "scattergeo", + "mode": "text", + "text": "azimuthal
equal area", + "locations": [ + "AUS" + ], + "hoverinfo": "skip" + }, + { + "name": "azimuthal equidistant", + "geo": "geo10", + "type": "choropleth", + "locations": [ + "AUS" + ], + "z": [ + 10 + ], + "showscale": false, + "hovertemplate": "azimuthal equidistant" + }, + { + "geo": "geo10", + "type": "scattergeo", + "mode": "text", + "text": "azimuthal
equidistant", + "locations": [ + "AUS" + ], + "hoverinfo": "skip" + }, + { + "name": "conic equal area", + "geo": "geo11", + "type": "choropleth", + "locations": [ + "AUS" + ], + "z": [ + 10 + ], + "showscale": false, + "hovertemplate": "conic equal area" + }, + { + "geo": "geo11", + "type": "scattergeo", + "mode": "text", + "text": "conic
equal area", + "locations": [ + "AUS" + ], + "hoverinfo": "skip" + }, + { + "name": "conic conformal", + "geo": "geo12", + "type": "choropleth", + "locations": [ + "AUS" + ], + "z": [ + 10 + ], + "showscale": false, + "hovertemplate": "conic conformal" + }, + { + "geo": "geo12", + "type": "scattergeo", + "mode": "text", + "text": "conic
conformal", + "locations": [ + "AUS" + ], + "hoverinfo": "skip" + }, + { + "name": "conic equidistant", + "geo": "geo13", + "type": "choropleth", + "locations": [ + "AUS" + ], + "z": [ + 10 + ], + "showscale": false, + "hovertemplate": "conic equidistant" + }, + { + "geo": "geo13", + "type": "scattergeo", + "mode": "text", + "text": "conic
equidistant", + "locations": [ + "AUS" + ], + "hoverinfo": "skip" + }, + { + "name": "gnomonic", + "geo": "geo14", + "type": "choropleth", + "locations": [ + "AUS" + ], + "z": [ + 10 + ], + "showscale": false, + "hovertemplate": "gnomonic" + }, + { + "geo": "geo14", + "type": "scattergeo", + "mode": "text", + "text": "gnomonic", + "locations": [ + "AUS" + ], + "hoverinfo": "skip" + }, + { + "name": "stereographic", + "geo": "geo15", + "type": "choropleth", + "locations": [ + "AUS" + ], + "z": [ + 10 + ], + "showscale": false, + "hovertemplate": "stereographic" + }, + { + "geo": "geo15", + "type": "scattergeo", + "mode": "text", + "text": "stereographic", + "locations": [ + "AUS" + ], + "hoverinfo": "skip" + }, + { + "name": "mollweide", + "geo": "geo16", + "type": "choropleth", + "locations": [ + "AUS" + ], + "z": [ + 10 + ], + "showscale": false, + "hovertemplate": "mollweide" + }, + { + "geo": "geo16", + "type": "scattergeo", + "mode": "text", + "text": "mollweide", + "locations": [ + "AUS" + ], + "hoverinfo": "skip" + }, + { + "name": "hammer", + "geo": "geo17", + "type": "choropleth", + "locations": [ + "AUS" + ], + "z": [ + 10 + ], + "showscale": false, + "hovertemplate": "hammer" + }, + { + "geo": "geo17", + "type": "scattergeo", + "mode": "text", + "text": "hammer", + "locations": [ + "AUS" + ], + "hoverinfo": "skip" + }, + { + "name": "transverse mercator", + "geo": "geo18", + "type": "choropleth", + "locations": [ + "AUS" + ], + "z": [ + 10 + ], + "showscale": false, + "hovertemplate": "transverse mercator" + }, + { + "geo": "geo18", + "type": "scattergeo", + "mode": "text", + "text": "transverse
mercator", + "locations": [ + "AUS" + ], + "hoverinfo": "skip" + }, + { + "name": "albers usa", + "geo": "geo19", + "type": "choropleth", + "locationmode": "USA-states", + "locations": [ + "WA" + ], + "z": [ + 10 + ], + "showscale": false, + "hovertemplate": "albers usa" + }, + { + "name": "albers usa", + "geo": "geo19", + "type": "scattergeo", + "locationmode": "USA-states", + "locations": [ + "WA" + ], + "mode": "text", + "text": "albers
usa", + "hoverinfo": "skip" + }, + { + "name": "winkel tripel", + "geo": "geo20", + "type": "choropleth", + "locations": [ + "AUS" + ], + "z": [ + 10 + ], + "showscale": false, + "hovertemplate": "winkel tripel" + }, + { + "geo": "geo20", + "type": "scattergeo", + "mode": "text", + "text": "winkel
tripel", + "locations": [ + "AUS" + ], + "hoverinfo": "skip" + }, + { + "name": "aitoff", + "geo": "geo21", + "type": "choropleth", + "locations": [ + "AUS" + ], + "z": [ + 10 + ], + "showscale": false, + "hovertemplate": "aitoff" + }, + { + "geo": "geo21", + "type": "scattergeo", + "mode": "text", + "text": "aitoff", + "locations": [ + "AUS" + ], + "hoverinfo": "skip" + }, + { + "name": "sinusoidal", + "geo": "geo22", + "type": "choropleth", + "locations": [ + "AUS" + ], + "z": [ + 10 + ], + "showscale": false, + "hovertemplate": "sinusoidal" + }, + { + "geo": "geo22", + "type": "scattergeo", + "mode": "text", + "text": "sinusoidal", + "locations": [ + "AUS" + ], + "hoverinfo": "skip" + } + ], + "layout": { + "grid": { + "rows": 8, + "columns": 3 + }, + "showlegend": false, + "width": 650, + "height": 1200, + "margin": { + "l": 20, + "t": 20, + "r": 20, + "b": 20 + }, + "annotations": [ + { + "showarrow": false, + "text": "fitbounds
'locations'
for all
projection
types", + "font": { + "size": 24 + }, + "x": 1, + "xref": "paper", + "xanchor": "right", + "y": 0.1, + "yanchor": "bottom" + } + ], + "geo": { + "domain": { + "row": 0, + "column": 0 + }, + "projection": { + "type": "equirectangular" + }, + "fitbounds": "locations" + }, + "geo2": { + "domain": { + "row": 1, + "column": 0 + }, + "projection": { + "type": "mercator" + }, + "fitbounds": "locations" + }, + "geo3": { + "domain": { + "row": 2, + "column": 0 + }, + "projection": { + "type": "orthographic" + }, + "fitbounds": "locations" + }, + "geo4": { + "domain": { + "row": 3, + "column": 0 + }, + "projection": { + "type": "natural earth" + }, + "fitbounds": "locations" + }, + "geo5": { + "domain": { + "row": 4, + "column": 0 + }, + "projection": { + "type": "kavrayskiy7" + }, + "fitbounds": "locations" + }, + "geo6": { + "domain": { + "row": 5, + "column": 0 + }, + "projection": { + "type": "miller" + }, + "fitbounds": "locations" + }, + "geo7": { + "domain": { + "row": 6, + "column": 0 + }, + "projection": { + "type": "robinson" + }, + "fitbounds": "locations" + }, + "geo8": { + "domain": { + "row": 7, + "column": 0 + }, + "projection": { + "type": "eckert4" + }, + "fitbounds": "locations" + }, + "geo9": { + "domain": { + "row": 0, + "column": 1 + }, + "projection": { + "type": "azimuthal equal area" + }, + "fitbounds": "locations" + }, + "geo10": { + "domain": { + "row": 1, + "column": 1 + }, + "projection": { + "type": "azimuthal equidistant" + }, + "fitbounds": "locations" + }, + "geo11": { + "domain": { + "row": 2, + "column": 1 + }, + "projection": { + "type": "conic equal area" + }, + "fitbounds": "locations" + }, + "geo12": { + "domain": { + "row": 3, + "column": 1 + }, + "projection": { + "type": "conic conformal" + }, + "fitbounds": "locations" + }, + "geo13": { + "domain": { + "row": 4, + "column": 1 + }, + "projection": { + "type": "conic equidistant" + }, + "fitbounds": "locations" + }, + "geo14": { + "domain": { + "row": 5, + "column": 1 + }, + "projection": { + "type": "gnomonic" + }, + "fitbounds": "locations" + }, + "geo15": { + "domain": { + "row": 6, + "column": 1 + }, + "projection": { + "type": "stereographic" + }, + "fitbounds": "locations" + }, + "geo16": { + "domain": { + "row": 7, + "column": 1 + }, + "projection": { + "type": "mollweide" + }, + "fitbounds": "locations" + }, + "geo17": { + "domain": { + "row": 0, + "column": 2 + }, + "projection": { + "type": "hammer" + }, + "fitbounds": "locations" + }, + "geo18": { + "domain": { + "row": 1, + "column": 2 + }, + "projection": { + "type": "transverse mercator" + }, + "fitbounds": "locations" + }, + "geo19": { + "domain": { + "row": 2, + "column": 2 + }, + "projection": { + "type": "albers usa" + }, + "fitbounds": "locations", + "scope": "usa" + }, + "geo20": { + "domain": { + "row": 3, + "column": 2 + }, + "projection": { + "type": "winkel tripel" + }, + "fitbounds": "locations" + }, + "geo21": { + "domain": { + "row": 4, + "column": 2 + }, + "projection": { + "type": "aitoff" + }, + "fitbounds": "locations" + }, + "geo22": { + "domain": { + "row": 5, + "column": 2 + }, + "projection": { + "type": "sinusoidal" + }, + "fitbounds": "locations" + } + } +} diff --git a/test/image/mocks/geo_fitbounds-scopes.json b/test/image/mocks/geo_fitbounds-scopes.json new file mode 100644 index 00000000000..2d1420551a3 --- /dev/null +++ b/test/image/mocks/geo_fitbounds-scopes.json @@ -0,0 +1,433 @@ +{ + "data": [ + { + "name": "usa", + "geo": "geo", + "type": "choropleth", + "locations": [ + 1 + ], + "z": [ + 1 + ], + "showscale": false, + "geojson": { + "type": "Feature", + "id": 1, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -120, + 40 + ], + [ + -110, + 40 + ], + [ + -110, + 30 + ], + [ + -120, + 30 + ], + [ + -120, + 40 + ] + ] + ] + } + }, + "marker": { + "opacity": 0.6 + }, + "hovertemplate": "(%{ct[0]},%{ct[1]}) usa" + }, + { + "hovertemplate": "(%{lon},%{lat}) usa", + "geo": "geo", + "type": "scattergeo", + "lon": [ + -115 + ], + "lat": [ + 45 + ], + "marker": { + "size": 23 + } + }, + { + "name": "europe", + "geo": "geo2", + "type": "choropleth", + "locations": [ + 1 + ], + "z": [ + 1 + ], + "showscale": false, + "geojson": { + "type": "Feature", + "id": 1, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 2, + 50 + ], + [ + 20, + 50 + ], + [ + 20, + 40 + ], + [ + 2, + 40 + ], + [ + 2, + 50 + ] + ] + ] + } + }, + "marker": { + "opacity": 0.6 + }, + "hovertemplate": "(%{ct[0]},%{ct[1]}) europe" + }, + { + "hovertemplate": "(%{lon},%{lat}) europe", + "geo": "geo2", + "type": "scattergeo", + "lon": [ + 24 + ], + "lat": [ + 45 + ], + "marker": { + "size": 23 + } + }, + { + "name": "asia", + "geo": "geo3", + "type": "choropleth", + "locations": [ + 1 + ], + "z": [ + 1 + ], + "showscale": false, + "geojson": { + "type": "Feature", + "id": 1, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 120, + 50 + ], + [ + 150, + 50 + ], + [ + 150, + 20 + ], + [ + 120, + 20 + ], + [ + 120, + 50 + ] + ] + ] + } + }, + "marker": { + "opacity": 0.6 + }, + "hovertemplate": "(%{ct[0]},%{ct[1]}) asia" + }, + { + "hovertemplate": "(%{lon},%{lat}) asia", + "geo": "geo3", + "type": "scattergeo", + "lon": [ + 155 + ], + "lat": [ + 30 + ], + "marker": { + "size": 23 + } + }, + { + "name": "africa", + "geo": "geo4", + "type": "choropleth", + "locations": [ + 1 + ], + "z": [ + 1 + ], + "showscale": false, + "geojson": { + "type": "Feature", + "id": 1, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 2, + 1 + ], + [ + 20, + 1 + ], + [ + 20, + -20 + ], + [ + 2, + -20 + ], + [ + 2, + 1 + ] + ] + ] + } + }, + "marker": { + "opacity": 0.6 + }, + "hovertemplate": "(%{ct[0]},%{ct[1]}) africa" + }, + { + "hovertemplate": "(%{lon},%{lat}) africa", + "geo": "geo4", + "type": "scattergeo", + "lon": [ + -2 + ], + "lat": [ + 10 + ], + "marker": { + "size": 23 + } + }, + { + "name": "north america", + "geo": "geo5", + "type": "choropleth", + "locations": [ + 1 + ], + "z": [ + 1 + ], + "showscale": false, + "geojson": { + "type": "Feature", + "id": 1, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -120, + 40 + ], + [ + -110, + 40 + ], + [ + -110, + 30 + ], + [ + -120, + 30 + ], + [ + -120, + 40 + ] + ] + ] + } + }, + "marker": { + "opacity": 0.6 + }, + "hovertemplate": "(%{ct[0]},%{ct[1]}) north america" + }, + { + "hovertemplate": "(%{lon},%{lat}) north america", + "geo": "geo5", + "type": "scattergeo", + "lon": [ + -125 + ], + "lat": [ + 25 + ], + "marker": { + "size": 23 + } + }, + { + "name": "south america", + "geo": "geo6", + "type": "choropleth", + "locations": [ + 1 + ], + "z": [ + 1 + ], + "showscale": false, + "geojson": { + "type": "Feature", + "id": 1, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -60, + -20 + ], + [ + -50, + -20 + ], + [ + -50, + -40 + ], + [ + -60, + -40 + ], + [ + -60, + -20 + ] + ] + ] + } + }, + "marker": { + "opacity": 0.6 + }, + "hovertemplate": "(%{ct[0]},%{ct[1]}) south america" + }, + { + "hovertemplate": "(%{lon},%{lat}) south america", + "geo": "geo6", + "type": "scattergeo", + "lon": [ + -55 + ], + "lat": [ + -45 + ], + "marker": { + "size": 23 + } + } + ], + "layout": { + "grid": { + "rows": 3, + "columns": 2 + }, + "showlegend": false, + "width": 650, + "height": 900, + "margin": { + "l": 20, + "t": 20, + "r": 20, + "b": 20 + }, + "geo": { + "domain": { + "row": 0, + "column": 0 + }, + "scope": "usa", + "fitbounds": "locations" + }, + "geo2": { + "domain": { + "row": 1, + "column": 0 + }, + "scope": "europe", + "fitbounds": "locations" + }, + "geo3": { + "domain": { + "row": 2, + "column": 0 + }, + "scope": "asia", + "fitbounds": "locations" + }, + "geo4": { + "domain": { + "row": 0, + "column": 1 + }, + "scope": "africa", + "fitbounds": "locations" + }, + "geo5": { + "domain": { + "row": 1, + "column": 1 + }, + "scope": "north america", + "fitbounds": "locations" + }, + "geo6": { + "domain": { + "row": 2, + "column": 1 + }, + "scope": "south america", + "fitbounds": "locations" + } + } +} diff --git a/test/image/mocks/mapbox_choropleth-raw-geojson.json b/test/image/mocks/mapbox_choropleth-raw-geojson.json index e3ae99ae8d2..9fc58b63210 100644 --- a/test/image/mocks/mapbox_choropleth-raw-geojson.json +++ b/test/image/mocks/mapbox_choropleth-raw-geojson.json @@ -28,6 +28,20 @@ ]] } } + }, { + "type": "choroplethmapbox", + "locations": ["Georgia"], + "z": [5], + "featureidkey": "properties.name", + "coloraxis": "coloraxis", + "geojson": { + "type": "Feature", + "properties": {"name": "Georgia"}, + "geometry": { + "type": "Polygon", + "coordinates": [[[-83.109191,35.00118],[-83.322791,34.787579],[-83.339222,34.683517],[-83.005129,34.469916],[-82.901067,34.486347],[-82.747713,34.26727],[-82.714851,34.152254],[-82.55602,33.94413],[-82.325988,33.81816],[-82.194542,33.631944],[-81.926172,33.462159],[-81.937125,33.347144],[-81.761863,33.160928],[-81.493493,33.007573],[-81.42777,32.843265],[-81.416816,32.629664],[-81.279893,32.558464],[-81.121061,32.290094],[-81.115584,32.120309],[-80.885553,32.032678],[-81.132015,31.693108],[-81.175831,31.517845],[-81.279893,31.364491],[-81.290846,31.20566],[-81.400385,31.13446],[-81.444201,30.707258],[-81.718048,30.745597],[-81.948079,30.827751],[-82.041187,30.751074],[-82.002849,30.564858],[-82.046664,30.362211],[-82.167157,30.356734],[-82.216449,30.570335],[-83.498053,30.647012],[-84.867289,30.712735],[-85.004212,31.003013],[-85.113751,31.27686],[-85.042551,31.539753],[-85.141136,31.840985],[-85.053504,32.01077],[-85.058981,32.13674],[-84.889196,32.262709],[-85.004212,32.322956],[-84.960397,32.421541],[-85.069935,32.580372],[-85.184951,32.859696],[-85.431413,34.124869],[-85.606675,34.984749],[-84.319594,34.990226],[-83.618546,34.984749],[-83.109191,35.00118]]] + } + } }], "layout": { "width": 600, diff --git a/test/jasmine/tests/choropleth_test.js b/test/jasmine/tests/choropleth_test.js index 8c6cf96055f..9aee3409e56 100644 --- a/test/jasmine/tests/choropleth_test.js +++ b/test/jasmine/tests/choropleth_test.js @@ -3,6 +3,7 @@ var Choropleth = require('@src/traces/choropleth'); var Plotly = require('@lib'); var Plots = require('@src/plots/plots'); var Lib = require('@src/lib'); +var loggers = require('@src/lib/loggers'); var d3 = require('d3'); var createGraphDiv = require('../assets/create_graph_div'); @@ -88,6 +89,65 @@ describe('Test choropleth', function() { expect(traceOut.marker.line.width).toBe(0, 'mlw'); expect(traceOut.marker.line.color).toBe(undefined, 'mlc'); }); + + it('should default locationmode to *geojson-id* when a valid *geojson* is provided', function() { + traceIn = { + locations: ['CAN', 'USA'], + z: [1, 2], + geojson: 'url' + }; + traceOut = {}; + Choropleth.supplyDefaults(traceIn, traceOut, defaultColor, layout); + expect(traceOut.locationmode).toBe('geojson-id', 'valid url string'); + + traceIn = { + locations: ['CAN', 'USA'], + z: [1, 2], + geojson: {} + }; + traceOut = {}; + Choropleth.supplyDefaults(traceIn, traceOut, defaultColor, layout); + expect(traceOut.locationmode).toBe('geojson-id', 'valid object'); + + traceIn = { + locations: ['CAN', 'USA'], + z: [1, 2], + geojson: '' + }; + traceOut = {}; + Choropleth.supplyDefaults(traceIn, traceOut, defaultColor, layout); + expect(traceOut.locationmode).toBe('ISO-3', 'invalid sting'); + + traceIn = { + locations: ['CAN', 'USA'], + z: [1, 2], + geojson: [] + }; + traceOut = {}; + Choropleth.supplyDefaults(traceIn, traceOut, defaultColor, layout); + expect(traceOut.locationmode).toBe('ISO-3', 'invalid object'); + }); + + it('should only coerce *featureidkey* when locationmode is *geojson-id', function() { + traceIn = { + locations: ['CAN', 'USA'], + z: [1, 2], + geojson: 'url', + featureidkey: 'properties.name' + }; + traceOut = {}; + Choropleth.supplyDefaults(traceIn, traceOut, defaultColor, layout); + expect(traceOut.featureidkey).toBe('properties.name', 'coerced'); + + traceIn = { + locations: ['CAN', 'USA'], + z: [1, 2], + featureidkey: 'properties.name' + }; + traceOut = {}; + Choropleth.supplyDefaults(traceIn, traceOut, defaultColor, layout); + expect(traceOut.featureidkey).toBe(undefined, 'NOT coerced'); + }); }); }); @@ -242,6 +302,15 @@ describe('Test choropleth hover:', function() { run(pos, fig, exp).then(done); }); }); + + it('should include *properties* from input custom geojson', function(done) { + var fig = Lib.extendDeep({}, require('@mocks/geo_custom-geojson.json')); + fig.data = [fig.data[1]]; + fig.data[0].hovertemplate = '%{properties.name}%{ct[0]:.1f} | %{ct[1]:.1f}'; + fig.layout.geo.projection = {scale: 20}; + + run([300, 200], fig, ['New York', '-75.1 | 42.6']).then(done); + }); }); describe('choropleth drawing', function() { @@ -254,7 +323,7 @@ describe('choropleth drawing', function() { afterEach(destroyGraphDiv); it('should not throw an error with bad locations', function(done) { - spyOn(Lib, 'log'); + spyOn(loggers, 'log'); Plotly.newPlot(gd, [{ locations: ['canada', 0, null, '', 'utopia'], z: [1, 2, 3, 4, 5], @@ -263,7 +332,7 @@ describe('choropleth drawing', function() { }]) .then(function() { // only utopia logs - others are silently ignored - expect(Lib.log).toHaveBeenCalledTimes(1); + expect(loggers.log).toHaveBeenCalledTimes(1); }) .catch(failTest) .then(done); diff --git a/test/jasmine/tests/choroplethmapbox_test.js b/test/jasmine/tests/choroplethmapbox_test.js index 41657a1181a..1940e983e6f 100644 --- a/test/jasmine/tests/choroplethmapbox_test.js +++ b/test/jasmine/tests/choroplethmapbox_test.js @@ -1,6 +1,7 @@ var Plotly = require('@lib'); var Plots = require('@src/plots/plots'); var Lib = require('@src/lib'); +var loggers = require('@src/lib/loggers'); var convertModule = require('@src/traces/choroplethmapbox/convert'); var MAPBOX_ACCESS_TOKEN = require('@build/credentials.json').MAPBOX_ACCESS_TOKEN; @@ -150,19 +151,19 @@ describe('Test choroplethmapbox convert:', function() { }); it('should return early if something goes wrong while fetching a GeoJSON', function() { - spyOn(Lib, 'error'); + spyOn(loggers, 'error'); var opts = _convert({ locations: ['a'], z: [1], geojson: 'url' }); - expect(Lib.error).toHaveBeenCalledWith('Oops ... something when wrong when fetching url'); + expect(loggers.error).toHaveBeenCalledWith('Oops ... something went wrong when fetching url'); expectBlank(opts); }); describe('should warn when set GeoJSON is not a *FeatureCollection* or a *Feature* type and return early', function() { - beforeEach(function() { spyOn(Lib, 'warn'); }); + beforeEach(function() { spyOn(loggers, 'warn'); }); it('- case missing *type* key', function() { var opts = _convert({ @@ -172,9 +173,9 @@ describe('Test choroplethmapbox convert:', function() { } }); expectBlank(opts); - expect(Lib.warn).toHaveBeenCalledWith([ - 'Invalid GeoJSON type none,', - 'choroplethmapbox traces only support *FeatureCollection* and *Feature* types.' + expect(loggers.warn).toHaveBeenCalledWith([ + 'Invalid GeoJSON type none.', + 'Traces with locationmode *geojson-id* only support *FeatureCollection* and *Feature* types.' ].join(' ')); }); @@ -186,15 +187,15 @@ describe('Test choroplethmapbox convert:', function() { } }); expectBlank(opts); - expect(Lib.warn).toHaveBeenCalledWith([ - 'Invalid GeoJSON type nop!,', - 'choroplethmapbox traces only support *FeatureCollection* and *Feature* types.' + expect(loggers.warn).toHaveBeenCalledWith([ + 'Invalid GeoJSON type nop!.', + 'Traces with locationmode *geojson-id* only support *FeatureCollection* and *Feature* types.' ].join(' ')); }); }); describe('should log when crossing a GeoJSON geometry that is not a *Polygon* or a *MultiPolygon* type', function() { - beforeEach(function() { spyOn(Lib, 'log'); }); + beforeEach(function() { spyOn(loggers, 'log'); }); it('- case missing geometry *type*', function() { var trace = base(); @@ -202,9 +203,9 @@ describe('Test choroplethmapbox convert:', function() { var opts = _convert(trace); expect(opts.geojson.features.length).toBe(2, '# of feature to be rendered'); - expect(Lib.log).toHaveBeenCalledWith([ - 'Location with id b does not have a valid GeoJSON geometry,', - 'choroplethmapbox traces only support *Polygon* and *MultiPolygon* geometries.' + expect(loggers.log).toHaveBeenCalledWith([ + 'Location b does not have a valid GeoJSON geometry.', + 'Traces with locationmode *geojson-id* only support *Polygon* and *MultiPolygon* geometries.' ].join(' ')); }); @@ -214,15 +215,15 @@ describe('Test choroplethmapbox convert:', function() { var opts = _convert(trace); expect(opts.geojson.features.length).toBe(2, '# of feature to be rendered'); - expect(Lib.log).toHaveBeenCalledWith([ - 'Location with id c does not have a valid GeoJSON geometry,', - 'choroplethmapbox traces only support *Polygon* and *MultiPolygon* geometries.' + expect(loggers.log).toHaveBeenCalledWith([ + 'Location c does not have a valid GeoJSON geometry.', + 'Traces with locationmode *geojson-id* only support *Polygon* and *MultiPolygon* geometries.' ].join(' ')); }); }); it('should log when an entry set in *locations* does not a matching feature in the GeoJSON', function() { - spyOn(Lib, 'log'); + spyOn(loggers, 'log'); var trace = base(); trace.locations = ['a', 'b', 'c', 'd']; @@ -230,7 +231,7 @@ describe('Test choroplethmapbox convert:', function() { var opts = _convert(trace); expect(opts.geojson.features.length).toBe(3, '# of features to be rendered'); - expect(Lib.log).toHaveBeenCalledWith('Location with id d does not have a matching feature'); + expect(loggers.log).toHaveBeenCalledWith('Location *d* does not have a matching feature with id-key *id*.'); }); describe('should accept numbers as *locations* items', function() { @@ -593,7 +594,7 @@ describe('@noCI Test choroplethmapbox hover:', function() { desc: 'with "typeof number" locations[i] and feature id (in *name* label case)', patch: function() { var fig = Lib.extendDeep({}, require('@mocks/mapbox_choropleth-raw-geojson.json')); - fig.data.shift(); + fig.data = [fig.data[1]]; fig.data[0].locations = [100]; fig.data[0].geojson.id = 100; return fig; @@ -605,7 +606,7 @@ describe('@noCI Test choroplethmapbox hover:', function() { desc: 'with "typeof number" locations[i] and feature id (in *nums* label case)', patch: function() { var fig = Lib.extendDeep({}, require('@mocks/mapbox_choropleth-raw-geojson.json')); - fig.data.shift(); + fig.data = [fig.data[1]]; fig.data[0].locations = [100]; fig.data[0].geojson.id = 100; fig.data[0].hoverinfo = 'location+name'; @@ -618,14 +619,14 @@ describe('@noCI Test choroplethmapbox hover:', function() { desc: 'with "typeof number" locations[i] and feature id (hovertemplate case)', patch: function() { var fig = Lib.extendDeep({}, require('@mocks/mapbox_choropleth-raw-geojson.json')); - fig.data.shift(); + fig.data = [fig.data[1]]; fig.data[0].locations = [100]; fig.data[0].geojson.id = 100; - fig.data[0].hovertemplate = '### %{location}%{location} ###'; + fig.data[0].hovertemplate = '### %{location}%{ct[0]:.1f} | %{ct[1]:.1f} ###'; return fig; }, nums: '### 100', - name: '100 ###', + name: '-86.7 | 32.0 ###', evtPts: [{location: 100, z: 10, pointNumber: 0, curveNumber: 0}] }]; diff --git a/test/jasmine/tests/geo_test.js b/test/jasmine/tests/geo_test.js index 897a84981fe..3828874767c 100644 --- a/test/jasmine/tests/geo_test.js +++ b/test/jasmine/tests/geo_test.js @@ -128,7 +128,7 @@ describe('Test Geo layout defaults', function() { }); }); - it('should not coerce projection.parallels if type is conic', function() { + it('should only coerce projection.parallels if type is conic', function() { var projTypes = layoutAttributes.projection.type.values; function testOne(projType) { @@ -463,6 +463,229 @@ describe('Test Geo layout defaults', function() { }); }); }); + + describe('should clear attributes that get auto-filled under *fitbounds*', function() { + var vals = ['locations', 'geojson']; + + function _assert(exp) { + expect(layoutOut.geo.projection.scale).toBe(exp['projection.scale'], 'projection.scale'); + expect(layoutOut.geo.center.lon).toBe(exp['center.lon'], 'center.lon'); + expect(layoutOut.geo.center.lat).toBe(exp['center.lat'], 'center.lat'); + expect(layoutOut.geo.projection.rotation.lon).toBe(exp['projection.rotation.lon'], 'projection.rotation.lon'); + expect(layoutOut.geo.projection.rotation.lat).toBe(exp['projection.rotation.lat'], 'projection.rotation.lat'); + expect(layoutOut.geo.lonaxis.range).withContext('lonaxis.range').toEqual(exp['lonaxis.range'], 'lonaxis.range'); + expect(layoutOut.geo.lataxis.range).withContext('lataxis.range').toEqual(exp['lataxis.range'], 'lataxis.range'); + } + + describe('- for scoped maps', function() { + it('fitbounds:false (base case)', function() { + layoutIn = { + geo: { + scope: 'europe', + fitbounds: false + } + }; + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + _assert({ + 'projection.scale': 1, + 'center.lon': 15, + 'center.lat': 57.5, + 'projection.rotation.lon': 15, + 'projection.rotation.lat': 0, + 'lonaxis.range': [-30, 60], + 'lataxis.range': [30, 85] + }); + }); + + vals.forEach(function(v) { + it('fitbounds:' + v, function() { + layoutIn = { + geo: { + scope: 'europe', + fitbounds: v + } + }; + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + _assert({ + 'projection.scale': undefined, + 'center.lon': undefined, + 'center.lat': undefined, + 'projection.rotation.lon': 15, + 'projection.rotation.lat': 0, + 'lonaxis.range': [-30, 60], + 'lataxis.range': [30, 85] + }); + }); + }); + }); + + describe('- for clipped projections', function() { + it('fitbounds:false (base case)', function() { + layoutIn = { + geo: { + projection: { + type: 'orthographic', + rotation: {lon: 20, lat: 20}, + scale: 2 + }, + fitbounds: false, + } + }; + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + _assert({ + 'projection.scale': 2, + 'center.lon': 20, + 'center.lat': 20, + 'projection.rotation.lon': 20, + 'projection.rotation.lat': 20, + 'lonaxis.range': [-70, 110], + 'lataxis.range': [-70, 110] + }); + }); + + vals.forEach(function(v) { + it('fitbounds:' + v, function() { + layoutIn = { + geo: { + projection: { + type: 'orthographic', + rotation: {lon: 20, lat: 20}, + scale: 2 + }, + fitbounds: v + } + }; + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + _assert({ + 'projection.scale': undefined, + 'center.lon': undefined, + 'center.lat': undefined, + 'projection.rotation.lon': undefined, + 'projection.rotation.lat': undefined, + 'lonaxis.range': undefined, + 'lataxis.range': undefined + }); + }); + }); + }); + + describe('- for non-clipped projections', function() { + it('fitbounds:false (base case)', function() { + layoutIn = { + geo: { + projection: { + type: 'natural earth', + rotation: {lon: 20}, + scale: 2 + }, + lonaxis: {range: [-90, 90]}, + lataxis: {range: [0, 80]}, + fitbounds: false, + } + }; + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + _assert({ + 'projection.scale': 2, + 'center.lon': 20, + 'center.lat': 40, + 'projection.rotation.lon': 20, + 'projection.rotation.lat': 0, + 'lonaxis.range': [-90, 90], + 'lataxis.range': [0, 80] + }); + }); + + vals.forEach(function(v) { + it('fitbounds:' + v, function() { + layoutIn = { + geo: { + projection: { + type: 'natural earth', + rotation: {lon: 20}, + scale: 2 + }, + lonaxis: {range: [-90, 90]}, + lataxis: {range: [0, 80]}, + fitbounds: v, + } + }; + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + _assert({ + 'projection.scale': undefined, + 'center.lon': undefined, + 'center.lat': undefined, + 'projection.rotation.lon': undefined, + 'projection.rotation.lat': 0, + 'lonaxis.range': [-90, 90], + 'lataxis.range': [0, 80] + }); + }); + }); + }); + }); + + describe('geo.visible should override show* defaults', function() { + var keys = [ + 'lonaxis.showgrid', + 'lataxis.showgrid', + 'showcoastlines', + 'showocean', + 'showland', + 'showlakes', + 'showrivers', + 'showcountries', + 'showsubunits', + 'showframe' + ]; + + function _assert(extra) { + var geo = layoutOut.geo; + keys.forEach(function(k) { + var actual = Lib.nestedProperty(geo, k).get(); + if(extra && k in extra) { + expect(actual).toBe(extra[k], k); + } else { + expect(actual).toBe(false, k); + } + }); + } + + it('- base case', function() { + layoutIn = { + geo: { visible: false } + }; + + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + _assert({ + showsubunits: undefined + }); + }); + + it('- scoped case', function() { + layoutIn = { + geo: { scope: 'europe', visible: false } + }; + + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + _assert({ + showframe: undefined, + showsubunits: undefined + }); + }); + + it('- scope:usa case', function() { + layoutIn = { + geo: { scope: 'usa', visible: false } + }; + + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + _assert({ + showframe: undefined, + showcoastlines: undefined, + showocean: undefined + }); + }); + }); }); describe('geojson / topojson utils', function() { @@ -701,7 +924,7 @@ describe('Test geo interactions', function() { it('should contain the correct fields', function() { expect(Object.keys(ptData)).toEqual([ 'data', 'fullData', 'curveNumber', 'pointNumber', 'pointIndex', - 'location', 'z' + 'location', 'z', 'ct' ]); }); @@ -729,7 +952,7 @@ describe('Test geo interactions', function() { it('should contain the correct fields', function() { expect(Object.keys(ptData)).toEqual([ 'data', 'fullData', 'curveNumber', 'pointNumber', 'pointIndex', - 'location', 'z' + 'location', 'z', 'ct' ]); }); @@ -761,7 +984,7 @@ describe('Test geo interactions', function() { it('should contain the correct fields', function() { expect(Object.keys(ptData)).toEqual([ 'data', 'fullData', 'curveNumber', 'pointNumber', 'pointIndex', - 'location', 'z' + 'location', 'z', 'ct' ]); }); @@ -1192,6 +1415,7 @@ describe('Test geo interactions', function() { expect(geoLayout.lataxis.range).toEqual([-90, 90]); expect(geo.viewInitial).toEqual({ + 'fitbounds': false, 'projection.rotation.lon': 0, 'center.lon': 0, 'center.lat': 0, @@ -1303,6 +1527,7 @@ describe('Test geo interactions', function() { Plotly.react(gd, figWorld) .then(function() { _assertViewInitial('world scope', { + 'fitbounds': false, 'center.lon': 0, 'center.lat': 0, 'projection.scale': 1, @@ -1312,6 +1537,7 @@ describe('Test geo interactions', function() { .then(function() { return Plotly.react(gd, figUSA); }) .then(function() { _assertViewInitial('react to usa scope', { + 'fitbounds': false, 'center.lon': -96.6, 'center.lat': 38.7, 'projection.scale': 1 @@ -1320,6 +1546,7 @@ describe('Test geo interactions', function() { .then(function() { return Plotly.react(gd, figNA); }) .then(function() { _assertViewInitial('react to NA scope', { + 'fitbounds': false, 'center.lon': -112.5, 'center.lat': 45, 'projection.scale': 1 @@ -1328,6 +1555,7 @@ describe('Test geo interactions', function() { .then(function() { return Plotly.react(gd, figWorld); }) .then(function() { _assertViewInitial('react back to world scope', { + 'fitbounds': false, 'center.lon': 0, 'center.lat': 0, 'projection.scale': 1, @@ -1390,6 +1618,21 @@ describe('Test geo interactions', function() { .catch(failTest) .then(done); }); + + it('- geo.visible:false', function(done) { + Plotly.plot(gd, [{ + type: 'scattergeo', + lon: [0], + lat: [0] + }], { + geo: {visible: false} + }) + .then(_assert(0)) + .then(function() { return Plotly.relayout(gd, 'geo.visible', true); }) + .then(_assert(1)) + .catch(failTest) + .then(done); + }); }); }); @@ -1774,25 +2017,27 @@ describe('Test geo zoom/pan/drag interactions:', function() { }); } - it('should work for non-clipped projections', function(done) { - var fig = Lib.extendDeep({}, require('@mocks/geo_winkel-tripel')); - fig.layout.width = 700; - fig.layout.height = 500; - fig.layout.dragmode = 'pan'; + describe('should work for non-clipped projections', function() { + var fig; + + beforeEach(function() { + fig = Lib.extendDeep({}, require('@mocks/geo_winkel-tripel')); + fig.layout.width = 700; + fig.layout.height = 500; + fig.layout.dragmode = 'pan'; + }); function _assert(step, attr, proj, eventKeys) { var msg = '[' + step + '] '; var geoLayout = gd._fullLayout.geo; - var rotation = geoLayout.projection.rotation; - var center = geoLayout.center; + var rotation = geoLayout.projection.rotation || {}; + var center = geoLayout.center || {}; var scale = geoLayout.projection.scale; - expect(rotation.lon).toBeCloseTo(attr[0][0], 1, msg + 'rotation.lon'); - expect(rotation.lat).toBeCloseTo(attr[0][1], 1, msg + 'rotation.lat'); - expect(center.lon).toBeCloseTo(attr[1][0], 1, msg + 'center.lon'); - expect(center.lat).toBeCloseTo(attr[1][1], 1, msg + 'center.lat'); - expect(scale).toBeCloseTo(attr[2], 1, msg + 'zoom'); + expect([rotation.lon, rotation.lat]).toBeCloseToArray(attr[0], 1, msg + 'rotation.(lon|lat)'); + expect([center.lon, center.lat]).toBeCloseToArray(attr[1], 1, msg + 'center.(lon|lat)'); + expect(scale)[typeof scale === 'number' ? 'toBeCloseTo' : 'toBe'](attr[2], 1, msg + 'zoom'); var geo = geoLayout._subplot; var rotate = geo.projection.rotate(); @@ -1811,85 +2056,145 @@ describe('Test geo zoom/pan/drag interactions:', function() { assertEventData(msg, eventKeys); } - plot(fig).then(function() { - _assert('base', [ - [-90, 0], [-90, 0], 1 - ], [ - [90, 0], [350, 260], [0, 0], 101.9 - ], undefined); - return drag({path: [[350, 250], [400, 250]], noCover: true}); - }) - .then(function() { - _assert('after east-west drag', [ - [-124.4, 0], [-124.4, 0], 1 - ], [ - [124.4, 0], [350, 260], [0, 0], 101.9 - ], [ - 'geo.projection.rotation.lon', 'geo.center.lon' - ]); - return drag({path: [[400, 250], [400, 300]], noCover: true}); - }) - .then(function() { - _assert('after north-south drag', [ - [-124.4, 0], [-124.4, 28.1], 1 - ], [ - [124.4, 0], [350, 310], [0, 0], 101.9 - ], [ - 'geo.center.lat' - ]); - return scroll([200, 250], [-200, -200]); - }) - .then(function() { - _assert('after off-center scroll', [ - [-151.2, 0], [-151.2, 29.5], 1.3 - ], [ - [151.2, 0], [350, 329.2], [0, 0], 134.4 - ], [ - 'geo.projection.rotation.lon', - 'geo.center.lon', 'geo.center.lat', - 'geo.projection.scale' - ]); - return Plotly.relayout(gd, 'geo.showocean', false); - }) - .then(function() { - _assert('after some relayout call that causes a replot', [ - [-151.2, 0], [-151.2, 29.5], 1.3 - ], [ - // converts translate (px) to center (lonlat) - [151.2, 0], [350, 260], [0, 29.5], 134.4 - ], [ - 'geo.showocean' - ]); - return dblClick([350, 250]); - }) - .then(function() { - // resets to initial view - _assert('after double click', [ - [-90, 0], [-90, 0], 1 - ], [ - [90, 0], [350, 260], [0, 0], 101.9 - ], 'dblclick'); - }) - .catch(failTest) - .then(done); + it('- base case', function(done) { + plot(fig).then(function() { + _assert('base', [ + [-90, 0], [-90, 0], 1 + ], [ + [90, 0], [350, 260], [0, 0], 101.9 + ], undefined); + return drag({path: [[350, 250], [400, 250]], noCover: true}); + }) + .then(function() { + _assert('after east-west drag', [ + [-124.4, 0], [-124.4, 0], 1 + ], [ + [124.4, 0], [350, 260], [0, 0], 101.9 + ], [ + 'geo.projection.rotation.lon', 'geo.center.lon' + ]); + return drag({path: [[400, 250], [400, 300]], noCover: true}); + }) + .then(function() { + _assert('after north-south drag', [ + [-124.4, 0], [-124.4, 28.1], 1 + ], [ + [124.4, 0], [350, 310], [0, 0], 101.9 + ], [ + 'geo.center.lat' + ]); + return scroll([200, 250], [-200, -200]); + }) + .then(function() { + _assert('after off-center scroll', [ + [-151.2, 0], [-151.2, 29.5], 1.3 + ], [ + [151.2, 0], [350, 329.2], [0, 0], 134.4 + ], [ + 'geo.projection.rotation.lon', + 'geo.center.lon', 'geo.center.lat', + 'geo.projection.scale' + ]); + return Plotly.relayout(gd, 'geo.showocean', false); + }) + .then(function() { + _assert('after some relayout call that causes a replot', [ + [-151.2, 0], [-151.2, 29.5], 1.3 + ], [ + // converts translate (px) to center (lonlat) + [151.2, 0], [350, 260], [0, 29.5], 134.4 + ], [ + 'geo.showocean' + ]); + return dblClick([350, 250]); + }) + .then(function() { + // resets to initial view + _assert('after double click', [ + [-90, 0], [-90, 0], 1 + ], [ + [90, 0], [350, 260], [0, 0], 101.9 + ], 'dblclick'); + }) + .catch(failTest) + .then(done); + }); + + it('- fitbounds case', function(done) { + fig.layout.geo.fitbounds = 'locations'; + + plot(fig).then(function() { + _assert('base', [ + [undefined, 0], [undefined, undefined], undefined + ], [ + [-180, -0], [350, 260], [0, 0], 114.59 + ], undefined); + return drag({path: [[350, 250], [400, 250]], noCover: true}); + }) + .then(function() { + _assert('after east-west drag', [ + [149.40, 0], [149.40, 0], 1.1249 + ], [ + [-149.40, 0], [350, 260], [0, 0], 114.59 + ], [ + 'geo.projection.rotation.lon', 'geo.center.lon', 'geo.center.lat', + 'geo.projection.scale', 'geo.fitbounds' + ]); + return scroll([200, 250], [-200, -200]); + }) + .then(function() { + _assert('after off-center scroll', [ + [127.176, 0], [127.176, 1.21], 1.484 + ], [ + [-127.176, 0], [350, 263.195], [0, 0], 151.20 + ], [ + 'geo.projection.rotation.lon', 'geo.center.lon', + 'geo.center.lat', 'geo.projection.scale' + ]); + return Plotly.relayout(gd, 'geo.showocean', false); + }) + .then(function() { + _assert('after some relayout call that causes a replot', [ + // converts translate (px) to center (lonlat) + [127.176, 0], [127.176, 1.21], 1.484 + ], [ + [-127.176, 0], [350, 260], [0, 1.21], 151.20 + ], [ + 'geo.showocean' + ]); + return dblClick([350, 250]); + }) + .then(function() { + _assert('after double click', [ + [undefined, 0], [undefined, undefined], undefined + ], [ + [-180, -0], [350, 260], [0, 0], 114.59 + ], 'dblclick'); + }) + .catch(failTest) + .then(done); + }); }); - it('should work for clipped projections', function(done) { - var fig = Lib.extendDeep({}, require('@mocks/geo_orthographic')); - fig.layout.dragmode = 'pan'; + describe('should work for clipped projections', function() { + var fig; - // of layout width = height = 500 + beforeEach(function() { + fig = Lib.extendDeep({}, require('@mocks/geo_orthographic')); + fig.layout.dragmode = 'pan'; + + // of layout width = height = 500 + }); function _assert(step, attr, proj, eventKeys) { var msg = '[' + step + '] '; var geoLayout = gd._fullLayout.geo; - var rotation = geoLayout.projection.rotation; + var rotation = geoLayout.projection.rotation || {}; var scale = geoLayout.projection.scale; - expect(rotation.lon).toBeCloseTo(attr[0][0], 0, msg + 'rotation.lon'); - expect(rotation.lat).toBeCloseTo(attr[0][1], 0, msg + 'rotation.lat'); - expect(scale).toBeCloseTo(attr[1], 1, msg + 'zoom'); + expect([rotation.lon, rotation.lat]).toBeCloseToArray(attr[0], -0.5, msg + 'rotation.(lon|lat)'); + expect(scale)[typeof scale === 'number' ? 'toBeCloseTo' : 'toBe'](attr[1], 1, msg + 'zoom'); var geo = geoLayout._subplot; var rotate = geo.projection.rotate(); @@ -1902,84 +2207,144 @@ describe('Test geo zoom/pan/drag interactions:', function() { assertEventData(msg, eventKeys); } - plot(fig).then(function() { - _assert('base', [ - [-75, 45], 1 - ], [ - [75, -45], 160 - ], undefined); - return drag({path: [[250, 250], [300, 250]], noCover: true}); - }) - .then(function() { - _assert('after east-west drag', [ - [-103.7, 49.3], 1 - ], [ - [103.7, -49.3], 160 - ], [ - 'geo.projection.rotation.lon', 'geo.projection.rotation.lat' - ]); - return drag({path: [[250, 250], [300, 300]], noCover: true}); - }) - .then(function() { - _assert('after NW-SE drag', [ - [-135.5, 73.8], 1 - ], [ - [135.5, -73.8], 160 - ], [ - 'geo.projection.rotation.lon', 'geo.projection.rotation.lat' - ]); - return scroll([300, 300], [-200, -200]); - }) - .then(function() { - _assert('after scroll', [ - [-126.2, 67.1], 1.3 - ], [ - [126.2, -67.1], 211.1 - ], [ - 'geo.projection.rotation.lon', 'geo.projection.rotation.lat', - 'geo.projection.scale' - ]); - return Plotly.relayout(gd, 'geo.showocean', false); - }) - .then(function() { - _assert('after some relayout call that causes a replot', [ - [-126.2, 67.1], 1.3 - ], [ - [126.2, -67.1], 211.1 - ], [ - 'geo.showocean' - ]); - return dblClick([350, 250]); - }) - .then(function() { - // resets to initial view - _assert('after double click', [ - [-75, 45], 1 - ], [ - [75, -45], 160 - ], 'dblclick'); - }) - .catch(failTest) - .then(done); + it('- base case', function(done) { + plot(fig).then(function() { + _assert('base', [ + [-75, 45], 1 + ], [ + [75, -45], 160 + ], undefined); + return drag({path: [[250, 250], [300, 250]], noCover: true}); + }) + .then(function() { + _assert('after east-west drag', [ + [-103.7, 49.3], 1 + ], [ + [103.7, -49.3], 160 + ], [ + 'geo.projection.rotation.lon', 'geo.projection.rotation.lat' + ]); + return drag({path: [[250, 250], [300, 300]], noCover: true}); + }) + .then(function() { + _assert('after NW-SE drag', [ + [-135.5, 73.8], 1 + ], [ + [135.5, -73.8], 160 + ], [ + 'geo.projection.rotation.lon', 'geo.projection.rotation.lat' + ]); + return scroll([300, 300], [-200, -200]); + }) + .then(function() { + _assert('after scroll', [ + [-126.2, 67.1], 1.3 + ], [ + [126.2, -67.1], 211.1 + ], [ + 'geo.projection.rotation.lon', 'geo.projection.rotation.lat', + 'geo.projection.scale' + ]); + return Plotly.relayout(gd, 'geo.showocean', false); + }) + .then(function() { + _assert('after some relayout call that causes a replot', [ + [-126.2, 67.1], 1.3 + ], [ + [126.2, -67.1], 211.1 + ], [ + 'geo.showocean' + ]); + return dblClick([350, 250]); + }) + .then(function() { + // resets to initial view + _assert('after double click', [ + [-75, 45], 1 + ], [ + [75, -45], 160 + ], 'dblclick'); + }) + .catch(failTest) + .then(done); + }); + + it('- fitbounds case', function(done) { + fig.layout.geo.fitbounds = 'locations'; + + plot(fig).then(function() { + _assert('base', [ + [undefined, undefined], undefined + ], [ + [0.252, -19.8], 160 + ], undefined); + return drag({path: [[250, 250], [300, 250]], noCover: true}); + }) + .then(function() { + _assert('after east-west drag', [ + [-20.32, 21.226], 1 + ], [ + [20.32, -21.226], 160 + ], [ + 'geo.projection.rotation.lon', 'geo.projection.rotation.lat', + 'geo.projection.scale', 'geo.fitbounds' + ]); + return scroll([300, 300], [-100, -100]); + }) + .then(function() { + _assert('after scroll', [ + [-17.5597, 18.862], 1.1488 + ], [ + [17.5597, -18.862], 183.818 + ], [ + 'geo.projection.rotation.lon', 'geo.projection.rotation.lat', + 'geo.projection.scale' + ]); + return Plotly.relayout(gd, 'geo.showocean', false); + }) + .then(function() { + _assert('after some relayout call that causes a replot', [ + [-17.5597, 18.862], 1.1488 + ], [ + [17.5597, -18.862], 183.818 + ], [ + 'geo.showocean' + ]); + return dblClick([350, 250]); + }) + .then(function() { + // resets to initial view + _assert('after double click', [ + [undefined, undefined], undefined + ], [ + [0.252, -19.8], 160 + ], 'dblclick'); + }) + .catch(failTest) + .then(done); + }); }); - it('should work for scoped projections', function(done) { - var fig = Lib.extendDeep({}, require('@mocks/geo_europe-bubbles')); - fig.layout.geo.resolution = 110; - fig.layout.dragmode = 'pan'; + describe('should work for scoped projections', function() { + var fig; + + beforeEach(function() { + fig = Lib.extendDeep({}, require('@mocks/geo_europe-bubbles')); + fig.layout.geo.resolution = 110; + fig.layout.dragmode = 'pan'; - // of layout width = height = 500 + // of layout width = height = 500 + }); function _assert(step, attr, proj, eventKeys) { var msg = '[' + step + '] '; var geoLayout = gd._fullLayout.geo; - var center = geoLayout.center; + var center = geoLayout.center || {}; var scale = geoLayout.projection.scale; - expect(center.lon).toBeCloseTo(attr[0][0], -0.5, msg + 'center.lon'); - expect(center.lat).toBeCloseTo(attr[0][1], -0.5, msg + 'center.lat'); - expect(scale).toBeCloseTo(attr[1], 1, msg + 'zoom'); + expect([center.lon, center.lat]).toBeCloseToArray(attr[0], -0.5, msg + 'center.(lon|lat)'); + expect(scale)[typeof scale === 'number' ? 'toBeCloseTo' : 'toBe'](attr[1], 1, msg + 'zoom'); var geo = geoLayout._subplot; var translate = geo.projection.translate(); @@ -1995,55 +2360,112 @@ describe('Test geo zoom/pan/drag interactions:', function() { assertEventData(msg, eventKeys); } - plot(fig).then(function() { - _assert('base', [ - [15, 57.5], 1, - ], [ - [247, 260], [0, 57.5], 292.2 - ], undefined); - return drag({path: [[250, 250], [200, 200]], noCover: true}); - }) - .then(function() { - _assert('after SW-NE drag', [ - [30.9, 46.2], 1 - ], [ - // changes translate(), but not center() - [197, 210], [0, 57.5], 292.2 - ], [ - 'geo.center.lon', 'geo.center.lon' - ]); - return scroll([300, 300], [-200, -200]); - }) - .then(function() { - _assert('after scroll', [ - [34.3, 43.6], 1.3 - ], [ - [164.1, 181.2], [0, 57.5], 385.5 - ], [ - 'geo.center.lon', 'geo.center.lon', 'geo.projection.scale' - ]); - return Plotly.relayout(gd, 'geo.showlakes', true); - }) - .then(function() { - _assert('after some relayout call that causes a replot', [ - [34.3, 43.6], 1.3 - ], [ - // changes are now reflected in 'center' - [247, 260], [19.3, 43.6], 385.5 - ], [ - 'geo.showlakes' - ]); - return dblClick([250, 250]); - }) - .then(function() { - _assert('after double click', [ - [15, 57.5], 1, - ], [ - [247, 260], [0, 57.5], 292.2 - ], 'dblclick'); - }) - .catch(failTest) - .then(done); + it('- base case', function(done) { + plot(fig).then(function() { + _assert('base', [ + [15, 57.5], 1, + ], [ + [247, 260], [0, 57.5], 292.2 + ], undefined); + return drag({path: [[250, 250], [200, 200]], noCover: true}); + }) + .then(function() { + _assert('after SW-NE drag', [ + [30.9, 46.2], 1 + ], [ + // changes translate(), but not center() + [197, 210], [0, 57.5], 292.2 + ], [ + 'geo.center.lon', 'geo.center.lon' + ]); + return scroll([300, 300], [-200, -200]); + }) + .then(function() { + _assert('after scroll', [ + [34.3, 43.6], 1.3 + ], [ + [164.1, 181.2], [0, 57.5], 385.5 + ], [ + 'geo.center.lon', 'geo.center.lon', 'geo.projection.scale' + ]); + return Plotly.relayout(gd, 'geo.showlakes', true); + }) + .then(function() { + _assert('after some relayout call that causes a replot', [ + [34.3, 43.6], 1.3 + ], [ + // changes are now reflected in 'center' + [247, 260], [19.3, 43.6], 385.5 + ], [ + 'geo.showlakes' + ]); + return dblClick([250, 250]); + }) + .then(function() { + _assert('after double click', [ + [15, 57.5], 1, + ], [ + [247, 260], [0, 57.5], 292.2 + ], 'dblclick'); + }) + .catch(failTest) + .then(done); + }); + + it('- fitbounds case', function(done) { + fig.layout.geo.fitbounds = 'locations'; + + plot(fig).then(function() { + _assert('base', [ + [undefined, undefined], undefined, + ], [ + [247, 260], [5.7998, 49.29], 504.8559 + ], undefined); + return drag({path: [[250, 250], [200, 200]], noCover: true}); + }) + .then(function() { + _assert('after SW-NE drag', [ + [29.059, 42.38], 1.727 + ], [ + [197, 210], [5.7988, 49.29], 504.8559 + ], [ + 'geo.center.lon', 'geo.center.lon', + 'geo.projection.scale', 'geo.fitbounds' + ]); + return scroll([300, 300], [-200, -200]); + }) + .then(function() { + _assert('after scroll', [ + [31.027, 40.91], 2.28 + ], [ + [164.09, 181.24], [5.7988, 49.29], 666.16 + ], [ + 'geo.center.lon', 'geo.center.lon', + 'geo.projection.scale' + ]); + return Plotly.relayout(gd, 'geo.showlakes', true); + }) + .then(function() { + _assert('after some relayout call that causes a replot', [ + [31.027, 40.91], 2.28 + ], [ + // changes are now reflected in 'center' + [247, 260], [16.027, 40.91], 666.16 + ], [ + 'geo.showlakes' + ]); + return dblClick([250, 250]); + }) + .then(function() { + _assert('after double click', [ + [undefined, undefined], undefined, + ], [ + [247, 260], [5.7998, 49.29], 504.8559 + ], 'dblclick'); + }) + .catch(failTest) + .then(done); + }); }); it('should work for *albers usa* projections', function(done) { diff --git a/test/jasmine/tests/plot_api_react_test.js b/test/jasmine/tests/plot_api_react_test.js index e622f3bb175..1050c7225fa 100644 --- a/test/jasmine/tests/plot_api_react_test.js +++ b/test/jasmine/tests/plot_api_react_test.js @@ -1450,6 +1450,39 @@ describe('Plotly.react and uirevision attributes', function() { _run(fig, editView, checkOriginalView, checkEditedView).then(done); }); + it('preserves geo viewport changes using geo.uirevision (fitbounds case)', function(done) { + function fig(mainRev, geoRev) { + return { + data: [{ + type: 'scattergeo', lon: [0, -75], lat: [0, 45] + }], + layout: { + uirevision: mainRev, + geo: {uirevision: geoRev, fitbounds: 'locations'} + } + }; + } + + function attrs(original) { + return { + 'geo.fitbounds': original ? ['locations', 'locations'] : false, + 'geo.projection.scale': original ? [undefined, undefined] : 3, + 'geo.projection.rotation.lon': original ? [undefined, undefined] : -45, + 'geo.center.lat': original ? [undefined, undefined] : 22, + 'geo.center.lon': original ? [undefined, undefined] : -45 + }; + } + + function editView() { + return Registry.call('_guiRelayout', gd, attrs()); + } + + var checkOriginalView = checkState([], attrs(true)); + var checkEditedView = checkState([], attrs()); + + _run(fig, editView, checkOriginalView, checkEditedView).then(done); + }); + it('@gl preserves 3d camera changes using scene.uirevision', function(done) { function fig(mainRev, sceneRev) { return { diff --git a/test/jasmine/tests/scattergeo_test.js b/test/jasmine/tests/scattergeo_test.js index 6fe010fe392..e13ef7bc097 100644 --- a/test/jasmine/tests/scattergeo_test.js +++ b/test/jasmine/tests/scattergeo_test.js @@ -1,6 +1,7 @@ var Plotly = require('@lib'); var Lib = require('@src/lib'); var BADNUM = require('@src/constants/numerical').BADNUM; +var loggers = require('@src/lib/loggers'); var ScatterGeo = require('@src/traces/scattergeo'); @@ -86,6 +87,67 @@ describe('Test scattergeo defaults', function() { traceOut = {}; testOne(); }); + + it('should default locationmode to *geojson-id* when a valid *geojson* is provided', function() { + traceIn = { + locations: ['CAN', 'USA'], + geojson: 'url' + }; + traceOut = {}; + ScatterGeo.supplyDefaults(traceIn, traceOut, defaultColor, layout); + expect(traceOut.locationmode).toBe('geojson-id', 'valid url string'); + + traceIn = { + locations: ['CAN', 'USA'], + geojson: {} + }; + traceOut = {}; + ScatterGeo.supplyDefaults(traceIn, traceOut, defaultColor, layout); + expect(traceOut.locationmode).toBe('geojson-id', 'valid object'); + + traceIn = { + locations: ['CAN', 'USA'], + geojson: '' + }; + traceOut = {}; + ScatterGeo.supplyDefaults(traceIn, traceOut, defaultColor, layout); + expect(traceOut.locationmode).toBe('ISO-3', 'invalid sting'); + + traceIn = { + locations: ['CAN', 'USA'], + geojson: [] + }; + traceOut = {}; + ScatterGeo.supplyDefaults(traceIn, traceOut, defaultColor, layout); + expect(traceOut.locationmode).toBe('ISO-3', 'invalid object'); + + traceIn = { + lon: [20, 40], + lat: [20, 40] + }; + traceOut = {}; + ScatterGeo.supplyDefaults(traceIn, traceOut, defaultColor, layout); + expect(traceOut.locationmode).toBe(undefined, 'lon/lat coordinates'); + }); + + it('should only coerce *featureidkey* when locationmode is *geojson-id', function() { + traceIn = { + locations: ['CAN', 'USA'], + geojson: 'url', + featureidkey: 'properties.name' + }; + traceOut = {}; + ScatterGeo.supplyDefaults(traceIn, traceOut, defaultColor, layout); + expect(traceOut.featureidkey).toBe('properties.name', 'coerced'); + + traceIn = { + locations: ['CAN', 'USA'], + featureidkey: 'properties.name' + }; + traceOut = {}; + ScatterGeo.supplyDefaults(traceIn, traceOut, defaultColor, layout); + expect(traceOut.featureidkey).toBe(undefined, 'NOT coerced'); + }); }); describe('Test scattergeo calc', function() { @@ -385,6 +447,22 @@ describe('Test scattergeo hover', function() { .then(done); }); }); + + it('should include *properties* from input custom geojson', function(done) { + var fig = Lib.extendDeep({}, require('@mocks/geo_custom-geojson.json')); + fig.data = [fig.data[3]]; + fig.data[0].geo = 'geo'; + fig.data[0].marker = {size: 40}; + fig.data[0].hovertemplate = '%{properties.name}LOOK'; + fig.layout.geo.projection = {scale: 30}; + + Plotly.react(gd, fig) + .then(function() { + check([275, 255], ['New York', 'LOOK']); + }) + .catch(failTest) + .then(done); + }); }); describe('scattergeo drawing', function() { @@ -397,7 +475,7 @@ describe('scattergeo drawing', function() { afterEach(destroyGraphDiv); it('should not throw an error with bad locations', function(done) { - spyOn(Lib, 'log'); + spyOn(loggers, 'log'); Plotly.newPlot(gd, [{ locations: ['canada', 0, null, '', 'utopia'], locationmode: 'country names', @@ -405,7 +483,7 @@ describe('scattergeo drawing', function() { }]) .then(function() { // only utopia logs - others are silently ignored - expect(Lib.log).toHaveBeenCalledTimes(1); + expect(loggers.log).toHaveBeenCalledTimes(1); }) .catch(failTest) .then(done);