diff --git a/lib/index.js b/lib/index.js index c16212d23ba..ff568c877ac 100644 --- a/lib/index.js +++ b/lib/index.js @@ -56,7 +56,8 @@ Plotly.register([ // Plotly.register([ require('./filter'), - require('./groupby') + require('./groupby'), + require('./sort') ]); // components diff --git a/lib/sort.js b/lib/sort.js new file mode 100644 index 00000000000..37550cf94dd --- /dev/null +++ b/lib/sort.js @@ -0,0 +1,11 @@ +/** +* Copyright 2012-2017, 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 = require('../src/transforms/sort'); diff --git a/src/lib/index.js b/src/lib/index.js index 21ac36e6668..10b3226ac77 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -344,6 +344,29 @@ lib.mergeArray = function(traceAttr, cd, cdAttr) { } }; +/** Returns target as set by 'target' transform attribute + * + * @param {object} trace : full trace object + * @param {object} transformOpts : transform option object + * - target (string} : + * either an attribute string referencing an array in the trace object, or + * a set array. + * + * @return {array or false} : the target array (NOT a copy!!) or false if invalid + */ +lib.getTargetArray = function(trace, transformOpts) { + var target = transformOpts.target; + + if(typeof target === 'string' && target) { + var array = lib.nestedProperty(trace, target).get(); + return Array.isArray(array) ? array : false; + } else if(Array.isArray(target)) { + return target; + } + + return false; +}; + /** * modified version of jQuery's extend to strip out private objs and functions, * and cut arrays down to first or 1 elements diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index a362ff0e015..2317a4f9ebf 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -29,13 +29,13 @@ var ONEMIN = constants.ONEMIN; var ONESEC = constants.ONESEC; var BADNUM = constants.BADNUM; - var axes = module.exports = {}; axes.layoutAttributes = require('./layout_attributes'); axes.supplyLayoutDefaults = require('./layout_defaults'); axes.setConvert = require('./set_convert'); +var autoType = require('./axis_autotype'); var axisIds = require('./axis_ids'); axes.id2name = axisIds.id2name; @@ -45,7 +45,6 @@ axes.listIds = axisIds.listIds; axes.getFromId = axisIds.getFromId; axes.getFromTrace = axisIds.getFromTrace; - /* * find the list of possible axes to reference with an xref or yref attribute * and coerce it to that list @@ -130,6 +129,48 @@ axes.coercePosition = function(containerOut, gd, coerce, axRef, attr, dflt) { containerOut[attr] = isNumeric(pos) ? Number(pos) : dflt; }; +axes.getDataToCoordFunc = function(gd, trace, target, targetArray) { + var ax; + + // If target points to an axis, use the type we already have for that + // axis to find the data type. Otherwise use the values to autotype. + var d2cTarget = (target === 'x' || target === 'y' || target === 'z') ? + target : + targetArray; + + // In the case of an array target, make a mock data array + // and call supplyDefaults to the data type and + // setup the data-to-calc method. + if(Array.isArray(d2cTarget)) { + ax = { + type: autoType(targetArray), + _categories: [] + }; + axes.setConvert(ax); + + // build up ax._categories (usually done during ax.makeCalcdata() + if(ax.type === 'category') { + for(var i = 0; i < targetArray.length; i++) { + ax.d2c(targetArray[i]); + } + } + } else { + ax = axes.getFromTrace(gd, trace, d2cTarget); + } + + // if 'target' has corresponding axis + // -> use setConvert method + if(ax) return ax.d2c; + + // special case for 'ids' + // -> cast to String + if(d2cTarget === 'ids') return function(v) { return String(v); }; + + // otherwise (e.g. numeric-array of 'marker.color' or 'marker.size') + // -> cast to Number + return function(v) { return +v; }; +}; + // empty out types for all axes containing these traces // so we auto-set them again axes.clearTypes = function(gd, traces) { diff --git a/src/transforms/filter.js b/src/transforms/filter.js index 1b5b0ab5faf..2a2a3182d2a 100644 --- a/src/transforms/filter.js +++ b/src/transforms/filter.js @@ -11,9 +11,7 @@ var Lib = require('../lib'); var Registry = require('../registry'); var PlotSchema = require('../plot_api/plot_schema'); -var axisIds = require('../plots/cartesian/axis_ids'); -var autoType = require('../plots/cartesian/axis_autotype'); -var setConvert = require('../plots/cartesian/set_convert'); +var Axes = require('../plots/cartesian/axes'); var COMPARISON_OPS = ['=', '!=', '<', '>=', '>', '<=']; var INTERVAL_OPS = ['[]', '()', '[)', '(]', '][', ')(', '](', ')[']; @@ -144,12 +142,11 @@ exports.supplyDefaults = function(transformIn) { exports.calcTransform = function(gd, trace, opts) { if(!opts.enabled) return; - var target = opts.target, - filterArray = getFilterArray(trace, target), - len = filterArray.length; - - if(!len) return; + var targetArray = Lib.getTargetArray(trace, opts); + if(!targetArray) return; + var target = opts.target; + var len = targetArray.length; var targetCalendar = opts.targetcalendar; // even if you provide targetcalendar, if target is a string and there @@ -159,13 +156,8 @@ exports.calcTransform = function(gd, trace, opts) { if(attrTargetCalendar) targetCalendar = attrTargetCalendar; } - // if target points to an axis, use the type we already have for that - // axis to find the data type. Otherwise use the values to autotype. - var d2cTarget = (target === 'x' || target === 'y' || target === 'z') ? - target : filterArray; - - var dataToCoord = getDataToCoordFunc(gd, trace, d2cTarget); - var filterFunc = getFilterFunc(opts, dataToCoord, targetCalendar); + var d2c = Axes.getDataToCoordFunc(gd, trace, target, targetArray); + var filterFunc = getFilterFunc(opts, d2c, targetCalendar); var arrayAttrs = PlotSchema.findArrayAttributes(trace); var originalArrays = {}; @@ -203,60 +195,11 @@ exports.calcTransform = function(gd, trace, opts) { // loop through filter array, fill trace arrays if passed for(var i = 0; i < len; i++) { - var passed = filterFunc(filterArray[i]); + var passed = filterFunc(targetArray[i]); if(passed) forAllAttrs(fillFn, i); } }; -function getFilterArray(trace, target) { - if(typeof target === 'string' && target) { - var array = Lib.nestedProperty(trace, target).get(); - - return Array.isArray(array) ? array : []; - } - else if(Array.isArray(target)) return target.slice(); - - return false; -} - -function getDataToCoordFunc(gd, trace, target) { - var ax; - - // In the case of an array target, make a mock data array - // and call supplyDefaults to the data type and - // setup the data-to-calc method. - if(Array.isArray(target)) { - ax = { - type: autoType(target), - _categories: [] - }; - - setConvert(ax); - - if(ax.type === 'category') { - // build up ax._categories (usually done during ax.makeCalcdata() - for(var i = 0; i < target.length; i++) { - ax.d2c(target[i]); - } - } - } - else { - ax = axisIds.getFromTrace(gd, trace, target); - } - - // if 'target' has corresponding axis - // -> use setConvert method - if(ax) return ax.d2c; - - // special case for 'ids' - // -> cast to String - if(target === 'ids') return function(v) { return String(v); }; - - // otherwise (e.g. numeric-array of 'marker.color' or 'marker.size') - // -> cast to Number - return function(v) { return +v; }; -} - function getFilterFunc(opts, d2c, targetCalendar) { var operation = opts.operation, value = opts.value, diff --git a/src/transforms/sort.js b/src/transforms/sort.js new file mode 100644 index 00000000000..97fbec17b3e --- /dev/null +++ b/src/transforms/sort.js @@ -0,0 +1,133 @@ +/** +* Copyright 2012-2017, 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 PlotSchema = require('../plot_api/plot_schema'); +var Axes = require('../plots/cartesian/axes'); + +exports.moduleType = 'transform'; + +exports.name = 'sort'; + +exports.attributes = { + enabled: { + valType: 'boolean', + dflt: true, + description: [ + 'Determines whether this sort transform is enabled or disabled.' + ].join(' ') + }, + target: { + valType: 'string', + strict: true, + noBlank: true, + arrayOk: true, + dflt: 'x', + description: [ + 'Sets the target by which the sort transform is applied.', + + 'If a string, *target* is assumed to be a reference to a data array', + 'in the parent trace object.', + 'To sort about nested variables, use *.* to access them.', + 'For example, set `target` to *marker.size* to sort', + 'about the marker size array.', + + 'If an array, *target* is then the data array by which', + 'the sort transform is applied.' + ].join(' ') + }, + order: { + valType: 'enumerated', + values: ['ascending', 'descending'], + dflt: 'ascending', + description: [ + 'Sets the sort transform order.' + ].join(' ') + } +}; + +exports.supplyDefaults = function(transformIn) { + var transformOut = {}; + + function coerce(attr, dflt) { + return Lib.coerce(transformIn, transformOut, exports.attributes, attr, dflt); + } + + var enabled = coerce('enabled'); + + if(enabled) { + coerce('target'); + coerce('order'); + } + + return transformOut; +}; + +exports.calcTransform = function(gd, trace, opts) { + if(!opts.enabled) return; + + var targetArray = Lib.getTargetArray(trace, opts); + if(!targetArray) return; + + var target = opts.target; + var len = targetArray.length; + var arrayAttrs = PlotSchema.findArrayAttributes(trace); + var d2c = Axes.getDataToCoordFunc(gd, trace, target, targetArray); + var indices = getIndices(opts, targetArray, d2c); + + for(var i = 0; i < arrayAttrs.length; i++) { + var np = Lib.nestedProperty(trace, arrayAttrs[i]); + var arrayOld = np.get(); + var arrayNew = new Array(len); + + for(var j = 0; j < len; j++) { + arrayNew[j] = arrayOld[indices[j]]; + } + + np.set(arrayNew); + } +}; + +function getIndices(opts, targetArray, d2c) { + var len = targetArray.length; + var indices = new Array(len); + + var sortedArray = targetArray + .slice() + .sort(getSortFunc(opts, d2c)); + + for(var i = 0; i < len; i++) { + var vTarget = targetArray[i]; + + for(var j = 0; j < len; j++) { + var vSorted = sortedArray[j]; + + if(vTarget === vSorted) { + indices[j] = i; + + // clear sortedArray item to get correct + // index of duplicate items (if any) + sortedArray[j] = null; + break; + } + } + } + + return indices; +} + +function getSortFunc(opts, d2c) { + switch(opts.order) { + case 'ascending': + return function(a, b) { return d2c(a) - d2c(b); }; + case 'descending': + return function(a, b) { return d2c(b) - d2c(a); }; + } +} diff --git a/test/jasmine/tests/transform_sort_test.js b/test/jasmine/tests/transform_sort_test.js new file mode 100644 index 00000000000..6125eeadde3 --- /dev/null +++ b/test/jasmine/tests/transform_sort_test.js @@ -0,0 +1,343 @@ +var Plotly = require('@lib/index'); +var Plots = require('@src/plots/plots'); +var Lib = require('@src/lib'); + +var d3 = require('d3'); +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var fail = require('../assets/fail_test'); +var mouseEvent = require('../assets/mouse_event'); + +describe('Test sort transform defaults:', function() { + function _supply(trace, layout) { + layout = layout || {}; + return Plots.supplyTraceDefaults(trace, 0, layout); + } + + it('should coerce all attributes', function() { + var out = _supply({ + x: [1, 2, 3], + y: [0, 2, 1], + transforms: [{ + type: 'sort', + target: 'marker.size', + order: 'descending' + }] + }); + + expect(out.transforms[0].type).toEqual('sort'); + expect(out.transforms[0].target).toEqual('marker.size'); + expect(out.transforms[0].order).toEqual('descending'); + expect(out.transforms[0].enabled).toBe(true); + }); + + it('should skip unsettable attribute when `enabled: false`', function() { + var out = _supply({ + x: [1, 2, 3], + y: [0, 2, 1], + transforms: [{ + type: 'sort', + enabled: false, + target: 'marker.size', + order: 'descending' + }] + }); + + expect(out.transforms[0].type).toEqual('sort'); + expect(out.transforms[0].target).toBeUndefined(); + expect(out.transforms[0].order).toBeUndefined(); + expect(out.transforms[0].enabled).toBe(false); + }); +}); + +describe('Test sort transform calc:', function() { + var base = { + x: [-2, -1, -2, 0, 1, 3, 1], + y: [1, 2, 3, 1, 2, 3, 1], + ids: ['n0', 'n1', 'n2', 'z', 'p1', 'p2', 'p3'], + marker: { + color: [0.1, 0.2, 0.3, 0.1, 0.2, 0.3, 0.4], + size: [10, 20, 5, 1, 6, 0, 10] + }, + transforms: [{ type: 'sort' }] + }; + + function extend(update) { + return Lib.extendDeep({}, base, update); + } + + function calcDatatoTrace(calcTrace) { + return calcTrace[0].trace; + } + + function _transform(data, layout) { + var gd = { + data: data, + layout: layout || {} + }; + + Plots.supplyDefaults(gd); + Plots.doCalcdata(gd); + + return gd.calcdata.map(calcDatatoTrace); + } + + it('should sort all array attributes (ascending case)', function() { + var out = _transform([extend({})]); + + expect(out[0].x).toEqual([-2, -2, -1, 0, 1, 1, 3]); + expect(out[0].y).toEqual([1, 3, 2, 1, 2, 1, 3]); + expect(out[0].ids).toEqual(['n0', 'n2', 'n1', 'z', 'p1', 'p3', 'p2']); + expect(out[0].marker.color).toEqual([0.1, 0.3, 0.2, 0.1, 0.2, 0.4, 0.3]); + expect(out[0].marker.size).toEqual([10, 5, 20, 1, 6, 10, 0]); + }); + + it('should sort all array attributes (descending case)', function() { + var out = _transform([extend({ + transforms: [{ + order: 'descending' + }] + })]); + + expect(out[0].x).toEqual([3, 1, 1, 0, -1, -2, -2]); + expect(out[0].y).toEqual([3, 2, 1, 1, 2, 1, 3]); + expect(out[0].ids).toEqual(['p2', 'p1', 'p3', 'z', 'n1', 'n0', 'n2']); + expect(out[0].marker.color).toEqual([0.3, 0.2, 0.4, 0.1, 0.2, 0.1, 0.3]); + expect(out[0].marker.size).toEqual([0, 6, 10, 1, 20, 10, 5]); + }); + + it('should sort via nested targets', function() { + var out = _transform([extend({ + transforms: [{ + target: 'marker.size', + order: 'descending' + }] + })]); + + expect(out[0].x).toEqual([-1, -2, 1, 1, -2, 0, 3]); + expect(out[0].y).toEqual([2, 1, 1, 2, 3, 1, 3]); + expect(out[0].ids).toEqual(['n1', 'n0', 'p3', 'p1', 'n2', 'z', 'p2']); + expect(out[0].marker.color).toEqual([0.2, 0.1, 0.4, 0.2, 0.3, 0.1, 0.3]); + expect(out[0].marker.size).toEqual([20, 10, 10, 6, 5, 1, 0]); + }); + + it('should sort via dates targets', function() { + var out = _transform([{ + x: ['2015-07-20', '2016-12-02', '2016-09-01', '2016-10-21', '2016-10-20'], + y: [0, 1, 2, 3, 4, 5], + transforms: [{ type: 'sort' }] + }]); + + expect(out[0].x).toEqual([ + '2015-07-20', '2016-09-01', '2016-10-20', '2016-10-21', '2016-12-02' + ]); + expect(out[0].y).toEqual([0, 2, 4, 3, 1]); + }); + + it('should sort via custom targets', function() { + var out = _transform([extend({ + transforms: [{ + target: [10, 20, 30, 10, 20, 30, 0] + }] + })]); + + expect(out[0].x).toEqual([1, -2, 0, -1, 1, -2, 3]); + expect(out[0].y).toEqual([1, 1, 1, 2, 2, 3, 3]); + expect(out[0].ids).toEqual(['p3', 'n0', 'z', 'n1', 'p1', 'n2', 'p2']); + expect(out[0].marker.color).toEqual([0.4, 0.1, 0.1, 0.2, 0.2, 0.3, 0.3]); + expect(out[0].marker.size).toEqual([10, 10, 1, 20, 6, 5, 0]); + }); + + it('should truncate transformed arrays to target array length (short target case)', function() { + var out = _transform([ + extend({ + transforms: [{ + order: 'descending', + target: [0, 1] + }] + } + ), extend({ + text: ['A', 'B'], + transforms: [{ target: 'text' }] + })]); + + expect(out[0].x).toEqual([-1, -2]); + expect(out[0].y).toEqual([2, 1]); + expect(out[0].ids).toEqual(['n1', 'n0']); + expect(out[0].marker.color).toEqual([0.2, 0.1]); + expect(out[0].marker.size).toEqual([20, 10]); + + expect(out[1].x).toEqual([-2, -1]); + expect(out[1].y).toEqual([1, 2]); + expect(out[1].ids).toEqual(['n0', 'n1']); + expect(out[1].marker.color).toEqual([0.1, 0.2]); + expect(out[1].marker.size).toEqual([10, 20]); + }); + + it('should truncate transformed arrays to target array length (long target case)', function() { + var out = _transform([ + extend({ + transforms: [{ + order: 'descending', + target: [0, 1, 2, 0, 1, 2, 3, 0, 1, 2, 3] + }] + } + ), extend({ + text: ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I'], + transforms: [{ target: 'text' }] + })]); + + expect(out[0].x).toEqual([1, undefined, -2, 3, undefined, -1, 1, undefined, -2, 0, undefined]); + expect(out[0].y).toEqual([1, undefined, 3, 3, undefined, 2, 2, undefined, 1, 1, undefined]); + expect(out[0].ids).toEqual(['p3', undefined, 'n2', 'p2', undefined, 'n1', 'p1', undefined, 'n0', 'z', undefined]); + expect(out[0].marker.color).toEqual([0.4, undefined, 0.3, 0.3, undefined, 0.2, 0.2, undefined, 0.1, 0.1, undefined]); + expect(out[0].marker.size).toEqual([10, undefined, 5, 0, undefined, 20, 6, undefined, 10, 1, undefined]); + + expect(out[1].x).toEqual([-2, -1, -2, 0, 1, 3, 1, undefined, undefined]); + expect(out[1].y).toEqual([1, 2, 3, 1, 2, 3, 1, undefined, undefined]); + expect(out[1].ids).toEqual(['n0', 'n1', 'n2', 'z', 'p1', 'p2', 'p3', undefined, undefined]); + expect(out[1].marker.color).toEqual([0.1, 0.2, 0.3, 0.1, 0.2, 0.3, 0.4, undefined, undefined]); + expect(out[1].marker.size).toEqual([10, 20, 5, 1, 6, 0, 10, undefined, undefined]); + }); +}); + +describe('Test sort transform interactions:', function() { + afterEach(destroyGraphDiv); + + function _assertFirst(p) { + var parts = d3.select('.point').attr('d').split(',').slice(0, 3).join(','); + expect(parts).toEqual(p); + } + + it('should respond to restyle calls', function(done) { + Plotly.plot(createGraphDiv(), [{ + x: [-2, -1, -2, 0, 1, 3, 1], + y: [1, 2, 3, 1, 2, 3, 1], + marker: { + size: [10, 20, 5, 1, 6, 0, 10] + }, + transforms: [{ + type: 'sort', + target: 'marker.size', + }] + }]) + .then(function(gd) { + _assertFirst('M0,0A0,0 0 1'); + + return Plotly.restyle(gd, 'transforms[0].order', 'descending'); + }) + .then(function(gd) { + _assertFirst('M10,0A10,10 0 1'); + + return Plotly.restyle(gd, 'transforms[0].enabled', false); + }) + .then(function(gd) { + _assertFirst('M5,0A5,5 0 1'); + + return Plotly.restyle(gd, 'transforms[0].enabled', true); + }) + .then(function() { + _assertFirst('M10,0A10,10 0 1'); + }) + .catch(fail) + .then(done); + }); + + it('does not preserve hover/click `pointNumber` value', function(done) { + var gd = createGraphDiv(); + + function getPxPos(gd, id) { + var trace = gd.data[0]; + var fullLayout = gd._fullLayout; + var index = trace.ids.indexOf(id); + + return [ + fullLayout.xaxis.d2p(trace.x[index]), + fullLayout.yaxis.d2p(trace.y[index]) + ]; + } + + function hover(gd, id) { + return new Promise(function(resolve) { + gd.once('plotly_hover', function(eventData) { + resolve(eventData); + }); + + var pos = getPxPos(gd, id); + mouseEvent('mousemove', pos[0], pos[1]); + }); + } + + function click(gd, id) { + return new Promise(function(resolve) { + gd.once('plotly_click', function(eventData) { + resolve(eventData); + }); + + var pos = getPxPos(gd, id); + mouseEvent('mousemove', pos[0], pos[1]); + mouseEvent('mousedown', pos[0], pos[1]); + mouseEvent('mouseup', pos[0], pos[1]); + }); + } + + function wait() { + return new Promise(function(resolve) { + setTimeout(resolve, 60); + }); + } + + function assertPt(eventData, x, y, pointNumber, id) { + var pt = eventData.points[0]; + + expect(pt.x).toEqual(x, 'x'); + expect(pt.y).toEqual(y, 'y'); + expect(pt.pointNumber).toEqual(pointNumber, 'pointNumber'); + expect(pt.fullData.ids[pt.pointNumber]).toEqual(id, 'id'); + } + + Plotly.plot(gd, [{ + mode: 'markers', + x: [-2, -1, -2, 0, 1, 3, 1], + y: [1, 2, 3, 1, 2, 3, 1], + ids: ['A', 'B', 'C', 'D', 'E', 'F', 'G'], + marker: { + size: [10, 20, 5, 1, 6, 0, 10] + }, + transforms: [{ + enabled: false, + type: 'sort', + target: 'marker.size', + }] + }], { + width: 500, + height: 500, + margin: {l: 0, t: 0, r: 0, b: 0}, + hovermode: 'closest' + }) + .then(function() { return hover(gd, 'D'); }) + .then(function(eventData) { + assertPt(eventData, 0, 1, 3, 'D'); + }) + .then(wait) + .then(function() { return click(gd, 'G'); }) + .then(function(eventData) { + assertPt(eventData, 1, 1, 6, 'G'); + }) + .then(wait) + .then(function() { + return Plotly.restyle(gd, 'transforms[0].enabled', true); + }) + .then(function() { return hover(gd, 'D'); }) + .then(function(eventData) { + assertPt(eventData, 0, 1, 1, 'D'); + }) + .then(wait) + .then(function() { return click(gd, 'G'); }) + .then(function(eventData) { + assertPt(eventData, 1, 1, 5, 'G'); + }) + .catch(fail) + .then(done); + }); +});