diff --git a/.eslintrc b/.eslintrc index 185abda5ef4..b8e364b0830 100644 --- a/.eslintrc +++ b/.eslintrc @@ -14,8 +14,12 @@ "Float32Array": true, "Float64Array": true, "Uint8Array": true, + "Int8Array": true, + "Uint8ClampedArray": true, "Int16Array": true, + "Uint16Array": true, "Int32Array": true, + "Uint32Array": true, "ArrayBuffer": true, "DataView": true, "SVGElement": false diff --git a/package-lock.json b/package-lock.json index 686bada9a20..827a745121e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -763,8 +763,7 @@ "base64-arraybuffer": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz", - "integrity": "sha1-c5JncZI7Whl0etZmqlzUv5xunOg=", - "dev": true + "integrity": "sha1-c5JncZI7Whl0etZmqlzUv5xunOg=" }, "base64-js": { "version": "1.3.0", diff --git a/package.json b/package.json index f422ff42cb1..9a126d29211 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@plotly/d3-sankey": "^0.5.0", "alpha-shape": "^1.0.0", "array-range": "^1.0.1", + "base64-arraybuffer": "^0.1.5", "canvas-fit": "^1.5.0", "color-normalize": "^1.3.0", "convex-hull": "^1.0.3", diff --git a/src/lib/index.js b/src/lib/index.js index 50a1e39948b..1a95a89fd49 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -34,6 +34,7 @@ var isArrayModule = require('./is_array'); lib.isTypedArray = isArrayModule.isTypedArray; lib.isArrayOrTypedArray = isArrayModule.isArrayOrTypedArray; lib.isArray1D = isArrayModule.isArray1D; +lib.isTypedArrayEncoding = isArrayModule.isTypedArrayEncoding; var coerceModule = require('./coerce'); lib.valObjectMeta = coerceModule.valObjectMeta; diff --git a/src/lib/is_array.js b/src/lib/is_array.js index b8c5e1ae47c..5269b28879e 100644 --- a/src/lib/is_array.js +++ b/src/lib/is_array.js @@ -8,6 +8,8 @@ 'use strict'; +var isPlainObject = require('./is_plain_object'); + // IE9 fallbacks var ab = (typeof ArrayBuffer === 'undefined' || !ArrayBuffer.isView) ? @@ -38,8 +40,14 @@ function isArray1D(a) { return !isArrayOrTypedArray(a[0]); } +function isTypedArrayEncoding(a) { + return (isPlainObject(a) && + a.hasOwnProperty('dtype') && a.hasOwnProperty('value')); +} + module.exports = { isTypedArray: isTypedArray, isArrayOrTypedArray: isArrayOrTypedArray, - isArray1D: isArray1D + isArray1D: isArray1D, + isTypedArrayEncoding: isTypedArrayEncoding }; diff --git a/src/plot_api/index.js b/src/plot_api/index.js index ac81c327b05..e5cef72ba62 100644 --- a/src/plot_api/index.js +++ b/src/plot_api/index.js @@ -16,6 +16,8 @@ exports.restyle = main.restyle; exports.relayout = main.relayout; exports.redraw = main.redraw; exports.update = main.update; +exports.decode = main.decode; +exports.encode = main.encode; exports.react = main.react; exports.extendTraces = main.extendTraces; exports.prependTraces = main.prependTraces; diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 6df86a73d0c..3cab6704164 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -13,6 +13,7 @@ var d3 = require('d3'); var isNumeric = require('fast-isnumeric'); var hasHover = require('has-hover'); +var b64 = require('base64-arraybuffer'); var Lib = require('../lib'); var Events = require('../lib/events'); @@ -2192,6 +2193,181 @@ exports.update = function update(gd, traceUpdate, layoutUpdate, _traces) { }); }; + +/** + * Get TypedArray type for a given dtype string + * @param {String} dtype: Data type string + * @returns {TypedArray} + */ +function getTypedArrayTypeForDtypeString(dtype) { + if(dtype === 'int8' && typeof Int8Array !== 'undefined') { + return Int8Array; + } else if(dtype === 'uint8' && typeof Uint8Array !== 'undefined') { + return Uint8Array; + } else if(dtype === 'uint8_clamped' && typeof Uint8ClampedArray !== 'undefined') { + return Uint8ClampedArray; + } else if(dtype === 'int16' && typeof Int16Array !== 'undefined') { + return Int16Array; + } else if(dtype === 'uint16' && typeof Uint16Array !== 'undefined') { + return Uint16Array; + } else if(dtype === 'int32' && typeof Int32Array !== 'undefined') { + return Int32Array; + } else if(dtype === 'uint32' && typeof Uint32Array !== 'undefined') { + return Uint32Array; + } else if(dtype === 'float32' && typeof Float32Array !== 'undefined') { + return Float32Array; + } else if(dtype === 'float64' && typeof Float64Array !== 'undefined') { + return Float64Array; + } +} + +/** + * Convert a TypedArray encoding object into a TypedArray + * @param {object} v: Object with `dtype` and `value` properties that + * represents a TypedArray. + * + * @returns {TypedArray} + */ +function decodeTypedArray(v) { + + var coercedV; + var value = v.value; + var TypeArrayType = getTypedArrayTypeForDtypeString(v.dtype); + + if(TypeArrayType) { + if(value instanceof ArrayBuffer) { + // value is an ArrayBuffer + coercedV = new TypeArrayType(value); + } else if(value.constructor === DataView) { + // value has a buffer property, where the buffer is an ArrayBuffer + coercedV = new TypeArrayType(value.buffer); + } else if(Array.isArray(value)) { + // value is a primitive array + coercedV = new TypeArrayType(value); + } else if(typeof value === 'string' || + value instanceof String) { + // value is a base64 encoded string + var buffer = b64.decode(value); + coercedV = new TypeArrayType(buffer); + } + } else { + // Either v.dtype was an invalid array type, or this browser doesn't + // support this typed array type. + } + return coercedV; +} + +/** + * Recursive helper function to perform decoding + */ +function performDecode(v) { + if(Lib.isTypedArrayEncoding(v)) { + return decodeTypedArray(v); + } else if(Array.isArray(v)) { + return v.map(performDecode); + } else if(Lib.isPlainObject(v)) { + var result = {}; + for(var k in v) { + if(v.hasOwnProperty(k)) { + result[k] = performDecode(v[k]); + } + } + return result; + } else { + return v; + } +} + +/** + * Plotly.decode: + * Attempt to recursively decode an object or array into a form supported + * by Plotly.js. This function is the inverse of Plotly.encode. + * + * @param {object} v: Value to be decoded + * @returns {object}: Decoded value + */ +exports.decode = function(v) { + return performDecode(v); +}; + +/** + * Get data type string for TypedArray + * @param {TypedArray} v: A TypedArray instance + * @returns {String} + */ +function getDtypeStringForTypedArray(v) { + if(typeof Int8Array !== 'undefined' && v instanceof Int8Array) { + return 'int8'; + } else if(typeof Uint8Array !== 'undefined' && v instanceof Uint8Array) { + return 'uint8'; + } else if(typeof Uint8ClampedArray !== 'undefined' && v instanceof Uint8ClampedArray) { + return 'uint8_clamped'; + } else if(typeof Int16Array !== 'undefined' && v instanceof Int16Array) { + return 'int16'; + } else if(typeof Uint16Array !== 'undefined' && v instanceof Uint16Array) { + return 'uint16'; + } else if(typeof Int32Array !== 'undefined' && v instanceof Int32Array) { + return 'int32'; + } else if(typeof Uint32Array !== 'undefined' && v instanceof Uint32Array) { + return 'uint32'; + } else if(typeof Float32Array !== 'undefined' && v instanceof Float32Array) { + return 'float32'; + } else if(typeof Float64Array !== 'undefined' && v instanceof Float64Array) { + return 'float64'; + } +} + + +/** + * Convert a TypedArray instance into a JSON-serializable object that + * represents it. + * + * @param {TypedArray} v: A TypedArray instance + * + * @returns {object} Object with `dtype` and `value` properties that + * represents a TypedArray. + */ +function encodeTypedArray(v) { + var dtype = getDtypeStringForTypedArray(v); + var buffer = b64.encode(v.buffer); + return {'value': buffer, 'dtype': dtype}; +} + + +/** + * Recursive helper function to perform encoding + * @param v + */ +function performEncode(v) { + if(Lib.isTypedArray(v)) { + return encodeTypedArray(v); + } else if(Array.isArray(v)) { + return v.map(performEncode); + } else if(Lib.isPlainObject(v)) { + var result = {}; + for(var k in v) { + if(v.hasOwnProperty(k)) { + result[k] = performEncode(v[k]); + } + } + return result; + } else { + return v; + } +} + +/** + * Plotly.encode + * Recursively encode a Plotly.js object or array into a form that is JSON + * serializable + * + * @param {object} v: Value to be encode + * @returns {object}: Encoded value + */ +exports.encode = function(v) { + return performEncode(v); +}; + /** * Plotly.react: * A plot/update method that takes the full plot state (same API as plot/newPlot) diff --git a/test/jasmine/tests/decode_typed_arrays_test.js b/test/jasmine/tests/decode_typed_arrays_test.js new file mode 100644 index 00000000000..ab57e38e8f1 --- /dev/null +++ b/test/jasmine/tests/decode_typed_arrays_test.js @@ -0,0 +1,160 @@ +var Plotly = require('@lib/index'); +var b64 = require('base64-arraybuffer'); +var encodedFigure = { + 'data': [{ + 'type': 'scatter', + 'x': {'dtype': 'float64', 'value': 'AAAAAAAACEAAAAAAAAAAQAAAAAAAAPA/'}, + 'y': {'dtype': 'float32', 'value': 'AABAQAAAAEAAAIA/'}, + 'marker': { + 'color': { + 'dtype': 'uint16', + 'value': 'AwACAAEA', + }, + } + }] +}; + +var typedArraySpecs = [ + ['int8', new Int8Array([-128, -34, 1, 127])], + ['uint8', new Uint8Array([0, 1, 127, 255])], + ['uint8_clamped', new Uint8ClampedArray([0, 1, 127, 255])], + ['int16', new Int16Array([-32768, -123, 345, 32767])], + ['uint16', new Uint16Array([0, 345, 32767, 65535])], + ['int32', new Int32Array([-2147483648, -123, 345, 32767, 2147483647])], + ['uint32', new Uint32Array([0, 345, 32767, 4294967295])], + ['float32', new Float32Array([1.2E-38, -2345.25, 2.7182818, 3.1415926, 2, 3.4E38])], + ['float64', new Float64Array([5.0E-324, 2.718281828459045, 3.141592653589793, 1.8E308])] +]; + +describe('Test TypedArray representations', function() { + 'use strict'; + + describe('ArrayBuffer', function() { + it('should accept representation as ArrayBuffer', function() { + typedArraySpecs.forEach(function(arraySpec) { + // Build value and confirm its type + var value = arraySpec[1].buffer; + expect(value.constructor).toEqual(ArrayBuffer); + + var repr = { + dtype: arraySpec[0], + value: value + }; + var raw = { + data: [{ + y: repr + }], + }; + + var gd = Plotly.decode(raw); + + expect(gd.data[0].y).toEqual(arraySpec[1]); + }); + }); + }); + + describe('Array', function() { + it('should accept representation as Array', function() { + typedArraySpecs.forEach(function(arraySpec) { + // Build value and confirm its type + var value = Array.prototype.slice.call(arraySpec[1]); + expect(Array.isArray(value)).toEqual(true); + + var repr = { + dtype: arraySpec[0], + value: value + }; + var raw = { + data: [{ + y: repr + }], + }; + + var gd = Plotly.decode(raw); + + expect(gd.data[0].y).toEqual(arraySpec[1]); + }); + }); + }); + + describe('DataView', function() { + it('should accept representation as DataView', function() { + typedArraySpecs.forEach(function(arraySpec) { + // Build value and confirm its type + var value = new DataView(arraySpec[1].buffer); + expect(value.constructor).toEqual(DataView); + + var repr = { + dtype: arraySpec[0], + value: value + }; + var raw = { + data: [{ + y: repr + }], + }; + + var gd = Plotly.decode(raw); + + expect(gd.data[0].y).toEqual(arraySpec[1]); + }); + }); + }); + + describe('base64', function() { + it('should accept representation as base 64 string', function() { + typedArraySpecs.forEach(function(arraySpec) { + // Build value and confirm its type + var value = b64.encode(arraySpec[1].buffer); + expect(typeof value).toEqual('string'); + + var repr = { + dtype: arraySpec[0], + value: value + }; + var raw = { + data: [{ + y: repr + }], + }; + + var gd = Plotly.decode(raw); + expect(gd.data[0].y).toEqual(arraySpec[1]); + + // Re-encoding should produce the original encoding + expect(Plotly.encode(gd)).toEqual({data: [{y: repr}]}); + }); + }); + }); + + describe('encoded figure', function() { + it('should decode representation as base 64 and Array in encoded figure', function() { + + var gd = Plotly.decode(encodedFigure); + + // Check x + // data_array property + expect(encodedFigure.data[0].x).toEqual({ + 'dtype': 'float64', + 'value': 'AAAAAAAACEAAAAAAAAAAQAAAAAAAAPA/'}); + expect(gd.data[0].x).toEqual(new Float64Array([3, 2, 1])); + + // Check y + // data_array property + expect(encodedFigure.data[0].y).toEqual({ + 'dtype': 'float32', + 'value': 'AABAQAAAAEAAAIA/'}); + expect(gd.data[0].y).toEqual(new Float32Array([3, 2, 1])); + + // Check marker.color + // This is an arrayOk property not a data_array property + expect(encodedFigure.data[0].marker.color).toEqual({ + 'dtype': 'uint16', + 'value': 'AwACAAEA'}); + expect(gd.data[0].marker.color).toEqual(new Uint16Array([3, 2, 1])); + + // Re-encode to make sure we obtain the original representation + expect(Plotly.encode(gd)).toEqual(encodedFigure); + }); + }); +});