diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d1ebcf3e86b..7af01ba4806 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -157,6 +157,18 @@ which shows the baseline image, the generated image, the diff and the json mocks To view the results of a run on CircleCI, download the `build/test_images/` and `build/test_images_diff/` artifacts into your local repo and then run `npm run start-image_viewer`. +### Note on testing our `mapbox-gl` integration + +Creating `mapbox-gl` graphs requires an +[`accessToken`](https://www.mapbox.com/help/define-access-token/). To make sure +that mapbox image and jasmine tests run properly, locate your Mapbox access +token and run: + + +```bash +export MAPBOX_ACCESS_TOKEN="" && npm run pretest +``` + ## Repo organization diff --git a/circle.yml b/circle.yml index e0186360ab4..5a726bd6467 100644 --- a/circle.yml +++ b/circle.yml @@ -15,12 +15,13 @@ dependencies: - docker pull plotly/testbed:latest post: - npm run cibuild + - npm run pretest - docker run -d --name mytestbed -v $PWD:/var/www/streambed/image_server/plotly.js -p 9010:9010 plotly/testbed:latest - sudo ./tasks/run_in_testbed.sh mytestbed "cp -f test/image/index.html ../server_app/index.html" - wget --server-response --spider --tries=8 --retry-connrefused http://localhost:9010/ping test: override: - - sudo ./tasks/run_in_testbed.sh mytestbed "node test/image/compare_pixels_test.js" + - sudo ./tasks/run_in_testbed.sh mytestbed "export CIRCLECI=1 && node test/image/compare_pixels_test.js" - sudo ./tasks/run_in_testbed.sh mytestbed "node test/image/export_test.js" - npm run citest-jasmine - npm run test-bundle diff --git a/devtools/test_dashboard/devtools.js b/devtools/test_dashboard/devtools.js index 50ad7a4d041..ea5b7b71636 100644 --- a/devtools/test_dashboard/devtools.js +++ b/devtools/test_dashboard/devtools.js @@ -4,6 +4,7 @@ var Fuse = require('fuse.js'); var mocks = require('../../build/test_dashboard_mocks.json'); +var credentials = require('../../build/credentials.json'); // put d3 in window scope var d3 = window.d3 = Plotly.d3; @@ -14,8 +15,15 @@ var Tabs = { // Set plot config options setPlotConfig: function() { Plotly.setPlotConfig({ + // use local topojson files topojsonURL: '../../dist/topojson/', + + // register mapbox access token + // run `npm run preset` if you haven't yet + mapboxAccessToken: credentials.MAPBOX_ACCESS_TOKEN, + + // show all logs in console logging: 2 }); }, diff --git a/lib/scattermapbox.js b/lib/scattermapbox.js new file mode 100644 index 00000000000..74af071f2eb --- /dev/null +++ b/lib/scattermapbox.js @@ -0,0 +1,9 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +module.exports = require('../src/traces/scattermapbox'); diff --git a/package.json b/package.json index 3f4d2fae81b..32a285e2a7b 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "watch": "node tasks/watch_plotly.js", "lint": "eslint . || true", "lint-fix": "eslint . --fix", + "pretest": "node tasks/pretest.js", "test-jasmine": "karma start test/jasmine/karma.conf.js", "citest-jasmine": "karma start test/jasmine/karma.ciconf.js", "test-image": "./tasks/test_image.sh", @@ -70,6 +71,7 @@ "gl-select-box": "^1.0.1", "gl-spikes2d": "^1.0.1", "gl-surface3d": "^1.2.3", + "mapbox-gl": "^0.18.0", "mouse-change": "^1.1.1", "mouse-wheel": "^1.0.2", "ndarray": "^1.0.16", diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 3d16e72fbe2..5b991a0e8f1 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -1648,7 +1648,7 @@ Plotly.restyle = function restyle(gd, astr, val, traces) { // to not go through a full replot var doPlotWhiteList = ['cartesian', 'pie', 'ternary']; fullLayout._basePlotModules.forEach(function(_module) { - if(doPlotWhiteList.indexOf(_module.name) === -1) doplot = true; + if(doPlotWhiteList.indexOf(_module.name) === -1) docalc = true; }); // make a new empty vals array for undoit @@ -2286,6 +2286,19 @@ Plotly.relayout = function relayout(gd, astr, val) { Images.supplyLayoutDefaults(gd.layout, gd._fullLayout); Images.draw(gd); } + else if(p.parts[0] === 'mapbox' && p.parts[1] === 'layers') { + Lib.extendDeepAll(gd.layout, Lib.objectFromPath(ai, vi)); + + // append empty container to mapbox.layers + // so that relinkPrivateKeys does not complain + + var fullLayers = (gd._fullLayout.mapbox || {}).layers || []; + var diff = (p.parts[2] + 1) - fullLayers.length; + + for(i = 0; i < diff; i++) fullLayers.push({}); + + doplot = true; + } // alter gd.layout else { // check whether we can short-circuit a full redraw diff --git a/src/plot_api/plot_config.js b/src/plot_api/plot_config.js index ee3b93ea6ce..d5709b395b9 100644 --- a/src/plot_api/plot_config.js +++ b/src/plot_api/plot_config.js @@ -87,6 +87,9 @@ module.exports = { // URL to topojson files used in geo charts topojsonURL: 'https://cdn.plot.ly/', + // Mapbox access token (required to plot mapbox trace types) + mapboxAccessToken: null, + // Turn all console logging on or off (errors will be thrown) // This should ONLY be set via Plotly.setPlotConfig logging: false diff --git a/src/plots/layout_attributes.js b/src/plots/layout_attributes.js index 030f99eca75..cd1c7d42bf7 100644 --- a/src/plots/layout_attributes.js +++ b/src/plots/layout_attributes.js @@ -187,6 +187,7 @@ module.exports = { 'annotations': 'Annotations', 'shapes': 'Shapes', 'images': 'Images', - 'ternary': 'ternary' + 'ternary': 'ternary', + 'mapbox': 'mapbox' } }; diff --git a/src/plots/mapbox/constants.js b/src/plots/mapbox/constants.js new file mode 100644 index 00000000000..6cbc6b943b0 --- /dev/null +++ b/src/plots/mapbox/constants.js @@ -0,0 +1,28 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + + +module.exports = { + styleUrlPrefix: 'mapbox://styles/mapbox/', + styleUrlSuffix: 'v9', + + controlContainerClassName: 'mapboxgl-control-container', + + noAccessTokenErrorMsg: [ + 'Missing Mapbox access token.', + 'Mapbox trace type require a Mapbox access token to be registered.', + 'For example:', + ' Plotly.plot(gd, data, layout, { mapboxAccessToken: \'my-access-token\' });', + 'More info here: https://www.mapbox.com/help/define-access-token/' + ].join('\n'), + + mapOnErrorMsg: 'Mapbox error.' +}; diff --git a/src/plots/mapbox/index.js b/src/plots/mapbox/index.js new file mode 100644 index 00000000000..5faf2f5c3d5 --- /dev/null +++ b/src/plots/mapbox/index.js @@ -0,0 +1,133 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var mapboxgl = require('mapbox-gl'); + +var Plots = require('../plots'); +var xmlnsNamespaces = require('../../constants/xmlns_namespaces'); + +var createMapbox = require('./mapbox'); +var constants = require('./constants'); + + +exports.name = 'mapbox'; + +exports.attr = 'subplot'; + +exports.idRoot = 'mapbox'; + +exports.idRegex = /^mapbox([2-9]|[1-9][0-9]+)?$/; + +exports.attrRegex = /^mapbox([2-9]|[1-9][0-9]+)?$/; + +exports.attributes = { + subplot: { + valType: 'subplotid', + role: 'info', + dflt: 'mapbox', + description: [ + 'Sets a reference between this trace\'s data coordinates and', + 'a mapbox subplot.', + 'If *mapbox* (the default value), the data refer to `layout.mapbox`.', + 'If *mapbox2*, the data refer to `layout.mapbox2`, and so on.' + ].join(' ') + } +}; + +exports.layoutAttributes = require('./layout_attributes'); + +exports.supplyLayoutDefaults = require('./layout_defaults'); + +exports.plot = function plotMapbox(gd) { + + if(!gd._context.mapboxAccessToken) { + throw new Error(constants.noAccessTokenErrorMsg); + } + else { + mapboxgl.accessToken = gd._context.mapboxAccessToken; + } + + var fullLayout = gd._fullLayout, + calcData = gd.calcdata, + mapboxIds = Plots.getSubplotIds(fullLayout, 'mapbox'); + + for(var i = 0; i < mapboxIds.length; i++) { + var id = mapboxIds[i], + subplotCalcData = getSubplotCalcData(calcData, id), + mapbox = fullLayout[id]._subplot; + + if(!mapbox) { + mapbox = createMapbox({ + gd: gd, + container: fullLayout._glcontainer.node(), + id: id, + fullLayout: fullLayout, + staticPlot: gd._context.staticPlot + }); + + fullLayout[id]._subplot = mapbox; + } + + mapbox.plot(subplotCalcData, fullLayout, gd._promises); + } +}; + +exports.clean = function(newFullData, newFullLayout, oldFullData, oldFullLayout) { + var oldMapboxKeys = Plots.getSubplotIds(oldFullLayout, 'mapbox'); + + for(var i = 0; i < oldMapboxKeys.length; i++) { + var oldMapboxKey = oldMapboxKeys[i]; + + if(!newFullLayout[oldMapboxKey] && !!oldFullLayout[oldMapboxKey]._subplot) { + oldFullLayout[oldMapboxKey]._subplot.destroy(); + } + } +}; + +exports.toSVG = function(gd) { + var fullLayout = gd._fullLayout, + subplotIds = Plots.getSubplotIds(fullLayout, 'mapbox'), + size = fullLayout._size; + + for(var i = 0; i < subplotIds.length; i++) { + var opts = fullLayout[subplotIds[i]], + domain = opts.domain, + mapbox = opts._subplot; + + var imageData = mapbox.toImage('png'); + var image = fullLayout._glimages.append('svg:image'); + + image.attr({ + xmlns: xmlnsNamespaces.svg, + 'xlink:href': imageData, + x: size.l + size.w * domain.x[0], + y: size.t + size.h * (1 - domain.y[1]), + width: size.w * (domain.x[1] - domain.x[0]), + height: size.h * (domain.y[1] - domain.y[0]), + preserveAspectRatio: 'none' + }); + + mapbox.destroy(); + } +}; + +function getSubplotCalcData(calcData, id) { + var subplotCalcData = []; + + for(var i = 0; i < calcData.length; i++) { + var calcTrace = calcData[i], + trace = calcTrace[0].trace; + + if(trace.subplot === id) subplotCalcData.push(calcTrace); + } + + return subplotCalcData; +} diff --git a/src/plots/mapbox/layers.js b/src/plots/mapbox/layers.js new file mode 100644 index 00000000000..64b95d6b966 --- /dev/null +++ b/src/plots/mapbox/layers.js @@ -0,0 +1,177 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var Lib = require('../../lib'); + + +function MapboxLayer(mapbox, index) { + this.mapbox = mapbox; + this.map = mapbox.map; + + this.uid = mapbox.uid + '-' + 'layer' + index; + + this.idSource = this.uid + '-source'; + this.idLayer = this.uid + '-layer'; + + // some state variable to check if a remove/add step is needed + this.sourceType = null; + this.source = null; + this.layerType = null; + this.below = null; +} + +var proto = MapboxLayer.prototype; + +proto.update = function update(opts) { + if(this.needsNewSource(opts)) { + + // IMPORTANT: must delete layer before source to not cause errors + this.updateLayer(opts); + this.updateSource(opts); + } + else if(this.needsNewLayer(opts)) { + this.updateLayer(opts); + } + + this.updateStyle(opts); +}; + +proto.needsNewSource = function(opts) { + return ( + this.sourceType !== opts.sourcetype || + this.source !== opts.source + ); +}; + +proto.needsNewLayer = function(opts) { + return ( + this.layerType !== opts.type || + this.below !== opts.below + ); +}; + +proto.updateSource = function(opts) { + var map = this.map; + + if(map.getSource(this.idSource)) map.removeSource(this.idSource); + + this.sourceType = opts.sourcetype; + this.source = opts.source; + + if(!isVisible(opts)) return; + + var sourceOpts = convertSourceOpts(opts); + + map.addSource(this.idSource, sourceOpts); +}; + +proto.updateLayer = function(opts) { + var map = this.map; + + if(map.getLayer(this.idLayer)) map.removeLayer(this.idLayer); + + this.layerType = opts.type; + + if(!isVisible(opts)) return; + + map.addLayer({ + id: this.idLayer, + source: this.idSource, + 'source-layer': opts.sourcelayer || '', + type: opts.type + }, opts.below); + + // the only way to make a layer invisible is to remove it + var layoutOpts = { visibility: 'visible' }; + this.mapbox.setOptions(this.idLayer, 'setLayoutProperty', layoutOpts); +}; + +proto.updateStyle = function(opts) { + var paintOpts = convertPaintOpts(opts); + + if(isVisible(opts)) { + this.mapbox.setOptions(this.idLayer, 'setPaintProperty', paintOpts); + } +}; + +proto.dispose = function dispose() { + var map = this.map; + + map.removeLayer(this.idLayer); + map.removeSource(this.idSource); +}; + +function isVisible(opts) { + var source = opts.source; + + // For some weird reason Lib.isPlainObject fails + // to detect `source` as a plain object in nw.js 0.12. + + return ( + typeof source === 'object' || + (typeof source === 'string' && source.length > 0) + ); +} + +function convertPaintOpts(opts) { + var paintOpts = {}; + + switch(opts.type) { + + case 'line': + Lib.extendFlat(paintOpts, { + 'line-width': opts.line.width, + 'line-color': opts.line.color, + 'line-opacity': opts.opacity + }); + break; + + case 'fill': + Lib.extendFlat(paintOpts, { + 'fill-color': opts.fillcolor, + 'fill-outline-color': opts.line.color, + 'fill-opacity': opts.opacity + + // no way to pass line.width at the moment + }); + break; + } + + return paintOpts; +} + +function convertSourceOpts(opts) { + var sourceType = opts.sourcetype, + source = opts.source, + sourceOpts = { type: sourceType }, + isSourceAString = (typeof source === 'string'), + field; + + if(sourceType === 'geojson') field = 'data'; + else if(sourceType === 'vector') { + field = isSourceAString ? 'url' : 'tiles'; + } + + sourceOpts[field] = source; + + return sourceOpts; +} + +module.exports = function createMapboxLayer(mapbox, index, opts) { + var mapboxLayer = new MapboxLayer(mapbox, index); + + // IMPORTANT: must create source before layer to not cause errors + mapboxLayer.updateSource(opts); + mapboxLayer.updateLayer(opts); + mapboxLayer.updateStyle(opts); + + return mapboxLayer; +}; diff --git a/src/plots/mapbox/layout_attributes.js b/src/plots/mapbox/layout_attributes.js new file mode 100644 index 00000000000..9386335395b --- /dev/null +++ b/src/plots/mapbox/layout_attributes.js @@ -0,0 +1,172 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var scatterMapboxAttrs = require('../../traces/scattermapbox/attributes'); +var defaultLine = require('../../components/color').defaultLine; +var extendFlat = require('../../lib').extendFlat; + +var lineAttrs = scatterMapboxAttrs.line; + + +module.exports = { + domain: { + x: { + valType: 'info_array', + role: 'info', + items: [ + {valType: 'number', min: 0, max: 1}, + {valType: 'number', min: 0, max: 1} + ], + dflt: [0, 1], + description: [ + 'Sets the horizontal domain of this subplot', + '(in plot fraction).' + ].join(' ') + }, + y: { + valType: 'info_array', + role: 'info', + items: [ + {valType: 'number', min: 0, max: 1}, + {valType: 'number', min: 0, max: 1} + ], + dflt: [0, 1], + description: [ + 'Sets the vertical domain of this subplot', + '(in plot fraction).' + ].join(' ') + } + }, + + style: { + valType: 'string', + values: ['basic', 'streets', 'outdoors', 'light', 'dark', 'satellite', 'satellite-streets'], + dflt: 'basic', + role: 'style', + description: [ + 'Sets the Mapbox map style.', + 'Either input the defaults Mapbox names or the URL to a custom style.' + ].join(' ') + }, + center: { + lon: { + valType: 'number', + dflt: 0, + role: 'info', + description: 'Sets the longitude of the center of the map (in degrees East).' + }, + lat: { + valType: 'number', + dflt: 0, + role: 'info', + description: 'Sets the latitude of the center of the map (in degrees North).' + } + }, + zoom: { + valType: 'number', + dflt: 1, + role: 'info', + description: 'Sets the zoom level of the map.' + }, + bearing: { + valType: 'number', + dflt: 0, + role: 'info', + description: 'Sets the bearing angle of the map (in degrees counter-clockwise from North).' + }, + pitch: { + valType: 'number', + dflt: 0, + role: 'info', + description: [ + 'Sets the pitch angle of the map', + '(in degrees, where *0* means perpendicular to the surface of the map).' + ].join(' ') + }, + + layers: { + _isLinkedToArray: true, + + sourcetype: { + valType: 'enumerated', + values: ['geojson', 'vector'], + dflt: 'geojson', + role: 'info', + description: [ + 'Sets the source type for this layer.', + 'Support for *raster*, *image* and *video* source types is coming soon.' + ].join(' ') + }, + + source: { + valType: 'any', + role: 'info', + description: [ + 'Sets the source data for this layer.', + 'Source can be either a URL,', + 'a geojson object (with `sourcetype` set to *geojson*)', + 'or an array of tile URLS (with `sourcetype` set to *vector*).' + ].join(' ') + }, + + sourcelayer: { + valType: 'string', + dflt: '', + role: 'info', + description: [ + 'Specifies the layer to use from a vector tile source.', + 'Required for *vector* source type that supports multiple layers.' + ].join(' ') + }, + + type: { + valType: 'enumerated', + values: ['line', 'fill'], + dflt: 'line', + role: 'info', + description: [ + 'Sets the layer type.', + 'Support for *raster*, *background* types is coming soon.' + ].join(' ') + }, + + below: { + valType: 'string', + dflt: '', + role: 'info', + description: [ + 'Determines if the layer will be inserted', + 'before the layer with the specified ID.', + 'If omitted or set to \'\',', + 'the layer will be inserted above every existing layer.' + ].join(' ') + }, + + line: { + color: extendFlat({}, lineAttrs.color, { + dflt: defaultLine + }), + width: lineAttrs.width + }, + + fillcolor: scatterMapboxAttrs.fillcolor, + + opacity: { + valType: 'number', + min: 0, + max: 1, + dflt: 1, + role: 'info', + description: 'Sets the opacity of the layer.' + } + } + +}; diff --git a/src/plots/mapbox/layout_defaults.js b/src/plots/mapbox/layout_defaults.js new file mode 100644 index 00000000000..0a20ce81f45 --- /dev/null +++ b/src/plots/mapbox/layout_defaults.js @@ -0,0 +1,78 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var Lib = require('../../lib'); + +var handleSubplotDefaults = require('../subplot_defaults'); +var layoutAttributes = require('./layout_attributes'); + + +module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { + handleSubplotDefaults(layoutIn, layoutOut, fullData, { + type: 'mapbox', + attributes: layoutAttributes, + handleDefaults: handleDefaults, + partition: 'y' + }); +}; + +function handleDefaults(containerIn, containerOut, coerce) { + coerce('style'); + coerce('center.lon'); + coerce('center.lat'); + coerce('zoom'); + coerce('bearing'); + coerce('pitch'); + + handleLayerDefaults(containerIn, containerOut); + + // copy ref to input container to update 'center' and 'zoom' on map move + containerOut._input = containerIn; +} + +function handleLayerDefaults(containerIn, containerOut) { + var layersIn = containerIn.layers || [], + layersOut = containerOut.layers = []; + + var layerIn, layerOut; + + function coerce(attr, dflt) { + return Lib.coerce(layerIn, layerOut, layoutAttributes.layers, attr, dflt); + } + + for(var i = 0; i < layersIn.length; i++) { + layerIn = layersIn[i]; + layerOut = {}; + + var sourceType = coerce('sourcetype'); + coerce('source'); + + if(sourceType === 'vector') coerce('sourcelayer'); + + // maybe add smart default based off 'fillcolor' ??? + var type = coerce('type'); + + var lineColor; + if(type === 'line' || type === 'fill') { + lineColor = coerce('line.color'); + } + + // no way to pass line.width to fill layers + if(type === 'line') coerce('line.width'); + + if(type === 'fill') coerce('fillcolor', lineColor); + + coerce('below'); + coerce('opacity'); + + layersOut.push(layerOut); + } +} diff --git a/src/plots/mapbox/mapbox.js b/src/plots/mapbox/mapbox.js new file mode 100644 index 00000000000..7bd0150cc32 --- /dev/null +++ b/src/plots/mapbox/mapbox.js @@ -0,0 +1,382 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var mapboxgl = require('mapbox-gl'); + +var Fx = require('../cartesian/graph_interact'); +var constants = require('./constants'); +var layoutAttributes = require('./layout_attributes'); +var createMapboxLayer = require('./layers'); + + +function Mapbox(opts) { + this.id = opts.id; + this.gd = opts.gd; + this.container = opts.container; + this.isStatic = opts.staticPlot; + + var fullLayout = opts.fullLayout; + + // unique id for this Mapbox instance + this.uid = fullLayout._uid + '-' + this.id; + + // full mapbox options (N.B. needs to be updated on every updates) + this.opts = fullLayout[this.id]; + + // create framework on instantiation for a smoother first plot call + this.div = null; + this.xaxis = null; + this.yaxis = null; + this.createFramework(fullLayout); + + // state variables used to infer how and what to update + this.map = null; + this.styleUrl = null; + this.traceHash = {}; + this.layerList = []; +} + +var proto = Mapbox.prototype; + +module.exports = function createMapbox(opts) { + var mapbox = new Mapbox(opts); + + return mapbox; +}; + +proto.plot = function(calcData, fullLayout, promises) { + var self = this; + + // feed in new mapbox options + self.opts = fullLayout[this.id]; + + var promise; + + if(!self.map) { + promise = new Promise(function(resolve, reject) { + self.createMap(calcData, fullLayout, resolve, reject); + }); + } + else { + promise = new Promise(function(resolve, reject) { + self.updateMap(calcData, fullLayout, resolve, reject); + }); + } + + promises.push(promise); +}; + +proto.createMap = function(calcData, fullLayout, resolve, reject) { + var self = this, + gd = self.gd, + opts = self.opts; + + // mapbox doesn't have a way to get the current style URL; do it ourselves + var styleUrl = self.styleUrl = convertStyleUrl(opts.style); + + var map = self.map = new mapboxgl.Map({ + container: self.div, + + style: styleUrl, + center: convertCenter(opts.center), + zoom: opts.zoom, + bearing: opts.bearing, + pitch: opts.pitch, + + interactive: !self.isStatic, + preserveDrawingBuffer: self.isStatic + }); + + // clear navigation container + var controlContainer = this.div.getElementsByClassName(constants.controlContainerClassName)[0]; + this.div.removeChild(controlContainer); + + self.rejectOnError(reject); + + map.once('load', function() { + self.updateData(calcData); + self.updateLayout(fullLayout); + + self.resolveOnRender(resolve); + }); + + // keep track of pan / zoom in user layout + map.on('move', function() { + var center = map.getCenter(); + opts._input.center = opts.center = { lon: center.lng, lat: center.lat }; + opts._input.zoom = opts.zoom = map.getZoom(); + }); + + map.on('mousemove', function(evt) { + var bb = self.div.getBoundingClientRect(); + + // some hackery to get Fx.hover to work + + evt.clientX = evt.point.x + bb.left; + evt.clientY = evt.point.y + bb.top; + + evt.target.getBoundingClientRect = function() { return bb; }; + + self.xaxis.p2c = function() { return evt.lngLat.lng; }; + self.yaxis.p2c = function() { return evt.lngLat.lat; }; + + Fx.hover(gd, evt, self.id); + }); + + map.on('click', function() { + Fx.click(gd, { target: true }); + }); + + function unhover() { + Fx.loneUnhover(fullLayout._toppaper); + } + + map.on('dragstart', unhover); + map.on('zoomstart', unhover); + +}; + +proto.updateMap = function(calcData, fullLayout, resolve, reject) { + var self = this, + map = self.map; + + self.rejectOnError(reject); + + var styleUrl = convertStyleUrl(self.opts.style); + + if(self.styleUrl !== styleUrl) { + self.styleUrl = styleUrl; + map.setStyle(styleUrl); + + map.style.once('load', function() { + + // need to rebuild trace layers on reload + // to avoid 'lost event' errors + self.traceHash = {}; + + self.updateData(calcData); + self.updateLayout(fullLayout); + + self.resolveOnRender(resolve); + }); + } + else { + self.updateData(calcData); + self.updateLayout(fullLayout); + + self.resolveOnRender(resolve); + } +}; + +proto.updateData = function(calcData) { + var traceHash = this.traceHash; + + var traceObj, trace, i, j; + + // update or create trace objects + for(i = 0; i < calcData.length; i++) { + var calcTrace = calcData[i]; + + trace = calcTrace[0].trace; + traceObj = traceHash[trace.uid]; + + if(traceObj) traceObj.update(calcTrace); + else { + traceHash[trace.uid] = trace._module.plot(this, calcTrace); + } + } + + // remove empty trace objects + var ids = Object.keys(traceHash); + id_loop: + for(i = 0; i < ids.length; i++) { + var id = ids[i]; + + for(j = 0; j < calcData.length; j++) { + trace = calcData[j][0].trace; + + if(id === trace.uid) continue id_loop; + } + + traceObj = traceHash[id]; + traceObj.dispose(); + delete traceHash[id]; + } +}; + +proto.updateLayout = function(fullLayout) { + var map = this.map, + opts = this.opts; + + map.setCenter(convertCenter(opts.center)); + map.setZoom(opts.zoom); + map.setBearing(opts.bearing); + map.setPitch(opts.pitch); + + this.updateLayers(); + this.updateFramework(fullLayout); + this.map.resize(); +}; + +proto.resolveOnRender = function(resolve) { + var map = this.map; + + map.on('render', function onRender() { + if(map.loaded()) { + map.off('render', onRender); + resolve(); + } + }); +}; + +proto.rejectOnError = function(reject) { + var map = this.map; + + function handler() { + reject(new Error(constants.mapOnErrorMsg)); + } + + map.once('error', handler); + map.once('style.error', handler); + map.once('source.error', handler); + map.once('tile.error', handler); + map.once('layer.error', handler); +}; + +proto.createFramework = function(fullLayout) { + var self = this; + + var div = self.div = document.createElement('div'); + + div.id = self.uid; + div.style.position = 'absolute'; + + self.container.appendChild(div); + + // create mock x/y axes for hover routine + + self.xaxis = { + _id: 'x', + c2p: function(v) { return self.project(v).x; } + }; + + self.yaxis = { + _id: 'y', + c2p: function(v) { return self.project(v).y; } + }; + + self.updateFramework(fullLayout); +}; + +proto.updateFramework = function(fullLayout) { + var domain = fullLayout[this.id].domain, + size = fullLayout._size; + + var style = this.div.style; + + // TODO Is this correct? It seems to get the map zoom level wrong? + + style.width = size.w * (domain.x[1] - domain.x[0]) + 'px'; + style.height = size.h * (domain.y[1] - domain.y[0]) + 'px'; + style.left = size.l + domain.x[0] * size.w + 'px'; + style.top = size.t + (1 - domain.y[1]) * size.h + 'px'; + + this.xaxis._offset = size.l + domain.x[0] * size.w; + this.xaxis._length = size.w * (domain.x[1] - domain.x[0]); + + this.yaxis._offset = size.t + (1 - domain.y[1]) * size.h; + this.yaxis._length = size.h * (domain.y[1] - domain.y[0]); +}; + +proto.updateLayers = function() { + var opts = this.opts, + layers = opts.layers, + layerList = this.layerList, + i; + + // if the layer arrays don't match, + // don't try to be smart, + // delete them all, and start all over. + + if(layers.length !== layerList.length) { + for(i = 0; i < layerList.length; i++) { + layerList[i].dispose(); + } + + layerList = this.layerList = []; + + for(i = 0; i < layers.length; i++) { + layerList.push(createMapboxLayer(this, i, layers[i])); + } + } + else { + for(i = 0; i < layers.length; i++) { + layerList[i].update(layers[i]); + } + } +}; + +proto.destroy = function() { + this.map.remove(); + this.container.removeChild(this.div); +}; + +proto.toImage = function() { + return this.map.getCanvas().toDataURL(); +}; + +// convenience wrapper to create blank GeoJSON sources +// and avoid 'invalid GeoJSON' errors +proto.createGeoJSONSource = function() { + var blank = { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [] + } + }; + + return new mapboxgl.GeoJSONSource({data: blank}); +}; + +// convenience wrapper to create set multiple layer +// 'layout' or 'paint options at once. +proto.setOptions = function(id, methodName, opts) { + var map = this.map, + keys = Object.keys(opts); + + for(var i = 0; i < keys.length; i++) { + var key = keys[i]; + + map[methodName](id, key, opts[key]); + } +}; + +// convenience method to project a [lon, lat] array to pixel coords +proto.project = function(v) { + return this.map.project(new mapboxgl.LngLat(v[0], v[1])); +}; + +function convertStyleUrl(style) { + var styleValues = layoutAttributes.style.values; + + // if style is part of the 'official' mapbox values, + // add URL prefix and suffix + if(styleValues.indexOf(style) !== -1) { + return constants.styleUrlPrefix + style + '-' + constants.styleUrlSuffix; + } + + return style; +} + +function convertCenter(center) { + return [center.lon, center.lat]; +} diff --git a/src/traces/scattermapbox/attributes.js b/src/traces/scattermapbox/attributes.js new file mode 100644 index 00000000000..38bb7d909e6 --- /dev/null +++ b/src/traces/scattermapbox/attributes.js @@ -0,0 +1,117 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +var scatterGeoAttrs = require('../scattergeo/attributes'); +var scatterAttrs = require('../scatter/attributes'); +var plotAttrs = require('../../plots/attributes'); +var extendFlat = require('../../lib/extend').extendFlat; + +var lineAttrs = scatterGeoAttrs.line; +var markerAttrs = scatterGeoAttrs.marker; + + +module.exports = { + lon: scatterGeoAttrs.lon, + lat: scatterGeoAttrs.lat, + + // locations + // locationmode + + mode: { + valType: 'flaglist', + flags: ['lines', 'markers', 'text'], + dflt: 'markers', + extras: ['none'], + role: 'info', + description: [ + 'Determines the drawing mode for this scatter trace.', + 'If the provided `mode` includes *text* then the `text` elements', + 'appear at the coordinates. Otherwise, the `text` elements', + 'appear on hover.' + ].join(' ') + }, + + text: extendFlat({}, scatterAttrs.text, { + description: [ + 'Sets text elements associated with each (lon,lat) pair', + 'If a single string, the same string appears over', + 'all the data points.', + 'If an array of string, the items are mapped in order to the', + 'this trace\'s (lon,lat) coordinates.' + ].join(' ') + }), + + line: { + color: lineAttrs.color, + width: lineAttrs.width, + + // TODO + dash: lineAttrs.dash + }, + + connectgaps: scatterAttrs.connectgaps, + + marker: { + symbol: { + valType: 'string', + dflt: 'circle', + role: 'style', + arrayOk: true, + description: [ + 'Sets the marker symbol.', + 'Full list: https://www.mapbox.com/maki-icons/', + 'Note that the array `marker.color` and `marker.size`', + 'are only available for *circle* symbols.' + ].join(' ') + }, + opacity: extendFlat({}, markerAttrs.opacity, { + arrayOk: false + }), + size: markerAttrs.size, + sizeref: markerAttrs.sizeref, + sizemin: markerAttrs.sizemin, + sizemode: markerAttrs.sizemode, + color: markerAttrs.color, + colorscale: markerAttrs.colorscale, + cauto: markerAttrs.cauto, + cmax: markerAttrs.cmax, + cmin: markerAttrs.cmin, + autocolorscale: markerAttrs.autocolorscale, + reversescale: markerAttrs.reversescale, + showscale: markerAttrs.showscale + + // line + }, + + fill: { + valType: 'enumerated', + values: ['none', 'toself'], + dflt: 'none', + role: 'style', + description: [ + 'Sets the area to fill with a solid color.', + 'Use with `fillcolor` if not *none*.', + '*toself* connects the endpoints of the trace (or each segment', + 'of the trace if it has gaps) into a closed shape.' + ].join(' ') + }, + fillcolor: scatterAttrs.fillcolor, + + textfont: extendFlat({}, scatterAttrs.textfont, { arrayOk: false }), + textposition: extendFlat({}, scatterAttrs.textposition, { arrayOk: false }), + + hoverinfo: extendFlat({}, plotAttrs.hoverinfo, { + flags: ['lon', 'lat', 'text', 'name'] + }), + + _nestedModules: { + 'marker.colorbar': 'Colorbar' + } +}; diff --git a/src/traces/scattermapbox/calc.js b/src/traces/scattermapbox/calc.js new file mode 100644 index 00000000000..295b6ffec3e --- /dev/null +++ b/src/traces/scattermapbox/calc.js @@ -0,0 +1,95 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var isNumeric = require('fast-isnumeric'); + +var Lib = require('../../lib'); +var hasColorscale = require('../../components/colorscale/has_colorscale'); +var makeColorScaleFn = require('../../components/colorscale/make_scale_function'); +var subtypes = require('../scatter/subtypes'); +var calcMarkerColorscale = require('../scatter/marker_colorscale_calc'); +var makeBubbleSizeFn = require('../scatter/make_bubble_size_func'); + + +module.exports = function calc(gd, trace) { + var len = trace.lon.length, + marker = trace.marker; + + var hasMarkers = subtypes.hasMarkers(trace), + hasColorArray = (hasMarkers && Array.isArray(marker.color)), + hasSizeArray = (hasMarkers && Array.isArray(marker.size)), + hasSymbolArray = (hasMarkers && Array.isArray(marker.symbol)), + hasTextArray = Array.isArray(trace.text); + + calcMarkerColorscale(trace); + + var colorFn = hasColorscale(trace, 'marker') ? + makeColorScaleFn(marker.colorscale, marker.cmin, marker.cmax) : + Lib.identity; + + var sizeFn = subtypes.isBubble(trace) ? + makeBubbleSizeFn(trace) : + Lib.identity; + + var calcTrace = [], + cnt = 0; + + // Different than cartesian calc step + // as skip over non-numeric lon, lat pairs. + // This makes the hover and convert calculations simpler. + + for(var i = 0; i < len; i++) { + var lon = trace.lon[i], + lat = trace.lat[i]; + + if(!isNumeric(lon) || !isNumeric(lat)) { + if(cnt > 0) calcTrace[cnt - 1].gapAfter = true; + continue; + } + + var calcPt = {}; + cnt++; + + // coerce numeric strings into numbers + calcPt.lonlat = [+lon, +lat]; + + if(hasMarkers) { + + if(hasColorArray) { + var mc = marker.color[i]; + + calcPt.mc = mc; + calcPt.mcc = colorFn(mc); + } + + if(hasSizeArray) { + var ms = marker.size[i]; + + calcPt.ms = ms; + calcPt.mrc = sizeFn(ms); + } + + if(hasSymbolArray) { + var mx = marker.symbol[i]; + calcPt.mx = (typeof mx === 'string') ? mx : 'circle'; + } + } + + if(hasTextArray) { + var tx = trace.text[i]; + calcPt.tx = (typeof tx === 'string') ? tx : ''; + } + + calcTrace.push(calcPt); + } + + return calcTrace; +}; diff --git a/src/traces/scattermapbox/convert.js b/src/traces/scattermapbox/convert.js new file mode 100644 index 00000000000..b469acc61d9 --- /dev/null +++ b/src/traces/scattermapbox/convert.js @@ -0,0 +1,395 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var Lib = require('../../lib'); +var subTypes = require('../scatter/subtypes'); + +var COLOR_PROP = 'circle-color'; +var SIZE_PROP = 'circle-radius'; + + +module.exports = function convert(calcTrace) { + var trace = calcTrace[0].trace; + + var isVisible = (trace.visible === true), + hasFill = (trace.fill !== 'none'), + hasLines = subTypes.hasLines(trace), + hasMarkers = subTypes.hasMarkers(trace), + hasText = subTypes.hasText(trace), + hasCircles = (hasMarkers && trace.marker.symbol === 'circle'), + hasSymbols = (hasMarkers && trace.marker.symbol !== 'circle'); + + var fill = initContainer(), + line = initContainer(), + circle = initContainer(), + symbol = initContainer(); + + var opts = { + fill: fill, + line: line, + circle: circle, + symbol: symbol + }; + + // early return is not visible + if(!isVisible) return opts; + + // fill layer and line layer use the same coords + var coords; + if(hasFill || hasLines) { + coords = getCoords(calcTrace); + } + + if(hasFill) { + fill.geojson = makeFillGeoJSON(calcTrace, coords); + fill.layout.visibility = 'visible'; + + Lib.extendFlat(fill.paint, { + 'fill-color': trace.fillcolor + }); + } + + if(hasLines) { + line.geojson = makeLineGeoJSON(calcTrace, coords); + line.layout.visibility = 'visible'; + + Lib.extendFlat(line.paint, { + 'line-width': trace.line.width, + 'line-color': trace.line.color, + 'line-opacity': trace.opacity + }); + + // TODO convert line.dash into line-dasharray + } + + if(hasCircles) { + var hash = {}; + hash[COLOR_PROP] = {}; + hash[SIZE_PROP] = {}; + + circle.geojson = makeCircleGeoJSON(calcTrace, hash); + circle.layout.visibility = 'visible'; + + Lib.extendFlat(circle.paint, { + 'circle-opacity': trace.opacity * trace.marker.opacity, + 'circle-color': calcCircleColor(trace, hash), + 'circle-radius': calcCircleRadius(trace, hash) + }); + } + + if(hasSymbols || hasText) { + symbol.geojson = makeSymbolGeoJSON(calcTrace); + + Lib.extendFlat(symbol.layout, { + visibility: 'visible', + 'icon-image': '{symbol}-15', + 'text-field': '{text}' + }); + + if(hasSymbols) { + Lib.extendFlat(symbol.layout, { + 'icon-size': trace.marker.size / 10 + }); + + Lib.extendFlat(symbol.paint, { + 'icon-opacity': trace.opacity * trace.marker.opacity, + + // TODO does not work ?? + 'icon-color': trace.marker.color + }); + } + + if(hasText) { + var textOpts = calcTextOpts(trace); + + Lib.extendFlat(symbol.layout, { + 'text-font': trace.textfont.textfont, + 'text-size': trace.textfont.size, + 'text-anchor': textOpts.anchor, + 'text-offset': textOpts.offset + }); + + Lib.extendFlat(symbol.paint, { + 'text-color': trace.textfont.color, + 'text-opacity': trace.opacity + }); + } + } + + return opts; +}; + +function initContainer() { + return { + geojson: makeBlankGeoJSON(), + layout: { visibility: 'none' }, + paint: {} + }; +} + +function makeBlankGeoJSON() { + return { + type: 'Point', + coordinates: [] + }; +} + +function makeFillGeoJSON(_, coords) { + return { + type: 'Polygon', + coordinates: coords + }; +} + +function makeLineGeoJSON(_, coords) { + return { + type: 'MultiLineString', + coordinates: coords + }; +} + +// N.B. `hash` is mutated here +// +// The `hash` object contains mapping between values +// (e.g. calculated marker.size and marker.color items) +// and their index in the input arrayOk attributes. +// +// GeoJSON features have their 'data-driven' properties set to +// the index of the first value found in the data. +// +// The `hash` object is then converted to mapbox `stops` arrays +// mapping index to value. +// +// The solution prove to be more robust than trying to generate +// `stops` arrays from scale functions. +function makeCircleGeoJSON(calcTrace, hash) { + var trace = calcTrace[0].trace; + + var marker = trace.marker, + hasColorArray = Array.isArray(marker.color), + hasSizeArray = Array.isArray(marker.size); + + // Translate vals in trace arrayOk containers + // into a val-to-index hash object + function translate(props, key, val, index) { + if(!hash[key][val]) hash[key][val] = index; + + props[key] = hash[key][val]; + } + + var features = []; + + for(var i = 0; i < calcTrace.length; i++) { + var calcPt = calcTrace[i]; + + var props = {}; + if(hasColorArray) translate(props, COLOR_PROP, calcPt.mcc, i); + if(hasSizeArray) translate(props, SIZE_PROP, calcPt.mrc, i); + + features.push({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: calcPt.lonlat + }, + properties: props + }); + } + + return { + type: 'FeatureCollection', + features: features + }; +} + +function makeSymbolGeoJSON(calcTrace) { + var trace = calcTrace[0].trace; + + var marker = trace.marker || {}, + symbol = marker.symbol, + text = trace.text; + + var fillSymbol = (symbol !== 'circle') ? + getFillFunc(symbol) : + blankFillFunc; + + var fillText = subTypes.hasText(trace) ? + getFillFunc(text) : + blankFillFunc; + + var features = []; + + for(var i = 0; i < calcTrace.length; i++) { + var calcPt = calcTrace[i]; + + features.push({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: calcPt.lonlat + }, + properties: { + symbol: fillSymbol(calcPt.mx), + text: fillText(calcPt.tx) + } + }); + } + + return { + type: 'FeatureCollection', + features: features + }; +} + +function calcCircleColor(trace, hash) { + var marker = trace.marker, + out; + + if(Array.isArray(marker.color)) { + var vals = Object.keys(hash[COLOR_PROP]), + stops = []; + + for(var i = 0; i < vals.length; i++) { + var val = vals[i]; + + stops.push([ hash[COLOR_PROP][val], val ]); + } + + out = { + property: COLOR_PROP, + stops: stops + }; + + } + else { + out = marker.color; + } + + return out; +} + +function calcCircleRadius(trace, hash) { + var marker = trace.marker, + out; + + if(Array.isArray(marker.size)) { + var vals = Object.keys(hash[SIZE_PROP]), + stops = []; + + for(var i = 0; i < vals.length; i++) { + var val = vals[i]; + + stops.push([ hash[SIZE_PROP][val], +val ]); + } + + // stops indices must be sorted + stops.sort(function(a, b) { + return a[0] - b[0]; + }); + + out = { + property: SIZE_PROP, + stops: stops + }; + } + else { + out = marker.size / 2; + } + + return out; +} + +function calcTextOpts(trace) { + var textposition = trace.textposition, + parts = textposition.split(' '), + vPos = parts[0], + hPos = parts[1]; + + // ballpack values + var ms = (trace.marker || {}).size, + factor = Array.isArray(ms) ? Lib.mean(ms) : ms, + xInc = 0.5 + (factor / 100), + yInc = 1.5 + (factor / 100); + + var anchorVals = ['', ''], + offset = [0, 0]; + + switch(vPos) { + case 'top': + anchorVals[0] = 'top'; + offset[1] = -yInc; + break; + case 'bottom': + anchorVals[0] = 'bottom'; + offset[1] = yInc; + break; + } + + switch(hPos) { + case 'left': + anchorVals[1] = 'right'; + offset[0] = -xInc; + break; + case 'right': + anchorVals[1] = 'left'; + offset[0] = xInc; + break; + } + + // Mapbox text-anchor must be one of: + // center, left, right, top, bottom, + // top-left, top-right, bottom-left, bottom-right + + var anchor; + if(anchorVals[0] && anchorVals[1]) anchor = anchorVals.join('-'); + else if(anchorVals[0]) anchor = anchorVals[0]; + else if(anchorVals[1]) anchor = anchorVals[1]; + else anchor = 'center'; + + return { anchor: anchor, offset: offset }; +} + +function getCoords(calcTrace) { + var trace = calcTrace[0].trace, + connectgaps = trace.connectgaps; + + var coords = [], + lineString = []; + + for(var i = 0; i < calcTrace.length; i++) { + var calcPt = calcTrace[i]; + + lineString.push(calcPt.lonlat); + + if(!connectgaps && calcPt.gapAfter && lineString.length > 0) { + coords.push(lineString); + lineString = []; + } + } + + coords.push(lineString); + + return coords; +} + +function getFillFunc(attr) { + if(Array.isArray(attr)) { + return function(v) { return v; }; + } + else if(attr) { + return function() { return attr; }; + } + else { + return blankFillFunc; + } +} + +function blankFillFunc() { return ''; } diff --git a/src/traces/scattermapbox/defaults.js b/src/traces/scattermapbox/defaults.js new file mode 100644 index 00000000000..8368ddabede --- /dev/null +++ b/src/traces/scattermapbox/defaults.js @@ -0,0 +1,86 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var Lib = require('../../lib'); + +var subTypes = require('../scatter/subtypes'); +var handleMarkerDefaults = require('../scatter/marker_defaults'); +var handleLineDefaults = require('../scatter/line_defaults'); +var handleTextDefaults = require('../scatter/text_defaults'); +var handleFillColorDefaults = require('../scatter/fillcolor_defaults'); + +var attributes = require('./attributes'); +var scatterAttrs = require('../scatter/attributes'); + + +module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout) { + function coerce(attr, dflt) { + return Lib.coerce(traceIn, traceOut, attributes, attr, dflt); + } + + function coerceMarker(attr, dflt) { + var attrs = (attr.indexOf('.line') === -1) ? attributes : scatterAttrs; + + // use 'scatter' attributes for 'marker.line.' attr, + // so that we can reuse the scatter marker defaults + + return Lib.coerce(traceIn, traceOut, attrs, attr, dflt); + } + + var len = handleLonLatDefaults(traceIn, traceOut, coerce); + if(!len) { + traceOut.visible = false; + return; + } + + coerce('text'); + coerce('mode'); + + if(subTypes.hasLines(traceOut)) { + handleLineDefaults(traceIn, traceOut, defaultColor, coerce); + coerce('connectgaps'); + } + + if(subTypes.hasMarkers(traceOut)) { + handleMarkerDefaults(traceIn, traceOut, defaultColor, layout, coerceMarker); + + // array marker.size and marker.color are only supported with circles + + var marker = traceOut.marker; + + if(marker.symbol !== 'circle') { + if(Array.isArray(marker.size)) marker.size = marker.size[0]; + if(Array.isArray(marker.color)) marker.color = marker.color[0]; + } + } + + if(subTypes.hasText(traceOut)) { + handleTextDefaults(traceIn, traceOut, layout, coerce); + } + + coerce('fill'); + if(traceOut.fill !== 'none') { + handleFillColorDefaults(traceIn, traceOut, defaultColor, coerce); + } + + coerce('hoverinfo', (layout._dataLength === 1) ? 'lon+lat+text' : undefined); +}; + +function handleLonLatDefaults(traceIn, traceOut, coerce) { + var lon = coerce('lon') || []; + var lat = coerce('lat') || []; + var len = Math.min(lon.length, lat.length); + + if(len < lon.length) traceOut.lon = lon.slice(0, len); + if(len < lat.length) traceOut.lat = lat.slice(0, len); + + return len; +} diff --git a/src/traces/scattermapbox/hover.js b/src/traces/scattermapbox/hover.js new file mode 100644 index 00000000000..9bb9ea630c1 --- /dev/null +++ b/src/traces/scattermapbox/hover.js @@ -0,0 +1,91 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var Fx = require('../../plots/cartesian/graph_interact'); +var getTraceColor = require('../scatter/get_trace_color'); + + +module.exports = function hoverPoints(pointData, xval, yval) { + var cd = pointData.cd, + trace = cd[0].trace, + xa = pointData.xa, + ya = pointData.ya; + + // compute winding number about [-180, 180] globe + var winding = (xval >= 0) ? + Math.floor((xval + 180) / 360) : + Math.ceil((xval - 180) / 360); + + // shift longitude to [-180, 180] to determine closest point + var lonShift = winding * 360; + var xval2 = xval - lonShift; + + function distFn(d) { + var lonlat = d.lonlat, + dx = Math.abs(xa.c2p(lonlat) - xa.c2p([xval2, lonlat[1]])), + dy = Math.abs(ya.c2p(lonlat) - ya.c2p([lonlat[0], yval])), + rad = Math.max(3, d.mrc || 0); + + return Math.max(Math.sqrt(dx * dx + dy * dy) - rad, 1 - 3 / rad); + } + + Fx.getClosest(cd, distFn, pointData); + + // skip the rest (for this trace) if we didn't find a close point + if(pointData.index === false) return; + + var di = cd[pointData.index], + lonlat = di.lonlat, + lonlatShifted = [lonlat[0] + lonShift, lonlat[1]]; + + // shift labels back to original winded globe + var xc = xa.c2p(lonlatShifted), + yc = ya.c2p(lonlatShifted), + rad = di.mrc || 1; + + pointData.x0 = xc - rad; + pointData.x1 = xc + rad; + pointData.y0 = yc - rad; + pointData.y1 = yc + rad; + + pointData.color = getTraceColor(trace, di); + pointData.extraText = getExtraText(trace, di); + + return [pointData]; +}; + +function getExtraText(trace, di) { + var hoverinfo = trace.hoverinfo.split('+'), + isAll = (hoverinfo.indexOf('all') !== -1), + hasLon = (hoverinfo.indexOf('lon') !== -1), + hasLat = (hoverinfo.indexOf('lat') !== -1); + + var lonlat = di.lonlat, + text = []; + + // TODO should we use a mock axis to format hover? + // If so, we'll need to make precision be zoom-level dependent + function format(v) { + return v + '\u00B0'; + } + + if(isAll || (hasLon && hasLat)) { + text.push('(' + format(lonlat[0]) + ', ' + format(lonlat[1]) + ')'); + } + else if(hasLon) text.push('lon: ' + format(lonlat[0])); + else if(hasLat) text.push('lat: ' + format(lonlat[1])); + + if(isAll || hoverinfo.indexOf('text') !== -1) { + text.push(di.tx || trace.text); + } + + return text.join('
'); +} diff --git a/src/traces/scattermapbox/index.js b/src/traces/scattermapbox/index.js new file mode 100644 index 00000000000..bb0a5af7c2b --- /dev/null +++ b/src/traces/scattermapbox/index.js @@ -0,0 +1,34 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + + +var ScatterMapbox = {}; + +ScatterMapbox.attributes = require('./attributes'); +ScatterMapbox.supplyDefaults = require('./defaults'); +ScatterMapbox.colorbar = require('../scatter/colorbar'); +ScatterMapbox.calc = require('./calc'); +ScatterMapbox.hoverPoints = require('./hover'); +ScatterMapbox.plot = require('./plot'); + +ScatterMapbox.moduleType = 'trace'; +ScatterMapbox.name = 'scattermapbox'; +ScatterMapbox.basePlotModule = require('../../plots/mapbox'); +ScatterMapbox.categories = ['mapbox', 'gl', 'symbols', 'markerColorscale', 'showLegend']; +ScatterMapbox.meta = { + hrName: 'scatter_mapbox', + description: [ + 'The data visualized as scatter point, lines or marker symbols', + 'on a Mapbox GL geographic map', + 'is provided by longitude/latitude pairs in `lon` and `lat`.' + ].join(' ') +}; + +module.exports = ScatterMapbox; diff --git a/src/traces/scattermapbox/plot.js b/src/traces/scattermapbox/plot.js new file mode 100644 index 00000000000..18fc54ebdb1 --- /dev/null +++ b/src/traces/scattermapbox/plot.js @@ -0,0 +1,130 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var convert = require('./convert'); + + +function ScatterMapbox(mapbox, uid) { + this.mapbox = mapbox; + this.map = mapbox.map; + + this.uid = uid; + + this.idSourceFill = uid + '-source-fill'; + this.idSourceLine = uid + '-source-line'; + this.idSourceCircle = uid + '-source-circle'; + this.idSourceSymbol = uid + '-source-symbol'; + + this.idLayerFill = uid + '-layer-fill'; + this.idLayerLine = uid + '-layer-line'; + this.idLayerCircle = uid + '-layer-circle'; + this.idLayerSymbol = uid + '-layer-symbol'; + + this.sourceFill = mapbox.createGeoJSONSource(); + this.map.addSource(this.idSourceFill, this.sourceFill); + + this.sourceLine = mapbox.createGeoJSONSource(); + this.map.addSource(this.idSourceLine, this.sourceLine); + + this.sourceCircle = mapbox.createGeoJSONSource(); + this.map.addSource(this.idSourceCircle, this.sourceCircle); + + this.sourceSymbol = mapbox.createGeoJSONSource(); + this.map.addSource(this.idSourceSymbol, this.sourceSymbol); + + this.map.addLayer({ + id: this.idLayerFill, + source: this.idSourceFill, + type: 'fill' + }); + + this.map.addLayer({ + id: this.idLayerLine, + source: this.idSourceLine, + type: 'line' + }); + + this.map.addLayer({ + id: this.idLayerCircle, + source: this.idSourceCircle, + type: 'circle' + }); + + this.map.addLayer({ + id: this.idLayerSymbol, + source: this.idSourceSymbol, + type: 'symbol' + }); + + // We could merge the 'fill' source with the 'line' source and + // the 'circle' source with the 'symbol' source if ever having + // for up-to 4 sources per 'scattermapbox' traces becomes a problem. +} + +var proto = ScatterMapbox.prototype; + +proto.update = function update(calcTrace) { + var mapbox = this.mapbox; + + var opts = convert(calcTrace); + + mapbox.setOptions(this.idLayerFill, 'setLayoutProperty', opts.fill.layout); + mapbox.setOptions(this.idLayerLine, 'setLayoutProperty', opts.line.layout); + mapbox.setOptions(this.idLayerCircle, 'setLayoutProperty', opts.circle.layout); + mapbox.setOptions(this.idLayerSymbol, 'setLayoutProperty', opts.symbol.layout); + + if(isVisible(opts.fill)) { + this.sourceFill.setData(opts.fill.geojson); + mapbox.setOptions(this.idLayerFill, 'setPaintProperty', opts.fill.paint); + } + + if(isVisible(opts.line)) { + this.sourceLine.setData(opts.line.geojson); + mapbox.setOptions(this.idLayerLine, 'setPaintProperty', opts.line.paint); + } + + if(isVisible(opts.circle)) { + this.sourceCircle.setData(opts.circle.geojson); + mapbox.setOptions(this.idLayerCircle, 'setPaintProperty', opts.circle.paint); + } + + if(isVisible(opts.symbol)) { + this.sourceSymbol.setData(opts.symbol.geojson); + mapbox.setOptions(this.idLayerSymbol, 'setPaintProperty', opts.symbol.paint); + } +}; + +proto.dispose = function dispose() { + var map = this.map; + + map.removeLayer(this.idLayerFill); + map.removeLayer(this.idLayerLine); + map.removeLayer(this.idLayerCircle); + map.removeLayer(this.idLayerSymbol); + + map.removeSource(this.idSourceFill); + map.removeSource(this.idSourceLine); + map.removeSource(this.idSourceCircle); + map.removeSource(this.idSourceSymbol); +}; + +function isVisible(layerOpts) { + return layerOpts.layout.visibility === 'visible'; +} + +module.exports = function createScatterMapbox(mapbox, calcTrace) { + var trace = calcTrace[0].trace; + + var scatterMapbox = new ScatterMapbox(mapbox, trace.uid); + scatterMapbox.update(calcTrace); + + return scatterMapbox; +}; diff --git a/tasks/pretest.js b/tasks/pretest.js new file mode 100644 index 00000000000..b15d6598b97 --- /dev/null +++ b/tasks/pretest.js @@ -0,0 +1,38 @@ +var fs = require('fs'); +var constants = require('./util/constants'); +var mapboxAccessToken = process.env.MAPBOX_ACCESS_TOKEN; + + +if(!mapboxAccessToken) { + throw new Error([ + 'MAPBOX_ACCESS_TOKEN not found!!!', + 'Please export your mapbox access token into and try again.' + ].join('\n')); +} + +// Create a credentials json file, +// to be required in jasmine test suites and test dashboard +var credentials = JSON.stringify({ + MAPBOX_ACCESS_TOKEN: mapboxAccessToken +}, null, 2); + +fs.writeFile(constants.pathToCredentials, credentials, function(err) { + if(err) throw err; +}); + +// Create a 'set plot config' file, +// to be included in the image test index +var setPlotConfig = [ + '\'use strict\';', + '', + '/* global Plotly:false */', + '', + 'Plotly.setPlotConfig({', + ' mapboxAccessToken: \'' + mapboxAccessToken + '\'', + '});', + '' +].join('\n'); + +fs.writeFile(constants.pathToSetPlotConfig, setPlotConfig, function(err) { + if(err) throw err; +}); diff --git a/tasks/util/constants.js b/tasks/util/constants.js index fb78cc0c79b..f4a190e6192 100644 --- a/tasks/util/constants.js +++ b/tasks/util/constants.js @@ -51,6 +51,9 @@ module.exports = { pathToJasmineTests: path.join(pathToRoot, 'test/jasmine/tests'), pathToJasmineBundleTests: path.join(pathToRoot, 'test/jasmine/bundle_tests'), + pathToCredentials: path.join(pathToBuild, 'credentials.json'), + pathToSetPlotConfig: path.join(pathToBuild, 'set_plot_config.js'), + uglifyOptions: { fromString: true, mangle: true, diff --git a/tasks/util/shortcut_paths.js b/tasks/util/shortcut_paths.js index 99a26d9751c..d998590bd3b 100644 --- a/tasks/util/shortcut_paths.js +++ b/tasks/util/shortcut_paths.js @@ -11,7 +11,8 @@ var constants = require('./constants'); var shortcutsConfig = { '@src': constants.pathToSrc, '@lib': constants.pathToLib, - '@mocks': constants.pathToTestImageMocks + '@mocks': constants.pathToTestImageMocks, + '@build': constants.pathToBuild }; module.exports = transformTools.makeRequireTransform('requireTransform', diff --git a/test/image/baselines/mapbox_0.png b/test/image/baselines/mapbox_0.png new file mode 100644 index 00000000000..9b869bac3ec Binary files /dev/null and b/test/image/baselines/mapbox_0.png differ diff --git a/test/image/baselines/mapbox_angles.png b/test/image/baselines/mapbox_angles.png new file mode 100644 index 00000000000..a3c0606f57b Binary files /dev/null and b/test/image/baselines/mapbox_angles.png differ diff --git a/test/image/baselines/mapbox_bubbles-text.png b/test/image/baselines/mapbox_bubbles-text.png new file mode 100644 index 00000000000..db8d036b0c1 Binary files /dev/null and b/test/image/baselines/mapbox_bubbles-text.png differ diff --git a/test/image/baselines/mapbox_bubbles.png b/test/image/baselines/mapbox_bubbles.png new file mode 100644 index 00000000000..11b5e21aa70 Binary files /dev/null and b/test/image/baselines/mapbox_bubbles.png differ diff --git a/test/image/baselines/mapbox_connectgaps.png b/test/image/baselines/mapbox_connectgaps.png new file mode 100644 index 00000000000..9fee51670db Binary files /dev/null and b/test/image/baselines/mapbox_connectgaps.png differ diff --git a/test/image/baselines/mapbox_custom-style.png b/test/image/baselines/mapbox_custom-style.png new file mode 100644 index 00000000000..ba20f14f01e Binary files /dev/null and b/test/image/baselines/mapbox_custom-style.png differ diff --git a/test/image/baselines/mapbox_fill.png b/test/image/baselines/mapbox_fill.png new file mode 100644 index 00000000000..c3fe0510580 Binary files /dev/null and b/test/image/baselines/mapbox_fill.png differ diff --git a/test/image/baselines/mapbox_layers.png b/test/image/baselines/mapbox_layers.png new file mode 100644 index 00000000000..9a7e16709c8 Binary files /dev/null and b/test/image/baselines/mapbox_layers.png differ diff --git a/test/image/baselines/mapbox_symbol-text.png b/test/image/baselines/mapbox_symbol-text.png new file mode 100644 index 00000000000..703b49681f2 Binary files /dev/null and b/test/image/baselines/mapbox_symbol-text.png differ diff --git a/test/image/compare_pixels_test.js b/test/image/compare_pixels_test.js index 69c5af8be00..1b2c97a258c 100644 --- a/test/image/compare_pixels_test.js +++ b/test/image/compare_pixels_test.js @@ -54,6 +54,11 @@ function runAll() { ); }); + // skip mapbox mocks for now + mocks = mocks.filter(function(mock) { + return mock.indexOf('mapbox_') === -1; + }); + t.plan(mocks.length); for(var i = 0; i < mocks.length; i++) { diff --git a/test/image/index.html b/test/image/index.html index efa5c6d2edc..e7d04e93247 100644 --- a/test/image/index.html +++ b/test/image/index.html @@ -5,6 +5,7 @@ + diff --git a/test/image/mocks/mapbox_0.json b/test/image/mocks/mapbox_0.json new file mode 100644 index 00000000000..dc453edda72 --- /dev/null +++ b/test/image/mocks/mapbox_0.json @@ -0,0 +1,53 @@ +{ + "data": [ + { + "type": "scattermapbox", + "mode": "markers+lines", + "lon": [ + 10, + 20, + 30 + ], + "lat": [ + 10, + 20, + 30 + ], + "marker": { + "size": 20 + }, + "uid": "d4580a" + }, + { + "type": "scattermapbox", + "mode": "markers+lines", + "lon": [ + -75, + -120, + 170 + ], + "lat": [ + 45, + 20, + -40 + ], + "marker": { + "size": 20 + }, + "uid": "f5a4c5" + } + ], + "layout": { + "mapbox": { + "style": "dark", + "center": { + "lon": -4.71092760419225, + "lat": 19.475789009298566 + }, + "zoom": 1.2345714569517612 + }, + "height": 450, + "width": 1100, + "autosize": true + } +} diff --git a/test/image/mocks/mapbox_angles.json b/test/image/mocks/mapbox_angles.json new file mode 100644 index 00000000000..8592525ae5e --- /dev/null +++ b/test/image/mocks/mapbox_angles.json @@ -0,0 +1,90 @@ +{ + "data": [ + { + "type": "scattermapbox", + "mode": "markers", + "lon": [ + -73.6, + -73.55, + -73.57 + ], + "lat": [ + 45.5, + 45.49, + 45.51 + ], + "marker": { + "size": 15, + "symbol": [ + "monument", + "harbor", + "music" + ] + } + }, + { + "type": "scattermapbox", + "mode": "markers", + "lon": [ + -73.6, + -73.55, + -73.57 + ], + "lat": [ + 45.5, + 45.49, + 45.51 + ], + "marker": { + "size": 12, + "symbol": [ + "monument", + "harbor", + "music" + ] + }, + "subplot": "mapbox2" + } + ], + "layout": { + "mapbox": { + "domain": { + "x": [ + 0, + 1 + ], + "y": [ + 0, + 1 + ] + }, + "center": { + "lon": -73.58432898976686, + "lat": 45.51514448108094 + }, + "zoom": 10.8, + "pitch": 60, + "bearing": -60 + }, + "mapbox2": { + "domain": { + "x": [ + 0.6, + 1 + ], + "y": [ + 0.6, + 1 + ] + }, + "center": { + "lon": -73.61356140239891, + "lat": 45.49896198405253 + }, + "zoom": 9.8 + }, + "showlegend": false, + "width": 700, + "height": 450 + } +} diff --git a/test/image/mocks/mapbox_bubbles-text.json b/test/image/mocks/mapbox_bubbles-text.json new file mode 100644 index 00000000000..559e8f2676b --- /dev/null +++ b/test/image/mocks/mapbox_bubbles-text.json @@ -0,0 +1,52 @@ +{ + "data": [ + { + "type": "scattermapbox", + "mode": "markers+lines+text", + "lon": [ + 10, + 20, + 30 + ], + "lat": [ + 10, + 20, + 30 + ], + "marker": { + "size": [ + 10, + 30, + 20 + ], + "color": [ + "red", + "green", + "blue" + ] + }, + "text": [ + "A", + "B", + "C" + ], + "textposition": "top left", + "textfont": { + "size": 20, + "color": "red" + } + } + ], + "layout": { + "mapbox": { + "style": "light", + "zoom": 2.5, + "center": { + "lon": 19.5, + "lat": 22 + } + }, + "width": 700, + "height": 450 + } +} diff --git a/test/image/mocks/mapbox_bubbles.json b/test/image/mocks/mapbox_bubbles.json new file mode 100644 index 00000000000..756ec774227 --- /dev/null +++ b/test/image/mocks/mapbox_bubbles.json @@ -0,0 +1,85 @@ +{ + "data": [ + { + "type": "scattermapbox", + "lon": [ + 10, + 20, + 30 + ], + "lat": [ + 10, + 20, + 30 + ], + "marker": { + "size": [ + 20, + 10, + 40 + ], + "color": [ + "red", + "blue", + "orange" + ] + } + }, + { + "type": "scattermapbox", + "lon": [ + -75, + -120, + 100 + ], + "lat": [ + 45, + 20, + -40 + ], + "marker": { + "size": [ + 60, + 20, + 40 + ], + "color": [ + 0, + 20, + 30 + ], + "colorbar": {}, + "cmin": 0, + "cmax": 30, + "colorscale": [ + [ + 0, + "rgb(220,220,220)" + ], + [ + 0.2, + "rgb(245,195,157)" + ], + [ + 0.4, + "rgb(245,160,105)" + ], + [ + 1, + "rgb(178,10,28)" + ] + ] + } + } + ], + "layout": { + "mapbox": { + "style": "light", + "zoom": 0.5 + }, + "showlegend": false, + "height": 450, + "width": 1100, + "autosize": true + } +} diff --git a/test/image/mocks/mapbox_connectgaps.json b/test/image/mocks/mapbox_connectgaps.json new file mode 100644 index 00000000000..7711bb5c159 --- /dev/null +++ b/test/image/mocks/mapbox_connectgaps.json @@ -0,0 +1,70 @@ +{ + "data": [ + { + "type": "scattermapbox", + "mode": "lines", + "lon": [ + 10, + 20, + 30, + null, + 20, + 30, + 40 + ], + "lat": [ + 10, + 20, + 30, + null, + 10, + 20, + 30 + ], + "line": { + "width": 5 + }, + "name": "connectgaps false" + }, + { + "type": "scattermapbox", + "mode": "lines", + "lon": [ + 10, + 20, + 30, + null, + 20, + 30, + 40 + ], + "lat": [ + -10, + -20, + -30, + null, + -10, + -20, + -30 + ], + "line": { + "width": 10 + }, + "connectgaps": true, + "name": "connectgaps true" + } + ], + "layout": { + "mapbox": { + "style": "streets", + "zoom": 1.5, + "center": { + "lon": 56, + "lat": 1 + } + }, + "height": 450, + "width": 1100, + "autosize": true + } +} diff --git a/test/image/mocks/mapbox_custom-style.json b/test/image/mocks/mapbox_custom-style.json new file mode 100644 index 00000000000..99f087af0cd --- /dev/null +++ b/test/image/mocks/mapbox_custom-style.json @@ -0,0 +1,70 @@ +{ + "data": [ + { + "type": "scattermapbox", + "mode": "markers", + "lon": [ + -73.6, + -73.62, + -73.58 + ], + "lat": [ + 45.5, + 45.52, + 45.48 + ], + "marker": { + "size": 20, + "color": [ + "#1b9e77", + "#d95f02", + "#7570b3" + ] + } + }, + { + "type": "scattermapbox", + "mode": "markers", + "lon": [ + -73.6, + -73.62, + -73.58 + ], + "lat": [ + 45.5, + 45.52, + 45.48 + ], + "marker": { + "size": [ + 20, + 30, + 10 + ], + "color": "#1b9e77" + }, + "subplot": "mapbox2" + } + ], + "layout": { + "mapbox": { + "style": "mapbox://styles/etpinard/cip93fm98000sbmnuednknloo", + "center": { + "lon": -73.60287319770295, + "lat": 45.50110152988742 + }, + "zoom": 11.4 + }, + "mapbox2": { + "style": "satellite-streets", + "center": { + "lon": -73.60287319770295, + "lat": 45.50110152988742 + }, + "zoom": 11.4 + }, + "showlegend": false, + "width": 700, + "height": 450 + } +} diff --git a/test/image/mocks/mapbox_fill.json b/test/image/mocks/mapbox_fill.json new file mode 100644 index 00000000000..c07de985ebe --- /dev/null +++ b/test/image/mocks/mapbox_fill.json @@ -0,0 +1,102 @@ +{ + "data": [ + { + "type": "scattermapbox", + "mode": "lines", + "fill": "toself", + "lon": [ + -67.13734351262877, + -66.96466, + -68.03252, + -69.06, + -70.11617, + -70.64573401557249, + -70.75102474636725, + -70.79761105007827, + -70.98176001655037, + -70.94416541205806, + -71.08482, + -70.6600225491012, + -70.30495378282376, + -70.00014034695016, + -69.23708614772835, + -68.90478084987546, + -68.23430497910454, + -67.79035274928509, + -67.79141211614706, + -67.13734351262877, + null, + -76, + -76, + -74, + -74, + -76 + ], + "lat": [ + 45.137451890638886, + 44.8097, + 44.3252, + 43.98, + 43.68405, + 43.090083319667144, + 43.08003225358635, + 43.21973948828747, + 43.36789581966826, + 43.46633942318431, + 45.3052400000002, + 45.46022288673396, + 45.914794623389355, + 46.69317088478567, + 47.44777598732787, + 47.184794623394396, + 47.35462921812177, + 47.066248887716995, + 45.702585354182816, + 45.137451890638886, + null, + 44, + 46, + 46, + 44, + 44 + ], + "line": { + "width": 6, + "color": "#756bb1" + }, + "fillcolor": "#d3d3d3" + }, + { + "type": "scattermapbox", + "fill": "toself", + "lon": [ + -75, + -77, + -77, + -75 + ], + "lat": [ + 47, + 47, + 49, + 49 + ], + "marker": { + "size": 20 + } + } + ], + "layout": { + "mapbox": { + "center": { + "lon": -67, + "lat": 45.5 + }, + "zoom": 4 + }, + "showlegend": false, + "height": 450, + "width": 1100, + "autosize": true + } +} diff --git a/test/image/mocks/mapbox_layers.json b/test/image/mocks/mapbox_layers.json new file mode 100644 index 00000000000..9d98da7dcc8 --- /dev/null +++ b/test/image/mocks/mapbox_layers.json @@ -0,0 +1,553 @@ +{ + "data": [ + { + "type": "scattermapbox", + "mode": "markers", + "lon": [ + -73.6, + -73.62, + -73.58 + ], + "lat": [ + 45.5, + 45.52, + 45.48 + ], + "marker": { + "size": 20, + "color": [ + "#1b9e77", + "#d95f02", + "#7570b3" + ] + } + } + ], + "layout": { + "mapbox": { + "style": "light", + "center": { + "lon": -73.59194521800514, + "lat": 45.50110152988742 + }, + "zoom": 11.4, + "layers": [ + { + "source": { + "name": "LIMADMIN", + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + -73.6207755934418, + 45.5236508796029 + ], + [ + -73.6207872028152, + 45.5236377690769 + ], + [ + -73.6208360983675, + 45.5235925888858 + ], + [ + -73.6229064657189, + 45.5212544720817 + ], + [ + -73.6244768311808, + 45.5195510388648 + ], + [ + -73.6267218934442, + 45.5165789048672 + ], + [ + -73.6260049185647, + 45.5162468899168 + ], + [ + -73.6252809111825, + 45.5159299069007 + ], + [ + -73.6247608646889, + 45.5156979294181 + ], + [ + -73.6247258132996, + 45.5156978780009 + ], + [ + -73.6240228217757, + 45.5153728701655 + ], + [ + -73.6238049108022, + 45.5152729444053 + ], + [ + -73.6225748083554, + 45.5147068651661 + ], + [ + -73.6211108895945, + 45.5140419351202 + ], + [ + -73.6181778143121, + 45.512730938516 + ], + [ + -73.6176638457886, + 45.5124989304851 + ], + [ + -73.618879835197, + 45.5111338573937 + ], + [ + -73.6169198104361, + 45.5102758011751 + ], + [ + -73.6178927943616, + 45.509173781791 + ], + [ + -73.6160398238206, + 45.5083287473535 + ], + [ + -73.6162207774673, + 45.5081327369473 + ], + [ + -73.6164537542892, + 45.507876735584 + ], + [ + -73.6179928487264, + 45.5062047819349 + ], + [ + -73.6182368673897, + 45.5059367055579 + ], + [ + -73.6183618439377, + 45.5057827742422 + ], + [ + -73.6185918430223, + 45.5055257789995 + ], + [ + -73.618948838393, + 45.5051247454567 + ], + [ + -73.6182348932225, + 45.5047207201667 + ], + [ + -73.6177618618361, + 45.5052617221482 + ], + [ + -73.6175969109787, + 45.505430723084 + ], + [ + -73.6174598829913, + 45.5055757562669 + ], + [ + -73.616486866179, + 45.506659747411 + ], + [ + -73.6158888397486, + 45.5073138173095 + ], + [ + -73.6156428056742, + 45.50758584957 + ], + [ + -73.6146357847723, + 45.5086858816877 + ], + [ + -73.6139258105029, + 45.5094719273215 + ], + [ + -73.6130488831493, + 45.5104459633429 + ], + [ + -73.6122108318229, + 45.5100839424483 + ], + [ + -73.611243886916, + 45.5096689249774 + ], + [ + -73.6063528884867, + 45.5074899994525 + ], + [ + -73.606133883651, + 45.506876000718 + ], + [ + -73.6059059043036, + 45.5067739808066 + ], + [ + -73.6035339056393, + 45.5056989463303 + ], + [ + -73.6024758700409, + 45.5068569698039 + ], + [ + -73.6000319044616, + 45.5056960036382 + ], + [ + -73.5993799928364, + 45.5053890662042 + ], + [ + -73.5991199024211, + 45.5056320080072 + ], + [ + -73.5988969771019, + 45.5055140396732 + ], + [ + -73.5987838942435, + 45.5056170015326 + ], + [ + -73.5956109141946, + 45.5040629949286 + ], + [ + -73.5934899969367, + 45.5061120484855 + ], + [ + -73.5914649102154, + 45.5080701175066 + ], + [ + -73.5941909351712, + 45.5097861073881 + ], + [ + -73.5935088384283, + 45.5105251548103 + ], + [ + -73.5943628438174, + 45.5109121348475 + ], + [ + -73.5949668679484, + 45.5109061398078 + ], + [ + -73.5956738651781, + 45.5111200884042 + ], + [ + -73.5966758518709, + 45.5115460750828 + ], + [ + -73.5969007789133, + 45.5117780652608 + ], + [ + -73.5968818247043, + 45.5124961373726 + ], + [ + -73.5968167337995, + 45.5125841163881 + ], + [ + -73.596793808861, + 45.5126371410099 + ], + [ + -73.5967787648421, + 45.5127301162159 + ], + [ + -73.5968207695811, + 45.5129110558321 + ], + [ + -73.5969408082518, + 45.51304108236 + ], + [ + -73.5970327502278, + 45.5130991385768 + ], + [ + -73.5971757853588, + 45.5131550812638 + ], + [ + -73.5973337620513, + 45.5131831132336 + ], + [ + -73.5975097723248, + 45.5131990707199 + ], + [ + -73.5977157399835, + 45.5132010527777 + ], + [ + -73.5979057416331, + 45.513189099848 + ], + [ + -73.5980747296379, + 45.513173114616 + ], + [ + -73.5984197121962, + 45.5131030704137 + ], + [ + -73.5986818075475, + 45.513086104328 + ], + [ + -73.5987887625844, + 45.5130941096285 + ], + [ + -73.5988957337211, + 45.5131121048595 + ], + [ + -73.5984157123796, + 45.5136001046227 + ], + [ + -73.5978607488775, + 45.5141631292314 + ], + [ + -73.5977037635577, + 45.5142861043779 + ], + [ + -73.597550728497, + 45.5143730795687 + ], + [ + -73.597366749244, + 45.5144530605157 + ], + [ + -73.5972027743173, + 45.5145020668286 + ], + [ + -73.5970616795637, + 45.5145341354493 + ], + [ + -73.5968587128929, + 45.5145621165344 + ], + [ + -73.5956037122206, + 45.5145810897428 + ], + [ + -73.5921677973593, + 45.5144841395591 + ], + [ + -73.5920288439416, + 45.5144691334948 + ], + [ + -73.5918448417188, + 45.514472163475 + ], + [ + -73.5916048410107, + 45.5145121346391 + ], + [ + -73.591418828585, + 45.5145781592694 + ], + [ + -73.5912887748834, + 45.5146491787759 + ], + [ + -73.5911988215994, + 45.5147101759116 + ], + [ + -73.590205784111, + 45.5157531802494 + ], + [ + -73.5913087273834, + 45.5162461850563 + ], + [ + -73.5913807820754, + 45.5162801450928 + ], + [ + -73.5967786568455, + 45.5186900628813 + ], + [ + -73.6027967704682, + 45.5213480469144 + ], + [ + -73.6122399832393, + 45.5255640375319 + ], + [ + -73.612422919886, + 45.5256420616179 + ], + [ + -73.617229085672, + 45.5277519834938 + ], + [ + -73.6172792346566, + 45.5277741602959 + ], + [ + -73.617304713874, + 45.5277413346227 + ], + [ + -73.6174920524961, + 45.5274983627902 + ], + [ + -73.6175332583267, + 45.5275122538334 + ], + [ + -73.6180741889569, + 45.5267591051693 + ], + [ + -73.6182716512573, + 45.526500673565 + ], + [ + -73.6184463200447, + 45.5262879434732 + ], + [ + -73.6189685079001, + 45.5256985601733 + ], + [ + -73.6193880023274, + 45.5252167500984 + ], + [ + -73.6195329660365, + 45.5250641831466 + ], + [ + -73.6196866624052, + 45.5248892907296 + ], + [ + -73.6197870389348, + 45.5247700866834 + ], + [ + -73.6199257423881, + 45.5245849397865 + ], + [ + -73.6199544860087, + 45.5245576900523 + ], + [ + -73.620122362851, + 45.5243779610402 + ], + [ + -73.6202017130167, + 45.5242989075929 + ], + [ + -73.6207755934418, + 45.5236508796029 + ] + ] + ] + ] + }, + "properties": { + "NOM": "Outremont", + "TYPE": "Arrondissement", + "CODEID": "11", + "ABREV": "OM", + "NUM": 5, + "CODEMAMROT": "REM05", + "AIRE": 3813355.72326504, + "MUNID": 66023, + "PERIM": 10836.6706340882 + } + } + ] + }, + "type": "fill", + "below": "water", + "fillcolor": "#ece2f0", + "opacity": 0.8 + }, + { + "sourcetype": "vector", + "source": "mapbox://mapbox.mapbox-terrain-v2", + "sourcelayer": "contour", + "line": { + "width": 2, + "color": "red" + } + } + ] + }, + "height": 450, + "width": 1100, + "autosize": true + } +} diff --git a/test/image/mocks/mapbox_symbol-text.json b/test/image/mocks/mapbox_symbol-text.json new file mode 100644 index 00000000000..8a892b9e916 --- /dev/null +++ b/test/image/mocks/mapbox_symbol-text.json @@ -0,0 +1,70 @@ +{ + "data": [ + { + "type": "scattermapbox", + "mode": "markers+lines+text", + "lon": [ + -10, + 20, + 30 + ], + "lat": [ + -10, + 20, + 40 + ], + "marker": { + "symbol": "harbor", + "size": 5 + }, + "text": "LOOK", + "textfont": { + "size": 10, + "color": "red" + }, + "textposition": "top center" + }, + { + "type": "scattermapbox", + "mode": "markers+text", + "lon": [ + -75, + -100, + 120 + ], + "lat": [ + 45, + 20, + -40 + ], + "marker": { + "size": 15, + "symbol": [ + "monument", + "harbor", + "music" + ] + }, + "text": [ + "Monument", + "Harbor", + "Music" + ], + "textposition": "bottom left" + } + ], + "layout": { + "mapbox": { + "style": "outdoors", + "zoom": 0.5, + "center": { + "lon": 0, + "lat": 0 + } + }, + "showlegend": false, + "height": 450, + "width": 1100, + "autosize": true + } +} diff --git a/test/jasmine/karma.ciconf.js b/test/jasmine/karma.ciconf.js index e735ef639ad..38f63ccac97 100644 --- a/test/jasmine/karma.ciconf.js +++ b/test/jasmine/karma.ciconf.js @@ -17,7 +17,9 @@ function func(config) { func.defaultConfig.exclude = [ 'tests/gl_plot_interact_test.js', 'tests/gl_plot_interact_basic_test.js', - 'tests/gl2d_scatterplot_contour_test.js' + 'tests/gl2d_scatterplot_contour_test.js', + 'tests/mapbox_test.js', + 'tests/scattermapbox_test.js' ]; // if true, Karma captures browsers, runs the tests and exits diff --git a/test/jasmine/tests/mapbox_test.js b/test/jasmine/tests/mapbox_test.js new file mode 100644 index 00000000000..b335b861f95 --- /dev/null +++ b/test/jasmine/tests/mapbox_test.js @@ -0,0 +1,725 @@ +var Plotly = require('@lib'); +var Lib = require('@src/lib'); + +var constants = require('@src/plots/mapbox/constants'); +var supplyLayoutDefaults = require('@src/plots/mapbox/layout_defaults'); + +var d3 = require('d3'); +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var mouseEvent = require('../assets/mouse_event'); +var customMatchers = require('../assets/custom_matchers'); + +var MAPBOX_ACCESS_TOKEN = require('@build/credentials.json').MAPBOX_ACCESS_TOKEN; +var TRANSITION_DELAY = 500; + +var noop = function() {}; + +// until it is part of the main plotly.js bundle +Plotly.register( + require('@lib/scattermapbox') +); + +Plotly.setPlotConfig({ + mapboxAccessToken: MAPBOX_ACCESS_TOKEN +}); + + +describe('mapbox defaults', function() { + 'use strict'; + + var layoutIn, layoutOut, fullData; + + beforeEach(function() { + layoutOut = { font: { color: 'red' } }; + + // needs a ternary-ref in a trace in order to be detected + fullData = [{ type: 'scattermapbox', subplot: 'mapbox' }]; + }); + + it('should fill empty containers', function() { + layoutIn = {}; + + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + expect(layoutIn).toEqual({ mapbox: {} }); + }); + + it('should copy ref to input container in full (for updating on map move)', function() { + var mapbox = { style: 'light '}; + + layoutIn = { mapbox: mapbox }; + + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + expect(layoutOut.mapbox._input).toBe(mapbox); + }); + + it('should fill layer containers', function() { + layoutIn = { + mapbox: { + layers: [{}, {}] + } + }; + + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + expect(layoutOut.mapbox.layers[0].sourcetype).toEqual('geojson'); + expect(layoutOut.mapbox.layers[1].sourcetype).toEqual('geojson'); + }); + + it('should coerce \'sourcelayer\' only for *vector* \'sourcetype\'', function() { + layoutIn = { + mapbox: { + layers: [{ + sourcetype: 'vector', + sourcelayer: 'layer0' + }, { + sourcetype: 'geojson', + sourcelayer: 'layer0' + }] + } + }; + + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + expect(layoutOut.mapbox.layers[0].sourcelayer).toEqual('layer0'); + expect(layoutOut.mapbox.layers[1].sourcelayer).toBeUndefined(); + }); + + it('should only coerce relevant layer style attributes', function() { + layoutIn = { + mapbox: { + layers: [{ + sourcetype: 'vector', + type: 'line', + line: { + color: 'red', + width: 3 + }, + fillcolor: 'blue' + }, { + sourcetype: 'geojson', + type: 'fill', + line: { + color: 'red', + width: 3 + }, + fillcolor: 'blue' + }] + } + }; + + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + + expect(layoutOut.mapbox.layers[0].line.color).toEqual('red'); + expect(layoutOut.mapbox.layers[0].line.width).toEqual(3); + expect(layoutOut.mapbox.layers[0].fillcolor).toBeUndefined(); + + expect(layoutOut.mapbox.layers[1].line.color).toEqual('red'); + expect(layoutOut.mapbox.layers[1].line.width).toBeUndefined(); + expect(layoutOut.mapbox.layers[1].fillcolor).toEqual('blue'); + }); +}); + +describe('mapbox credentials', function() { + 'use strict'; + + var dummyToken = 'asfdsa124331wersdsa1321q3'; + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + + Plotly.setPlotConfig({ + mapboxAccessToken: null + }); + }); + + afterEach(function() { + Plotly.purge(gd); + destroyGraphDiv(); + + Plotly.setPlotConfig({ + mapboxAccessToken: MAPBOX_ACCESS_TOKEN + }); + }); + + it('should throw error if token is not registered', function() { + expect(function() { + Plotly.plot(gd, [{ + type: 'scattermapbox', + lon: [10, 20, 30], + lat: [10, 20, 30] + }]); + }).toThrow(new Error(constants.noAccessTokenErrorMsg)); + }); + + it('should throw error if token is invalid', function(done) { + Plotly.plot(gd, [{ + type: 'scattermapbox', + lon: [10, 20, 30], + lat: [10, 20, 30] + }], {}, { + mapboxAccessToken: dummyToken + }).catch(function(err) { + expect(err).toEqual(new Error(constants.mapOnErrorMsg)); + done(); + }); + }); +}); + +describe('mapbox plots', function() { + 'use strict'; + + var mock = require('@mocks/mapbox_0.json'), + gd; + + var pointPos = [579, 276], + blankPos = [650, 120]; + + beforeAll(function() { + jasmine.addMatchers(customMatchers); + }); + + beforeEach(function(done) { + gd = createGraphDiv(); + + var mockCopy = Lib.extendDeep({}, mock); + + Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(done); + }); + + afterEach(function() { + Plotly.purge(gd); + destroyGraphDiv(); + }); + + it('should be able to toggle trace visibility', function(done) { + var modes = ['line', 'circle']; + + expect(countVisibleTraces(gd, modes)).toEqual(2); + + Plotly.restyle(gd, 'visible', false).then(function() { + expect(gd._fullLayout.mapbox).toBeUndefined(); + + return Plotly.restyle(gd, 'visible', true); + }).then(function() { + expect(countVisibleTraces(gd, modes)).toEqual(2); + + return Plotly.restyle(gd, 'visible', 'legendonly', [1]); + }).then(function() { + expect(countVisibleTraces(gd, modes)).toEqual(1); + + return Plotly.restyle(gd, 'visible', true); + }).then(function() { + expect(countVisibleTraces(gd, modes)).toEqual(2); + + done(); + }); + }); + + it('should be able to delete and add traces', function(done) { + var modes = ['line', 'circle']; + + expect(countVisibleTraces(gd, modes)).toEqual(2); + + Plotly.deleteTraces(gd, [0]).then(function() { + expect(countVisibleTraces(gd, modes)).toEqual(1); + + var trace = { + type: 'scattermapbox', + mode: 'markers+lines', + lon: [-10, -20, -10], + lat: [-10, 20, -10] + }; + + return Plotly.addTraces(gd, [trace]); + }).then(function() { + expect(countVisibleTraces(gd, modes)).toEqual(2); + + var trace = { + type: 'scattermapbox', + mode: 'markers+lines', + lon: [10, 20, 10], + lat: [10, -20, 10] + }; + + return Plotly.addTraces(gd, [trace]); + }).then(function() { + expect(countVisibleTraces(gd, modes)).toEqual(3); + + return Plotly.deleteTraces(gd, [0, 1, 2]); + }).then(function() { + expect(gd._fullLayout.mapbox).toBeUndefined(); + + done(); + }); + }); + + it('should be able to restyle', function(done) { + function assertMarkerColor(expectations) { + return new Promise(function(resolve) { + setTimeout(function() { + var colors = getStyle(gd, 'circle', 'circle-color'); + + expectations.forEach(function(expected, i) { + expect(colors[i]).toBeCloseToArray(expected); + }); + + resolve(); + }, TRANSITION_DELAY); + }); + } + + assertMarkerColor([ + [0.121, 0.466, 0.705, 1], + [1, 0.498, 0.0549, 1] + ]) + .then(function() { + return Plotly.restyle(gd, 'marker.color', 'green'); + }) + .then(function() { + return assertMarkerColor([ + [0, 0.5019, 0, 1], + [0, 0.5019, 0, 1] + ]); + }) + .then(function() { + return Plotly.restyle(gd, 'marker.color', 'red', [1]); + }) + .then(function() { + return assertMarkerColor([ + [0, 0.5019, 0, 1], + [1, 0, 0, 1] + ]); + }) + .then(done); + }); + + it('should be able to relayout', function(done) { + function assertLayout(style, center, zoom, dims) { + var mapInfo = getMapInfo(gd); + + expect(mapInfo.style.name).toEqual(style); + expect([mapInfo.center.lng, mapInfo.center.lat]) + .toBeCloseToArray(center); + expect(mapInfo.zoom).toBeCloseTo(zoom); + + var divStyle = mapInfo.div.style; + var expectedDims = ['left', 'top', 'width', 'height'].map(function(p) { + return parseFloat(divStyle[p]); + }); + + expect(expectedDims).toBeCloseToArray(dims); + } + + assertLayout('Mapbox Dark', [-4.710, 19.475], 1.234, [80, 100, 908, 270]); + + Plotly.relayout(gd, 'mapbox.center', { lon: 0, lat: 0 }).then(function() { + assertLayout('Mapbox Dark', [0, 0], 1.234, [80, 100, 908, 270]); + + return Plotly.relayout(gd, 'mapbox.zoom', '6'); + }).then(function() { + assertLayout('Mapbox Dark', [0, 0], 6, [80, 100, 908, 270]); + + return Plotly.relayout(gd, 'mapbox.style', 'light'); + }).then(function() { + assertLayout('Mapbox Light', [0, 0], 6, [80, 100, 908, 270]); + + return Plotly.relayout(gd, 'mapbox.domain.x', [0, 0.5]); + }).then(function() { + assertLayout('Mapbox Light', [0, 0], 6, [80, 100, 454, 270]); + + return Plotly.relayout(gd, 'mapbox.domain.y[0]', 0.5); + }).then(function() { + assertLayout('Mapbox Light', [0, 0], 6, [80, 100, 454, 135]); + + done(); + }); + }); + + it('should be able to add, update and remove layers', function(done) { + var mockWithLayers = require('@mocks/mapbox_layers'); + + var layer0 = Lib.extendDeep({}, mockWithLayers.layout.mapbox.layers[0]), + layer1 = Lib.extendDeep({}, mockWithLayers.layout.mapbox.layers[1]); + + var mapUpdate = { + 'mapbox.zoom': mockWithLayers.layout.mapbox.zoom, + 'mapbox.center.lon': mockWithLayers.layout.mapbox.center.lon, + 'mapbox.center.lat': mockWithLayers.layout.mapbox.center.lat + }; + + var styleUpdate0 = { + 'mapbox.layers[0].fillcolor': 'red', + 'mapbox.layers[0].line.color': 'blue', + 'mapbox.layers[0].opacity': 0.3 + }; + + var styleUpdate1 = { + 'mapbox.layers[1].line.width': 3, + 'mapbox.layers[1].line.color': 'blue', + 'mapbox.layers[1].opacity': 0.6 + }; + + function countVisibleLayers(gd) { + var mapInfo = getMapInfo(gd); + + var sourceLen = mapInfo.layoutSources.length, + layerLen = mapInfo.layoutLayers.length; + + if(sourceLen !== layerLen) return null; + + return layerLen; + } + + function assertLayerStyle(gd, expectations, index) { + var mapInfo = getMapInfo(gd), + layers = mapInfo.layers, + layerNames = mapInfo.layoutLayers; + + var layer = layers[layerNames[index]]; + + return new Promise(function(resolve) { + setTimeout(function() { + Object.keys(expectations).forEach(function(k) { + expect(layer.paint[k]).toEqual(expectations[k]); + }); + resolve(); + }, TRANSITION_DELAY); + }); + } + + expect(countVisibleLayers(gd)).toEqual(0); + + Plotly.relayout(gd, 'mapbox.layers[0]', layer0).then(function() { + expect(countVisibleLayers(gd)).toEqual(1); + + return Plotly.relayout(gd, 'mapbox.layers[1]', layer1); + }).then(function() { + expect(countVisibleLayers(gd)).toEqual(2); + + return Plotly.relayout(gd, mapUpdate); + }).then(function() { + expect(countVisibleLayers(gd)).toEqual(2); + + return Plotly.relayout(gd, styleUpdate0); + }).then(function() { + expect(countVisibleLayers(gd)).toEqual(2); + + return assertLayerStyle(gd, { + 'fill-color': [1, 0, 0, 1], + 'fill-outline-color': [0, 0, 1, 1], + 'fill-opacity': 0.3 + }, 0); + }).then(function() { + expect(countVisibleLayers(gd)).toEqual(2); + + return Plotly.relayout(gd, styleUpdate1); + }).then(function() { + expect(countVisibleLayers(gd)).toEqual(2); + + return assertLayerStyle(gd, { + 'line-width': 3, + 'line-color': [0, 0, 1, 1], + 'line-opacity': 0.6 + }, 1); + }).then(function() { + expect(countVisibleLayers(gd)).toEqual(2); + + return Plotly.relayout(gd, 'mapbox.layers[1]', 'remove'); + }).then(function() { + expect(countVisibleLayers(gd)).toEqual(1); + + return Plotly.relayout(gd, 'mapbox.layers[0]', 'remove'); + }).then(function() { + expect(countVisibleLayers(gd)).toEqual(0); + + done(); + }); + }); + + it('should be able to update traces', function(done) { + function assertDataPts(lengths) { + var lines = getGeoJsonData(gd, 'lines'), + markers = getGeoJsonData(gd, 'markers'); + + lines.forEach(function(obj, i) { + expect(obj.coordinates[0].length).toEqual(lengths[i]); + }); + + markers.forEach(function(obj, i) { + expect(obj.features.length).toEqual(lengths[i]); + }); + } + + assertDataPts([3, 3]); + + var update = { + lon: [[10, 20]], + lat: [[-45, -20]] + }; + + Plotly.restyle(gd, update, [1]).then(function() { + assertDataPts([3, 2]); + + var update = { + lon: [ [10, 20], [30, 40, 20] ], + lat: [ [-10, 20], [10, 20, 30] ] + }; + + return Plotly.extendTraces(gd, update, [0, 1]); + }).then(function() { + assertDataPts([5, 5]); + + done(); + }); + }); + + it('should display to hover labels on mouse over', function(done) { + function assertMouseMove(pos, len) { + return _mouseEvent('mousemove', pos, function() { + var hoverLabels = d3.select('.hoverlayer').selectAll('g'); + + expect(hoverLabels.size()).toEqual(len); + }); + } + + assertMouseMove(blankPos, 0).then(function() { + return assertMouseMove(pointPos, 1); + }).then(done); + }); + + it('should respond to hover interactions by', function(done) { + var hoverCnt = 0, + unhoverCnt = 0; + + var hoverData, unhoverData; + + gd.on('plotly_hover', function(eventData) { + hoverCnt++; + hoverData = eventData.points[0]; + }); + + gd.on('plotly_unhover', function(eventData) { + unhoverCnt++; + unhoverData = eventData.points[0]; + }); + + _mouseEvent('mousemove', blankPos, function() { + expect(hoverData).toBe(undefined, 'not firing on blank points'); + expect(unhoverData).toBe(undefined, 'not firing on blank points'); + }) + .then(function() { + return _mouseEvent('mousemove', pointPos, function() { + expect(hoverData).not.toBe(undefined, 'firing on data points'); + expect(Object.keys(hoverData)).toEqual([ + 'data', 'fullData', 'curveNumber', 'pointNumber', + 'x', 'y', 'xaxis', 'yaxis' + ], 'returning the correct event data keys'); + expect(hoverData.curveNumber).toEqual(0, 'returning the correct curve number'); + expect(hoverData.pointNumber).toEqual(0, 'returning the correct point number'); + }); + }) + .then(function() { + return _mouseEvent('mousemove', blankPos, function() { + expect(unhoverData).not.toBe(undefined, 'firing on data points'); + expect(Object.keys(unhoverData)).toEqual([ + 'data', 'fullData', 'curveNumber', 'pointNumber', + 'x', 'y', 'xaxis', 'yaxis' + ], 'returning the correct event data keys'); + expect(unhoverData.curveNumber).toEqual(0, 'returning the correct curve number'); + expect(unhoverData.pointNumber).toEqual(0, 'returning the correct point number'); + }); + }) + .then(function() { + expect(hoverCnt).toEqual(1); + expect(unhoverCnt).toEqual(1); + + done(); + }); + }); + + it('should respond to click interactions by', function(done) { + var ptData; + + gd.on('plotly_click', function(eventData) { + ptData = eventData.points[0]; + }); + + function _click(pos, cb) { + var promise = _mouseEvent('mousemove', pos, noop).then(function() { + return _mouseEvent('mousedown', pos, noop); + }).then(function() { + return _mouseEvent('click', pos, cb); + }); + + return promise; + } + + _click(blankPos, function() { + expect(ptData).toBe(undefined, 'not firing on blank points'); + }) + .then(function() { + return _click(pointPos, function() { + expect(ptData).not.toBe(undefined, 'firing on data points'); + expect(Object.keys(ptData)).toEqual([ + 'data', 'fullData', 'curveNumber', 'pointNumber', + 'x', 'y', 'xaxis', 'yaxis' + ], 'returning the correct event data keys'); + expect(ptData.curveNumber).toEqual(0, 'returning the correct curve number'); + expect(ptData.pointNumber).toEqual(0, 'returning the correct point number'); + }); + }) + .then(done); + }); + + it('should respond drag / scroll interactions', function(done) { + function _drag(p0, p1, cb) { + var promise = _mouseEvent('mousemove', p0, noop).then(function() { + return _mouseEvent('mousedown', p0, noop); + }).then(function() { + return _mouseEvent('mousemove', p1, noop); + }).then(function() { + return _mouseEvent('mouseup', p1, cb); + }); + + return promise; + } + + function assertLayout(center, zoom) { + var mapInfo = getMapInfo(gd), + layout = gd.layout.mapbox; + + expect([mapInfo.center.lng, mapInfo.center.lat]) + .toBeCloseToArray(center); + expect(mapInfo.zoom).toBeCloseTo(zoom); + + expect([layout.center.lon, layout.center.lat]) + .toBeCloseToArray(center); + expect(layout.zoom).toBeCloseTo(zoom); + } + + assertLayout([-4.710, 19.475], 1.234); + + var p1 = [pointPos[0] + 50, pointPos[1] - 20]; + + _drag(pointPos, p1, function() { + assertLayout([-19.651, 13.751], 1.234); + }) + .then(done); + + // TODO test scroll + + }); + + function getMapInfo(gd) { + var subplot = gd._fullLayout.mapbox._subplot, + map = subplot.map; + + var sources = map.style.sources, + layers = map.style._layers, + uid = subplot.uid; + + var traceSources = Object.keys(sources).filter(function(k) { + return k.indexOf('-source-') !== -1; + }); + + var traceLayers = Object.keys(layers).filter(function(k) { + return k.indexOf('-layer-') !== -1; + }); + + var layoutSources = Object.keys(sources).filter(function(k) { + return k.indexOf(uid) !== -1; + }); + + var layoutLayers = Object.keys(layers).filter(function(k) { + return k.indexOf(uid) !== -1; + }); + + return { + map: map, + div: subplot.div, + sources: sources, + layers: layers, + traceSources: traceSources, + traceLayers: traceLayers, + layoutSources: layoutSources, + layoutLayers: layoutLayers, + center: map.getCenter(), + zoom: map.getZoom(), + style: map.getStyle() + }; + } + + function countVisibleTraces(gd, modes) { + var mapInfo = getMapInfo(gd), + cnts = []; + + // 'modes' are the ScatterMapbox layers names + // e.g. 'fill', 'line', 'circle', 'symbol' + + modes.forEach(function(mode) { + var cntPerMode = 0; + + mapInfo.traceLayers.forEach(function(l) { + var info = mapInfo.layers[l]; + + if(l.indexOf(mode) === -1) return; + if(info.layout.visibility === 'visible') cntPerMode++; + }); + + cnts.push(cntPerMode); + }); + + var cnt = cnts.reduce(function(a, b) { + return (a === b) ? a : null; + }); + + // returns null if not all counter per mode are the same, + // returns the counter if all are the same. + + return cnt; + } + + function getStyle(gd, mode, prop) { + var mapInfo = getMapInfo(gd), + values = []; + + mapInfo.traceLayers.forEach(function(l) { + var info = mapInfo.layers[l]; + + if(l.indexOf(mode) === -1) return; + + values.push(info.paint[prop]); + }); + + return values; + } + + function getGeoJsonData(gd, mode) { + var mapInfo = getMapInfo(gd), + out = []; + + mapInfo.traceSources.forEach(function(s) { + var info = mapInfo.sources[s]; + + if(s.indexOf(mode) === -1) return; + + out.push(info._data); + }); + + return out; + } + + function _mouseEvent(type, pos, cb) { + var DELAY = 100; + + return new Promise(function(resolve) { + mouseEvent(type, pos[0], pos[1]); + + setTimeout(function() { + cb(); + resolve(); + }, DELAY); + }); + } + +}); diff --git a/test/jasmine/tests/plotschema_test.js b/test/jasmine/tests/plotschema_test.js index 1bf548db041..bbbc9df65e5 100644 --- a/test/jasmine/tests/plotschema_test.js +++ b/test/jasmine/tests/plotschema_test.js @@ -1,6 +1,10 @@ var Plotly = require('@lib/index'); var Lib = require('@src/lib'); +// until it is part of the main plotly.js bundle +Plotly.register( + require('@lib/scattermapbox') +); describe('plot schema', function() { 'use strict'; @@ -91,7 +95,7 @@ describe('plot schema', function() { it('all subplot objects should contain _isSubplotObj', function() { var IS_SUBPLOT_OBJ = '_isSubplotObj', - astrs = ['xaxis', 'yaxis', 'scene', 'geo', 'ternary'], + astrs = ['xaxis', 'yaxis', 'scene', 'geo', 'ternary', 'mapbox'], list = []; // check if the subplot objects have '_isSubplotObj' @@ -116,7 +120,8 @@ describe('plot schema', function() { it('should convert _isLinkedToArray attributes to items object', function() { var astrs = [ 'annotations', 'shapes', 'images', - 'xaxis.rangeselector.buttons', 'yaxis.rangeselector.buttons' + 'xaxis.rangeselector.buttons', 'yaxis.rangeselector.buttons', + 'mapbox.layers' ]; astrs.forEach(function(astr) { diff --git a/test/jasmine/tests/scattermapbox_test.js b/test/jasmine/tests/scattermapbox_test.js new file mode 100644 index 00000000000..5aa47a1b385 --- /dev/null +++ b/test/jasmine/tests/scattermapbox_test.js @@ -0,0 +1,528 @@ +var Plotly = require('@lib'); +var Plots = require('@src/plots/plots'); +var Lib = require('@src/lib'); + +var ScatterMapbox = require('@src/traces/scattermapbox'); +var convert = require('@src/traces/scattermapbox/convert'); + +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var customMatchers = require('../assets/custom_matchers'); + +// until it is part of the main plotly.js bundle +Plotly.register( + require('@lib/scattermapbox') +); + +Plotly.setPlotConfig({ + mapboxAccessToken: require('@build/credentials.json').MAPBOX_ACCESS_TOKEN +}); + + +describe('scattermapbox defaults', function() { + 'use strict'; + + function _supply(traceIn) { + var traceOut = { visible: true }, + defaultColor = '#444', + layout = { _dataLength: 1 }; + + ScatterMapbox.supplyDefaults(traceIn, traceOut, defaultColor, layout); + + return traceOut; + } + + it('should truncate \'lon\' if longer than \'lat\'', function() { + var fullTrace = _supply({ + lon: [1, 2, 3], + lat: [2, 3] + }); + + expect(fullTrace.lon).toEqual([1, 2]); + expect(fullTrace.lat).toEqual([2, 3]); + }); + + it('should truncate \'lat\' if longer than \'lon\'', function() { + var fullTrace = _supply({ + lon: [1, 2, 3], + lat: [2, 3, 3, 5] + }); + + expect(fullTrace.lon).toEqual([1, 2, 3]); + expect(fullTrace.lat).toEqual([2, 3, 3]); + }); + + it('should set \'visible\' to false if \'lat\' and/or \'lon\' has zero length', function() { + var fullTrace = _supply({ + lon: [1, 2, 3], + lat: [] + }); + + expect(fullTrace.visible).toEqual(false); + + fullTrace = _supply({ + lon: null, + lat: [1, 2, 3] + }); + + expect(fullTrace.visible).toEqual(false); + }); + + it('should set \'marker.color\' and \'marker.size\' to first item if symbol is set to \'circle\'', function() { + var base = { + mode: 'markers', + lon: [1, 2, 3], + lat: [2, 3, 3], + marker: { + color: ['red', 'green', 'blue'], + size: [10, 20, 30] + } + }; + + var fullTrace = _supply(Lib.extendDeep({}, base, { + marker: { symbol: 'monument' } + })); + + expect(fullTrace.marker.color).toEqual('red'); + expect(fullTrace.marker.size).toEqual(10); + + fullTrace = _supply(Lib.extendDeep({}, base, { + marker: { symbol: ['monument', 'music', 'harbor'] } + })); + + expect(fullTrace.marker.color).toEqual('red'); + expect(fullTrace.marker.size).toEqual(10); + + fullTrace = _supply(Lib.extendDeep({}, base, { + marker: { symbol: 'circle' } + })); + + expect(fullTrace.marker.color).toEqual(['red', 'green', 'blue']); + expect(fullTrace.marker.size).toEqual([10, 20, 30]); + }); +}); + +describe('scattermapbox calc', function() { + 'use strict'; + + function _calc(trace) { + var gd = { data: [trace] }; + + Plots.supplyDefaults(gd); + + var fullTrace = gd._fullData[0]; + return ScatterMapbox.calc(gd, fullTrace); + } + + var base = { type: 'scattermapbox' }; + + it('should place lon/lat data in lonlat pairs', function() { + var calcTrace = _calc(Lib.extendFlat({}, base, { + lon: [10, 20, 30], + lat: [20, 30, 10] + })); + + expect(calcTrace).toEqual([ + { lonlat: [10, 20] }, + { lonlat: [20, 30] }, + { lonlat: [30, 10] } + ]); + }); + + it('should coerce numeric strings lon/lat data into numbers', function() { + var calcTrace = _calc(Lib.extendFlat({}, base, { + lon: [10, 20, '30', '40'], + lat: [20, '30', 10, '50'] + })); + + expect(calcTrace).toEqual([ + { lonlat: [10, 20] }, + { lonlat: [20, 30] }, + { lonlat: [30, 10] }, + { lonlat: [40, 50] } + ]); + }); + + it('should keep track of gaps in data', function() { + var calcTrace = _calc(Lib.extendFlat({}, base, { + lon: [null, 10, null, null, 20, '30', null, '40', null, 10], + lat: [10, 20, '30', null, 10, '50', null, 60, null, null] + })); + + expect(calcTrace).toEqual([ + { lonlat: [10, 20], gapAfter: true }, + { lonlat: [20, 10] }, + { lonlat: [30, 50], gapAfter: true }, + { lonlat: [40, 60], gapAfter: true } + ]); + }); + + it('should fill array text (base case)', function() { + var calcTrace = _calc(Lib.extendFlat({}, base, { + lon: [10, 20, 30], + lat: [20, 30, 10], + text: ['A', 'B', 'C'] + })); + + expect(calcTrace).toEqual([ + { lonlat: [10, 20], tx: 'A' }, + { lonlat: [20, 30], tx: 'B' }, + { lonlat: [30, 10], tx: 'C' } + ]); + }); + + it('should fill array text (invalid entry case)', function() { + var calcTrace = _calc(Lib.extendFlat({}, base, { + lon: [10, 20, 30], + lat: [20, 30, 10], + text: ['A', 'B', null] + })); + + expect(calcTrace).toEqual([ + { lonlat: [10, 20], tx: 'A' }, + { lonlat: [20, 30], tx: 'B' }, + { lonlat: [30, 10], tx: '' } + ]); + }); + + it('should fill array marker attributes (base case)', function() { + var calcTrace = _calc(Lib.extendFlat({}, base, { + lon: [10, 20, null, 30], + lat: [20, 30, null, 10], + marker: { + color: ['red', 'blue', 'green', 'yellow'], + size: [10, 20, 8, 10] + } + })); + + expect(calcTrace).toEqual([ + { lonlat: [10, 20], mc: 'red', ms: 10, mcc: 'red', mrc: 5 }, + { lonlat: [20, 30], mc: 'blue', ms: 20, mcc: 'blue', mrc: 10, gapAfter: true }, + { lonlat: [30, 10], mc: 'yellow', ms: 10, mcc: 'yellow', mrc: 5 } + ]); + }); + + it('should fill array marker attributes (invalid scale case)', function() { + var calcTrace = _calc(Lib.extendFlat({}, base, { + lon: [10, 20, null, 30], + lat: [20, 30, null, 10], + marker: { + color: [0, null, 5, 10], + size: [10, NaN, 8, 10], + colorscale: [ + [0, 'blue'], [0.5, 'red'], [1, 'green'] + ] + } + })); + + expect(calcTrace).toEqual([ + { lonlat: [10, 20], mc: 0, ms: 10, mcc: 'rgb(0, 0, 255)', mrc: 5 }, + { lonlat: [20, 30], mc: null, ms: NaN, mcc: '#444', mrc: 0, gapAfter: true }, + { lonlat: [30, 10], mc: 10, ms: 10, mcc: 'rgb(0, 128, 0)', mrc: 5 } + ]); + }); + + it('should fill marker attributes (symbol case)', function() { + var calcTrace = _calc(Lib.extendFlat({}, base, { + lon: [10, 20, null, 30], + lat: [20, 30, null, 10], + marker: { + symbol: ['monument', 'music', 'harbor', null] + } + })); + + expect(calcTrace).toEqual([ + { lonlat: [10, 20], mx: 'monument' }, + { lonlat: [20, 30], mx: 'music', gapAfter: true }, + { lonlat: [30, 10], mx: 'circle' } + ]); + }); +}); + +describe('scattermapbox convert', function() { + 'use strict'; + + function _convert(trace) { + var gd = { data: [trace] }; + + Plots.supplyDefaults(gd); + + var fullTrace = gd._fullData[0]; + var calcTrace = ScatterMapbox.calc(gd, fullTrace); + calcTrace[0].trace = fullTrace; + + return convert(calcTrace); + } + + var base = { + type: 'scattermapbox', + lon: [10, '20', 30, 20, null, 20, 10], + lat: [20, 20, '10', null, 10, 10, 20] + }; + + it('for markers + circle bubbles traces, should', function() { + var opts = _convert(Lib.extendFlat({}, base, { + mode: 'markers', + marker: { + symbol: 'circle', + size: [10, 20, null, 10, '10'], + color: [10, null, '30', 20, 10] + } + })); + + assertVisibility(opts, ['none', 'none', 'visible', 'none']); + + expect(opts.circle.paint['circle-color']).toEqual({ + property: 'circle-color', + stops: [ + [0, 'rgb(220, 220, 220)'], [1, '#444'], [2, 'rgb(178, 10, 28)'] + ] + }, 'have correct circle-color stops'); + + expect(opts.circle.paint['circle-radius']).toEqual({ + property: 'circle-radius', + stops: [ [0, 5], [1, 10], [2, 0] ] + }, 'have correct circle-radius stops'); + + var circleProps = opts.circle.geojson.features.map(function(f) { + return f.properties; + }); + + // N.B repeated values have same geojson props + expect(circleProps).toEqual([ + { 'circle-color': 0, 'circle-radius': 0 }, + { 'circle-color': 1, 'circle-radius': 1 }, + { 'circle-color': 2, 'circle-radius': 2 }, + { 'circle-color': 1, 'circle-radius': 2 }, + { 'circle-color': 1, 'circle-radius': 2 } + ], 'have correct geojson feature properties'); + }); + + it('fill + markers + lines traces, should', function() { + var opts = _convert(Lib.extendFlat({}, base, { + mode: 'markers+lines', + marker: { symbol: 'circle' }, + fill: 'toself' + })); + + assertVisibility(opts, ['visible', 'visible', 'visible', 'none']); + + var lineCoords = [[ + [10, 20], [20, 20], [30, 10] + ], [ + [20, 10], [10, 20] + ]]; + + expect(opts.fill.geojson.coordinates).toEqual(lineCoords, 'have correct fill coords'); + expect(opts.line.geojson.coordinates).toEqual(lineCoords, 'have correct line coords'); + + var circleCoords = opts.circle.geojson.features.map(function(f) { + return f.geometry.coordinates; + }); + + expect(circleCoords).toEqual([ + [10, 20], [20, 20], [30, 10], [20, 10], [10, 20] + ], 'have correct circle coords'); + }); + + it('for markers + non-circle traces, should', function() { + var opts = _convert(Lib.extendFlat({}, base, { + mode: 'markers', + marker: { symbol: 'monument' } + })); + + assertVisibility(opts, ['none', 'none', 'none', 'visible']); + + var symbolProps = opts.symbol.geojson.features.map(function(f) { + return [f.properties.symbol, f.properties.text]; + }); + + var expected = opts.symbol.geojson.features.map(function() { + return ['monument', '']; + }); + + expect(symbolProps).toEqual(expected, 'have correct geojson properties'); + }); + + it('for text + lines traces, should', function() { + var opts = _convert(Lib.extendFlat({}, base, { + mode: 'lines+text', + connectgaps: true, + text: ['A', 'B', 'C', 'D', 'E', 'F'] + })); + + assertVisibility(opts, ['none', 'visible', 'none', 'visible']); + + var lineCoords = [[ + [10, 20], [20, 20], [30, 10], [20, 10], [10, 20] + ]]; + + expect(opts.line.geojson.coordinates).toEqual(lineCoords, 'have correct line coords'); + + var actualText = opts.symbol.geojson.features.map(function(f) { + return f.properties.text; + }); + + expect(actualText).toEqual(['A', 'B', 'C', 'F', '']); + }); + + it('should correctly convert \'textposition\' to \'text-anchor\' and \'text-offset\'', function() { + var specs = { + 'top left': ['top-right', [-0.65, -1.65]], + 'top center': ['top', [0, -1.65]], + 'top right': ['top-left', [0.65, -1.65]], + 'middle left': ['right', [-0.65, 0]], + 'middle center': ['center', [0, 0]], + 'middle right': ['left', [0.65, 0]], + 'bottom left': ['bottom-right', [-0.65, 1.65]], + 'bottom center': ['bottom', [0, 1.65]], + 'bottom right': ['bottom-left', [0.65, 1.65]] + }; + + Object.keys(specs).forEach(function(k) { + var spec = specs[k]; + + var opts = _convert(Lib.extendFlat({}, base, { + textposition: k, + mode: 'text+markers', + marker: { size: 15 }, + text: ['A', 'B', 'C'] + })); + + expect([ + opts.symbol.layout['text-anchor'], + opts.symbol.layout['text-offset'] + ]).toEqual(spec, '(case ' + k + ')'); + }); + }); + + function assertVisibility(opts, expectations) { + var actual = ['fill', 'line', 'circle', 'symbol'].map(function(l) { + return opts[l].layout.visibility; + }); + + var msg = 'set layer visibility properly'; + + expect(actual).toEqual(expectations, msg); + } +}); + +describe('scattermapbox hover', function() { + 'use strict'; + + var hoverPoints = ScatterMapbox.hoverPoints; + + var gd; + + beforeAll(function(done) { + jasmine.addMatchers(customMatchers); + + gd = createGraphDiv(); + + var data = [{ + type: 'scattermapbox', + lon: [10, 20, 30], + lat: [10, 20, 30], + text: ['A', 'B', 'C'] + }]; + + Plotly.plot(gd, data, { autosize: true }).then(done); + }); + + afterAll(function() { + Plotly.purge(gd); + destroyGraphDiv(); + }); + + function getPointData(gd) { + var cd = gd.calcdata, + mapbox = gd._fullLayout.mapbox._subplot; + + return { + index: false, + distance: 20, + cd: cd[0], + trace: cd[0][0].trace, + xa: mapbox.xaxis, + ya: mapbox.yaxis + }; + } + + it('should generate hover label info (base case)', function() { + var xval = 11, + yval = 11; + + var out = hoverPoints(getPointData(gd), xval, yval)[0]; + + expect(out.index).toEqual(0); + expect([out.x0, out.x1, out.y0, out.y1]).toBeCloseToArray([ + 444.444, 446.444, 105.410, 107.410 + ]); + expect(out.extraText).toEqual('(10°, 10°)
A'); + expect(out.color).toEqual('#1f77b4'); + }); + + it('should generate hover label info (positive winding case)', function() { + var xval = 11 + 720, + yval = 11; + + var out = hoverPoints(getPointData(gd), xval, yval)[0]; + + expect(out.index).toEqual(0); + expect([out.x0, out.x1, out.y0, out.y1]).toBeCloseToArray([ + 2492.444, 2494.444, 105.410, 107.410 + ]); + expect(out.extraText).toEqual('(10°, 10°)
A'); + expect(out.color).toEqual('#1f77b4'); + }); + + it('should generate hover label info (negative winding case)', function() { + var xval = 11 - 1080, + yval = 11; + + var out = hoverPoints(getPointData(gd), xval, yval)[0]; + + expect(out.index).toEqual(0); + expect([out.x0, out.x1, out.y0, out.y1]).toBeCloseToArray([ + -2627.555, -2625.555, 105.410, 107.410 + ]); + expect(out.extraText).toEqual('(10°, 10°)
A'); + expect(out.color).toEqual('#1f77b4'); + }); + + it('should generate hover label info (hoverinfo: \'lon\' case)', function(done) { + Plotly.restyle(gd, 'hoverinfo', 'lon').then(function() { + var xval = 11, + yval = 11; + + var out = hoverPoints(getPointData(gd), xval, yval)[0]; + + expect(out.extraText).toEqual('lon: 10°'); + done(); + }); + }); + + it('should generate hover label info (hoverinfo: \'lat\' case)', function(done) { + Plotly.restyle(gd, 'hoverinfo', 'lat').then(function() { + var xval = 11, + yval = 11; + + var out = hoverPoints(getPointData(gd), xval, yval)[0]; + + expect(out.extraText).toEqual('lat: 10°'); + done(); + }); + }); + + it('should generate hover label info (hoverinfo: \'text\' case)', function(done) { + Plotly.restyle(gd, 'hoverinfo', 'text').then(function() { + var xval = 11, + yval = 11; + + var out = hoverPoints(getPointData(gd), xval, yval)[0]; + + expect(out.extraText).toEqual('A'); + done(); + }); + }); +});