diff --git a/draftlogs/5827_add.md b/draftlogs/5827_add.md new file mode 100644 index 00000000000..9ff782d081c --- /dev/null +++ b/draftlogs/5827_add.md @@ -0,0 +1,2 @@ + - Add clustering options to `scattermapbox` [[#5827](https://github.com/plotly/plotly.js/pull/5827)], + with thanks to @elben10 for the contribution! diff --git a/src/traces/scattermapbox/attributes.js b/src/traces/scattermapbox/attributes.js index ab34f37d566..3404f2f9c35 100644 --- a/src/traces/scattermapbox/attributes.js +++ b/src/traces/scattermapbox/attributes.js @@ -10,6 +10,7 @@ var colorScaleAttrs = require('../../components/colorscale/attributes'); var extendFlat = require('../../lib/extend').extendFlat; var overrideAll = require('../../plot_api/edit_types').overrideAll; +var mapboxLayoutAtributes = require('../../plots/mapbox/layout_attributes'); var lineAttrs = scatterGeoAttrs.line; var markerAttrs = scatterGeoAttrs.marker; @@ -18,6 +19,50 @@ module.exports = overrideAll({ lon: scatterGeoAttrs.lon, lat: scatterGeoAttrs.lat, + cluster: { + enabled: { + valType: 'boolean', + description: 'Determines whether clustering is enabled or disabled.' + }, + maxzoom: extendFlat({}, mapboxLayoutAtributes.layers.maxzoom, { + description: [ + 'Sets the maximum zoom level.', + 'At zoom levels equal to or greater than this, points will never be clustered.' + ].join(' ') + }), + step: { + valType: 'number', + arrayOk: true, + dflt: -1, + min: -1, + description: [ + 'Sets how many points it takes to create a cluster or advance to the next cluster step.', + 'Use this in conjunction with arrays for `size` and / or `color`.', + 'If an integer, steps start at multiples of this number.', + 'If an array, each step extends from the given value until one less than the next value.' + ].join(' ') + }, + size: { + valType: 'number', + arrayOk: true, + dflt: 20, + min: 0, + description: [ + 'Sets the size for each cluster step.' + ].join(' ') + }, + color: { + valType: 'color', + arrayOk: true, + description: [ + 'Sets the color for each cluster step.' + ].join(' ') + }, + opacity: extendFlat({}, markerAttrs.opacity, { + dflt: 1 + }) + }, + // locations // locationmode diff --git a/src/traces/scattermapbox/convert.js b/src/traces/scattermapbox/convert.js index 130db2b80af..f2a1922871e 100644 --- a/src/traces/scattermapbox/convert.js +++ b/src/traces/scattermapbox/convert.js @@ -26,11 +26,12 @@ module.exports = function convert(gd, calcTrace) { var hasText = subTypes.hasText(trace); var hasCircles = (hasMarkers && trace.marker.symbol === 'circle'); var hasSymbols = (hasMarkers && trace.marker.symbol !== 'circle'); + var hasCluster = trace.cluster && trace.cluster.enabled; - var fill = initContainer(); - var line = initContainer(); - var circle = initContainer(); - var symbol = initContainer(); + var fill = initContainer('fill'); + var line = initContainer('line'); + var circle = initContainer('circle'); + var symbol = initContainer('symbol'); var opts = { fill: fill, @@ -74,6 +75,29 @@ module.exports = function convert(gd, calcTrace) { var circleOpts = makeCircleOpts(calcTrace); circle.geojson = circleOpts.geojson; circle.layout.visibility = 'visible'; + if(hasCluster) { + circle.filter = ['!', ['has', 'point_count']]; + opts.cluster = { + type: 'circle', + filter: ['has', 'point_count'], + layout: {visibility: 'visible'}, + paint: { + 'circle-color': arrayifyAttribute(trace.cluster.color, trace.cluster.step), + 'circle-radius': arrayifyAttribute(trace.cluster.size, trace.cluster.step), + 'circle-opacity': arrayifyAttribute(trace.cluster.opacity, trace.cluster.step), + }, + }; + opts.clusterCount = { + type: 'symbol', + filter: ['has', 'point_count'], + paint: {}, + layout: { + 'text-field': '{point_count_abbreviated}', + 'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'], + 'text-size': 12 + } + }; + } Lib.extendFlat(circle.paint, { 'circle-color': circleOpts.mcc, @@ -82,6 +106,10 @@ module.exports = function convert(gd, calcTrace) { }); } + if(hasCircles && hasCluster) { + circle.filter = ['!', ['has', 'point_count']]; + } + if(hasSymbols || hasText) { symbol.geojson = makeSymbolGeoJSON(calcTrace, gd); @@ -142,10 +170,12 @@ module.exports = function convert(gd, calcTrace) { return opts; }; -function initContainer() { +function initContainer(type) { return { + type: type, geojson: geoJsonUtils.makeBlank(), layout: { visibility: 'none' }, + filter: null, paint: {} }; } @@ -200,7 +230,8 @@ function makeCircleOpts(calcTrace) { features.push({ type: 'Feature', - geometry: {type: 'Point', coordinates: lonlat}, + id: i + 1, + geometry: { type: 'Point', coordinates: lonlat }, properties: props }); } @@ -323,3 +354,17 @@ function blankFillFunc() { return ''; } function isBADNUM(lonlat) { return lonlat[0] === BADNUM; } + +function arrayifyAttribute(values, step) { + var newAttribute; + if(Lib.isArrayOrTypedArray(values) && Lib.isArrayOrTypedArray(step)) { + newAttribute = ['step', ['get', 'point_count'], values[0]]; + + for(var idx = 1; idx < values.length; idx++) { + newAttribute.push(step[idx - 1], values[idx]); + } + } else { + newAttribute = values; + } + return newAttribute; +} diff --git a/src/traces/scattermapbox/defaults.js b/src/traces/scattermapbox/defaults.js index e3b87adc3df..93010cca922 100644 --- a/src/traces/scattermapbox/defaults.js +++ b/src/traces/scattermapbox/defaults.js @@ -14,6 +14,10 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout return Lib.coerce(traceIn, traceOut, attributes, attr, dflt); } + function coerce2(attr, dflt) { + return Lib.coerce2(traceIn, traceOut, attributes, attr, dflt); + } + var len = handleLonLatDefaults(traceIn, traceOut, coerce); if(!len) { traceOut.visible = false; @@ -46,6 +50,21 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout } } + var clusterMaxzoom = coerce2('cluster.maxzoom'); + var clusterStep = coerce2('cluster.step'); + var clusterColor = coerce2('cluster.color', (traceOut.marker && traceOut.marker.color) || defaultColor); + var clusterSize = coerce2('cluster.size'); + var clusterOpacity = coerce2('cluster.opacity'); + + var clusterEnabledDflt = + clusterMaxzoom !== false || + clusterStep !== false || + clusterColor !== false || + clusterSize !== false || + clusterOpacity !== false; + + coerce('cluster.enabled', clusterEnabledDflt); + if(subTypes.hasText(traceOut)) { handleTextDefaults(traceIn, traceOut, layout, coerce, {noSelect: true}); } diff --git a/src/traces/scattermapbox/hover.js b/src/traces/scattermapbox/hover.js index a582cf4b4b2..fa2355c5529 100644 --- a/src/traces/scattermapbox/hover.js +++ b/src/traces/scattermapbox/hover.js @@ -5,6 +5,7 @@ var Lib = require('../../lib'); var getTraceColor = require('../scatter/get_trace_color'); var fillText = Lib.fillText; var BADNUM = require('../../constants/numerical').BADNUM; +var LAYER_PREFIX = require('../../plots/mapbox/constants').traceLayerPrefix; function hoverPoints(pointData, xval, yval) { var cd = pointData.cd; @@ -12,6 +13,14 @@ function hoverPoints(pointData, xval, yval) { var xa = pointData.xa; var ya = pointData.ya; var subplot = pointData.subplot; + var clusteredPointsIds = []; + var layer = LAYER_PREFIX + trace.uid + '-circle'; + var hasCluster = trace.cluster && trace.cluster.enabled; + + if(hasCluster) { + var elems = subplot.map.queryRenderedFeatures(null, {layers: [layer]}); + clusteredPointsIds = elems.map(function(elem) {return elem.id;}); + } // compute winding number about [-180, 180] globe var winding = (xval >= 0) ? @@ -25,6 +34,7 @@ function hoverPoints(pointData, xval, yval) { function distFn(d) { var lonlat = d.lonlat; if(lonlat[0] === BADNUM) return Infinity; + if(hasCluster && clusteredPointsIds.indexOf(d.i + 1) === -1) return Infinity; var lon = Lib.modHalf(lonlat[0], 360); var lat = lonlat[1]; diff --git a/src/traces/scattermapbox/plot.js b/src/traces/scattermapbox/plot.js index 21e95b36c0a..2814c7b6430 100644 --- a/src/traces/scattermapbox/plot.js +++ b/src/traces/scattermapbox/plot.js @@ -1,26 +1,35 @@ 'use strict'; +var Lib = require('../../lib'); var convert = require('./convert'); var LAYER_PREFIX = require('../../plots/mapbox/constants').traceLayerPrefix; -var ORDER = ['fill', 'line', 'circle', 'symbol']; +var ORDER = { + cluster: ['cluster', 'clusterCount', 'circle'], + nonCluster: ['fill', 'line', 'circle', 'symbol'], +}; -function ScatterMapbox(subplot, uid) { +function ScatterMapbox(subplot, uid, clusterEnabled) { this.type = 'scattermapbox'; this.subplot = subplot; this.uid = uid; + this.clusterEnabled = clusterEnabled; this.sourceIds = { fill: 'source-' + uid + '-fill', line: 'source-' + uid + '-line', circle: 'source-' + uid + '-circle', - symbol: 'source-' + uid + '-symbol' + symbol: 'source-' + uid + '-symbol', + cluster: 'source-' + uid + '-circle', + clusterCount: 'source-' + uid + '-circle', }; this.layerIds = { fill: LAYER_PREFIX + uid + '-fill', line: LAYER_PREFIX + uid + '-line', circle: LAYER_PREFIX + uid + '-circle', - symbol: LAYER_PREFIX + uid + '-symbol' + symbol: LAYER_PREFIX + uid + '-symbol', + cluster: LAYER_PREFIX + uid + '-cluster', + clusterCount: LAYER_PREFIX + uid + '-cluster-count', }; // We could merge the 'fill' source with the 'line' source and @@ -34,11 +43,20 @@ function ScatterMapbox(subplot, uid) { var proto = ScatterMapbox.prototype; -proto.addSource = function(k, opts) { - this.subplot.map.addSource(this.sourceIds[k], { +proto.addSource = function(k, opts, cluster) { + var sourceOpts = { type: 'geojson', - data: opts.geojson - }); + data: opts.geojson, + }; + + if(cluster && cluster.enabled) { + Lib.extendFlat(sourceOpts, { + cluster: true, + clusterMaxZoom: cluster.maxzoom, + }); + } + + this.subplot.map.addSource(this.sourceIds[k], sourceOpts); }; proto.setSourceData = function(k, opts) { @@ -48,56 +66,79 @@ proto.setSourceData = function(k, opts) { }; proto.addLayer = function(k, opts, below) { - this.subplot.addLayer({ - type: k, + var source = { + type: opts.type, id: this.layerIds[k], source: this.sourceIds[k], layout: opts.layout, - paint: opts.paint - }, below); + paint: opts.paint, + }; + if(opts.filter) { + source.filter = opts.filter; + } + this.subplot.addLayer(source, below); }; proto.update = function update(calcTrace) { + var trace = calcTrace[0].trace; var subplot = this.subplot; var map = subplot.map; var optsAll = convert(subplot.gd, calcTrace); var below = subplot.belowLookup['trace-' + this.uid]; var i, k, opts; + var hasCluster = !!(trace.cluster && trace.cluster.enabled); + var hadCluster = !!this.clusterEnabled; if(below !== this.below) { - for(i = ORDER.length - 1; i >= 0; i--) { - k = ORDER[i]; + var order = ORDER.nonCluster; + + for(i = order.length - 1; i >= 0; i--) { + k = order[i]; map.removeLayer(this.layerIds[k]); } - for(i = 0; i < ORDER.length; i++) { - k = ORDER[i]; + for(i = 0; i < order.length; i++) { + k = order[i]; opts = optsAll[k]; this.addLayer(k, opts, below); } this.below = below; - } - - for(i = 0; i < ORDER.length; i++) { - k = ORDER[i]; - opts = optsAll[k]; - - subplot.setOptions(this.layerIds[k], 'setLayoutProperty', opts.layout); - - if(opts.layout.visibility === 'visible') { - this.setSourceData(k, opts); - subplot.setOptions(this.layerIds[k], 'setPaintProperty', opts.paint); + } else if(hasCluster && !hadCluster) { + for(i = ORDER.nonCluster.length - 1; i >= 0; i--) { + k = ORDER.nonCluster[i]; + map.removeLayer(this.layerIds[k]); + map.removeSource(this.sourceIds[k]); + } + this.addSource('circle', optsAll.circle, trace.cluster); + for(i = 0; i < ORDER.cluster.length; i++) { + k = ORDER.cluster[i]; + opts = optsAll[k]; + this.addLayer(k, opts, below); + } + this.clusterEnabled = hasCluster; + } else if(!hasCluster && hadCluster) { + for(i = 0; i < ORDER.cluster.length; i++) { + k = ORDER.cluster[i]; + map.removeLayer(this.layerIds[k]); } + map.removeSource(this.sourceIds.circle); + for(i = 0; i < ORDER.nonCluster.length; i++) { + k = ORDER.nonCluster[i]; + opts = optsAll[k]; + this.addSource(k, opts, trace.cluster); + this.addLayer(k, opts, below); + } + this.clusterEnabled = hasCluster; } - // link ref for quick update during selections + // link ref for quick update during selections calcTrace[0].trace._glTrace = this; }; proto.dispose = function dispose() { var map = this.subplot.map; - - for(var i = ORDER.length - 1; i >= 0; i--) { - var k = ORDER[i]; + var order = this.clusterEnabled ? ORDER.cluster : ORDER.nonCluster; + for(var i = order.length - 1; i >= 0; i--) { + var k = order[i]; map.removeLayer(this.layerIds[k]); map.removeSource(this.sourceIds[k]); } @@ -105,15 +146,31 @@ proto.dispose = function dispose() { module.exports = function createScatterMapbox(subplot, calcTrace) { var trace = calcTrace[0].trace; - var scatterMapbox = new ScatterMapbox(subplot, trace.uid); + var hasCluster = trace.cluster && trace.cluster.enabled; + var scatterMapbox = new ScatterMapbox( + subplot, + trace.uid, + hasCluster + ); + var optsAll = convert(subplot.gd, calcTrace); var below = scatterMapbox.below = subplot.belowLookup['trace-' + trace.uid]; + var i, k, opts; - for(var i = 0; i < ORDER.length; i++) { - var k = ORDER[i]; - var opts = optsAll[k]; - scatterMapbox.addSource(k, opts); - scatterMapbox.addLayer(k, opts, below); + if(hasCluster) { + scatterMapbox.addSource('circle', optsAll.circle, trace.cluster); + for(i = 0; i < ORDER.cluster.length; i++) { + k = ORDER.cluster[i]; + opts = optsAll[k]; + scatterMapbox.addLayer(k, opts, below); + } + } else { + for(i = 0; i < ORDER.nonCluster.length; i++) { + k = ORDER.nonCluster[i]; + opts = optsAll[k]; + scatterMapbox.addSource(k, opts, trace.cluster); + scatterMapbox.addLayer(k, opts, below); + } } // link ref for quick update during selections diff --git a/test/image/baselines/mapbox_scattercluster.png b/test/image/baselines/mapbox_scattercluster.png new file mode 100644 index 00000000000..5431666262d Binary files /dev/null and b/test/image/baselines/mapbox_scattercluster.png differ diff --git a/test/image/baselines/pie_textpad_radial.png b/test/image/baselines/pie_textpad_radial.png index 236b381d089..20733b24acc 100644 Binary files a/test/image/baselines/pie_textpad_radial.png and b/test/image/baselines/pie_textpad_radial.png differ diff --git a/test/image/baselines/sunburst_packages_colorscale_novalue.png b/test/image/baselines/sunburst_packages_colorscale_novalue.png index bcc8b6d907e..2f09e9246aa 100644 Binary files a/test/image/baselines/sunburst_packages_colorscale_novalue.png and b/test/image/baselines/sunburst_packages_colorscale_novalue.png differ diff --git a/test/image/baselines/uniformtext_sunburst_treemap.png b/test/image/baselines/uniformtext_sunburst_treemap.png index 31adc8ab71e..d99afe65a59 100644 Binary files a/test/image/baselines/uniformtext_sunburst_treemap.png and b/test/image/baselines/uniformtext_sunburst_treemap.png differ diff --git a/test/image/mocks/mapbox_scattercluster.json b/test/image/mocks/mapbox_scattercluster.json new file mode 100644 index 00000000000..43cae72ece7 --- /dev/null +++ b/test/image/mocks/mapbox_scattercluster.json @@ -0,0 +1,254 @@ +{ + "data": [ + { + "type": "scattermapbox", + "subplot": "mapbox", + "name": "20 (20)", + "mode": "markers", + "marker": { + "size": 20, + "color": "lightgray", + "opacity": 0.5 + }, + "cluster": { + "size": 20, + "color": "yellow" + }, + "lon": [ + -73.56, + -79.38, + -123.12, + -114.07, + -113.49, + -75.69, + -63.57, + -123.36, + -97.13, + -104.61 + ], + "lat": [ + 45.5, + 43.65, + 49.28, + 51.04, + 53.54, + 45.42, + 44.64, + 48.42, + 49.89, + 50.44 + ], + "text": [ + "Montreal", + "Toronto", + "Vancouver", + "Calgary", + "Edmonton", + "Ottawa", + "Halifax", + "Victoria", + "Winnepeg", + "Regina" + ] + }, + { + "type": "scattermapbox", + "subplot": "mapbox2", + "name": "20 (10)", + "mode": "markers", + "marker": { + "size": 20 + }, + "cluster": { + "size": 10 + }, + "lon": [ + -73.56, + -79.38, + -123.12, + -114.07, + -113.49, + -75.69, + -63.57, + -123.36, + -97.13, + -104.61 + ], + "lat": [ + 45.5, + 43.65, + 49.28, + 51.04, + 53.54, + 45.42, + 44.64, + 48.42, + 49.89, + 50.44 + ], + "text": [ + "Montreal", + "Toronto", + "Vancouver", + "Calgary", + "Edmonton", + "Ottawa", + "Halifax", + "Victoria", + "Winnepeg", + "Regina" + ] + }, + { + "type": "scattermapbox", + "subplot": "mapbox3", + "name": "10 (20)", + "mode": "markers", + "marker": { + "size": 10 + }, + "cluster": { + "size": 20 + }, + "lon": [ + -73.56, + -79.38, + -123.12, + -114.07, + -113.49, + -75.69, + -63.57, + -123.36, + -97.13, + -104.61 + ], + "lat": [ + 45.5, + 43.65, + 49.28, + 51.04, + 53.54, + 45.42, + 44.64, + 48.42, + 49.89, + 50.44 + ], + "text": [ + "Montreal", + "Toronto", + "Vancouver", + "Calgary", + "Edmonton", + "Ottawa", + "Halifax", + "Victoria", + "Winnepeg", + "Regina" + ] + }, + { + "type": "scattermapbox", + "subplot": "mapbox4", + "name": "10 (10)", + "mode": "markers", + "marker": { + "size": 10 + }, + "cluster": { + "size": 10 + }, + "lon": [ + -73.56, + -79.38, + -123.12, + -114.07, + -113.49, + -75.69, + -63.57, + -123.36, + -97.13, + -104.61 + ], + "lat": [ + 45.5, + 43.65, + 49.28, + 51.04, + 53.54, + 45.42, + 44.64, + 48.42, + 49.89, + 50.44 + ], + "text": [ + "Montreal", + "Toronto", + "Vancouver", + "Calgary", + "Edmonton", + "Ottawa", + "Halifax", + "Victoria", + "Winnepeg", + "Regina" + ] + } + ], + "layout": { + "title": { + "text": "Clustering points over Canadian cities" + }, + "mapbox": { + "zoom": 2, + "style": "dark", + "center": { + "lon": -90, + "lat": 45 + }, + "domain": { + "x": [0.55, 1], + "y": [0.55, 1] + } + }, + "mapbox2": { + "zoom": 2, + "style": "light", + "center": { + "lon": -90, + "lat": 45 + }, + "domain": { + "x": [0.55, 1], + "y": [0, 0.45] + } + }, + "mapbox3": { + "zoom": 2, + "style": "light", + "center": { + "lon": -90, + "lat": 45 + }, + "domain": { + "x": [0, 0.45], + "y": [0.55, 1] + } + }, + "mapbox4": { + "zoom": 2, + "style": "dark", + "center": { + "lon": -90, + "lat": 45 + }, + "domain": { + "x": [0, 0.45], + "y": [0, 0.45] + } + }, + "height": 800, + "width": 1200 + } +} diff --git a/test/jasmine/tests/scattermapbox_test.js b/test/jasmine/tests/scattermapbox_test.js index aaeb28d0a4e..3ecf8e4d825 100644 --- a/test/jasmine/tests/scattermapbox_test.js +++ b/test/jasmine/tests/scattermapbox_test.js @@ -656,6 +656,53 @@ describe('scattermapbox convert', function() { expect(opts.line.geojson.coordinates).toEqual([], 'line coords'); expect(opts.fill.geojson.coordinates).toEqual([], 'fill coords'); }); + + it('cluster options', function() { + var opts = _convert(Lib.extendFlat({}, base, { + cluster: { + enabled: true + } + })); + + // Ensure that cluster and clusterCount options is added to options + expect(opts.cluster).toBeInstanceOf(Object); + expect(opts.clusterCount).toBeInstanceOf(Object); + + // Ensure correct type of layers + expect(opts.cluster.type).toEqual('circle'); + expect(opts.clusterCount.type).toEqual('symbol'); + }); + + it('cluster colors, sizes, opacities - array', function() { + var opts = _convert(Lib.extendFlat({}, base, { + cluster: { + enabled: true, + color: 'red', + size: 20, + opacity: 0.25 + } + })); + + expect(opts.cluster.paint['circle-color']).toEqual('red'); + expect(opts.cluster.paint['circle-radius']).toEqual(20); + expect(opts.cluster.paint['circle-opacity']).toEqual(0.25); + }); + + it('cluster colors, sizes, opacities - array', function() { + var opts = _convert(Lib.extendFlat({}, base, { + cluster: { + enabled: true, + step: [10], + color: ['red', 'green'], + size: [20, 40], + opacity: [0.25, 0.75] + } + })); + + expect(opts.cluster.paint['circle-color']).toEqual(['step', ['get', 'point_count'], 'red', 10, 'green']); + expect(opts.cluster.paint['circle-radius']).toEqual(['step', ['get', 'point_count'], 20, 10, 40]); + expect(opts.cluster.paint['circle-opacity']).toEqual(['step', ['get', 'point_count'], 0.25, 10, 0.75]); + }); }); describe('scattermapbox hover', function() { diff --git a/test/plot-schema.json b/test/plot-schema.json index 92632390cad..c32d24547c3 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -53446,6 +53446,74 @@ "editType": "calc", "valType": "string" }, + "cluster": { + "color": { + "arrayOk": true, + "description": "Sets the color for each cluster step.", + "editType": "calc", + "valType": "color" + }, + "colorsrc": { + "description": "Sets the source reference on Chart Studio Cloud for `color`.", + "editType": "none", + "valType": "string" + }, + "editType": "calc", + "enabled": { + "description": "Determines whether clustering is enabled or disabled.", + "editType": "calc", + "valType": "boolean" + }, + "maxzoom": { + "description": "Sets the maximum zoom level. At zoom levels equal to or greater than this, points will never be clustered.", + "dflt": 24, + "editType": "calc", + "max": 24, + "min": 0, + "valType": "number" + }, + "opacity": { + "arrayOk": true, + "description": "Sets the marker opacity.", + "dflt": 1, + "editType": "calc", + "max": 1, + "min": 0, + "valType": "number" + }, + "opacitysrc": { + "description": "Sets the source reference on Chart Studio Cloud for `opacity`.", + "editType": "none", + "valType": "string" + }, + "role": "object", + "size": { + "arrayOk": true, + "description": "Sets the size for each cluster step.", + "dflt": 20, + "editType": "calc", + "min": 0, + "valType": "number" + }, + "sizesrc": { + "description": "Sets the source reference on Chart Studio Cloud for `size`.", + "editType": "none", + "valType": "string" + }, + "step": { + "arrayOk": true, + "description": "Sets how many points it takes to create a cluster or advance to the next cluster step. Use this in conjunction with arrays for `size` and / or `color`. If an integer, steps start at multiples of this number. If an array, each step extends from the given value until one less than the next value.", + "dflt": -1, + "editType": "calc", + "min": -1, + "valType": "number" + }, + "stepsrc": { + "description": "Sets the source reference on Chart Studio Cloud for `step`.", + "editType": "none", + "valType": "string" + } + }, "connectgaps": { "description": "Determines whether or not gaps (i.e. {nan} or missing values) in the provided data arrays are connected.", "dflt": false,