diff --git a/draftlogs/7894_add.md b/draftlogs/7894_add.md new file mode 100644 index 00000000000..f9b91f738c5 --- /dev/null +++ b/draftlogs/7894_add.md @@ -0,0 +1 @@ + - Add native log-scale colorbars, with new 'type': 'log' attribute [[#7894](https://github.com/plotly/plotly.js/pull/7894)] \ No newline at end of file diff --git a/src/components/colorbar/attributes.js b/src/components/colorbar/attributes.js index 3a562b516a0..48d939a1c1d 100644 --- a/src/components/colorbar/attributes.js +++ b/src/components/colorbar/attributes.js @@ -33,6 +33,16 @@ module.exports = overrideAll({ 'This measure excludes the size of the padding, ticks and labels.' ].join(' ') }, + type: { + valType: 'enumerated', + values: ['linear', 'log'], + dflt: 'linear', + editType: 'calc', + description: [ + 'Sets the colorbar axis type.', + 'If `"log"`, the colorscale and colorbar will be configured for logarithmic data.' + ].join(' ') + }, lenmode: { valType: 'enumerated', values: ['fraction', 'pixels'], diff --git a/src/components/colorbar/defaults.js b/src/components/colorbar/defaults.js index 2fab3cd5209..f703509894a 100644 --- a/src/components/colorbar/defaults.js +++ b/src/components/colorbar/defaults.js @@ -22,6 +22,7 @@ module.exports = function colorbarDefaults(containerIn, containerOut, layout) { var w = layout.width - margin.l - margin.r; var h = layout.height - margin.t - margin.b; + coerce('type'); var orientation = coerce('orientation'); var isVertical = orientation === 'v'; diff --git a/src/components/colorbar/draw.js b/src/components/colorbar/draw.js index 3fe5a5e9ce3..afb6d48753a 100644 --- a/src/components/colorbar/draw.js +++ b/src/components/colorbar/draw.js @@ -956,8 +956,11 @@ function mockColorBarAxis(gd, opts, zrange) { var isVertical = opts.orientation === 'v'; var cbAxisIn = { - type: 'linear', - range: zrange, + type: opts.type || 'linear', + // Colorscale.calc guarantees zrange is strictly positive whenever + // opts.type is still 'log' by the time we get here + range: opts.type === 'log' ? + [Math.log10(zrange[0]), Math.log10(zrange[1])] : zrange, tickmode: opts.tickmode, nticks: opts.nticks, tick0: opts.tick0, @@ -994,7 +997,7 @@ function mockColorBarAxis(gd, opts, zrange) { var letter = isVertical ? 'y' : 'x'; var cbAxisOut = { - type: 'linear', + type: cbAxisIn.type, _id: letter + opts._id }; diff --git a/src/components/colorscale/calc.js b/src/components/colorscale/calc.js index 0dd7800d431..d0df9d0c883 100644 --- a/src/components/colorscale/calc.js +++ b/src/components/colorscale/calc.js @@ -5,9 +5,24 @@ var isNumeric = require('fast-isnumeric'); var Lib = require('../../lib'); var extractOpts = require('./helpers').extractOpts; +// mirrors Lib.aggNums's own recursion into nested (eg heatmap z) arrays, but +// masks out non-positive values first - matching how cartesian log axes +// exclude non-positive data from autorange (see findExtremes in +// plots/cartesian/autorange.js) +function maskNonPositive(vals) { + if(Lib.isArrayOrTypedArray(vals[0])) return vals.map(maskNonPositive); + + var out = new Array(vals.length); + for(var i = 0; i < vals.length; i++) { + var v = vals[i]; + out[i] = (isNumeric(v) && v > 0) ? v : undefined; + } + return out; +} + module.exports = function calc(gd, trace, opts) { var fullLayout = gd._fullLayout; - var vals = opts.vals; + var rawVals = opts.vals; var containerStr = opts.containerStr; var container = containerStr ? @@ -20,6 +35,27 @@ module.exports = function calc(gd, trace, opts) { var max = cOpts.max; var mid = cOpts.mid; + var colorbar = cOpts.colorbar; + var isLog = !!(colorbar && colorbar.type === 'log'); + + if(isLog) { + // a log colorbar needs a strictly positive domain: an explicit + // non-positive cmin/cmax can never be logged, and if this trace's + // data is going to be scanned for an auto min/max but has no + // positive values at all, there's nothing to scale from. Either way, + // fall back to a linear colorbar instead of an undefined/negative range. + var fullyPinned = !auto && isNumeric(min) && isNumeric(max); + var hasExplicitNonPositive = (isNumeric(min) && min <= 0) || (isNumeric(max) && max <= 0); + var hasPositiveData = fullyPinned || isNumeric(Lib.aggNums(Math.max, null, maskNonPositive(rawVals))); + + if(hasExplicitNonPositive || !hasPositiveData) { + isLog = false; + colorbar.type = 'linear'; + } + } + + var vals = isLog ? maskNonPositive(rawVals) : rawVals; + var minVal = function() { return Lib.aggNums(Math.min, null, vals); }; var maxVal = function() { return Lib.aggNums(Math.max, null, vals); }; @@ -43,7 +79,9 @@ module.exports = function calc(gd, trace, opts) { } } - if(auto && mid !== undefined) { + // a symmetric-around-cmid range is a linear-domain concept - skip it for + // log colorbars rather than risk pushing a positive min/max non-positive + if(auto && mid !== undefined && !isLog) { if(max - mid > mid - min) { min = mid - (max - mid); } else if(max - mid < mid - min) { diff --git a/src/components/colorscale/helpers.js b/src/components/colorscale/helpers.js index bc476e0a9b5..9ad40417419 100644 --- a/src/components/colorscale/helpers.js +++ b/src/components/colorscale/helpers.js @@ -117,6 +117,17 @@ function extractScale(cont) { var cmin = cOpts.min; var cmax = cOpts.max; + // Check if a log type is defined on the colorbar + // (Colorscale.calc guarantees cmin/cmax are strictly positive whenever + // type is still 'log' by the time we get here - see maskNonPositive) + var type = (cOpts.colorbar && cOpts.colorbar.type) || 'linear'; + + // Convert domain boundaries to base-10 log if required + if(type === 'log') { + cmin = Math.log10(cmin); + cmax = Math.log10(cmax); + } + var scl = cOpts.reversescale ? flipScale(cOpts.colorscale) : cOpts.colorscale; @@ -208,7 +219,33 @@ function makeColorScaleFunc(specs, opts) { } function makeColorScaleFuncFromTrace(trace, opts) { - return makeColorScaleFunc(extractScale(trace), opts); + var cOpts = extractOpts(trace); + var type = (cOpts.colorbar && cOpts.colorbar.type) || 'linear'; + var baseColorFn = makeColorScaleFunc(extractScale(trace), opts); + + if(type !== 'log') return baseColorFn; + + opts = opts || {}; + var noNumericCheck = opts.noNumericCheck; + var returnArray = opts.returnArray; + + // Wrap the base generator to dynamically apply log transformations, + // matching the array/string return shape makeColorScaleFunc would have used + var wrappedFunc = function(v) { + if(noNumericCheck || isNumeric(v)) { + // log of zero/negative is undefined - treat as out-of-range + if(v > 0) return baseColorFn(Math.log10(v)); + return returnArray ? [0, 0, 0, 0] : 'rgba(0,0,0,0)'; + } else if(tinycolor(v).isValid()) { + return v; + } + return Color.defaultLine; + }; + + // Preserve native domain and range methods for the draw components + wrappedFunc.domain = baseColorFn.domain; + wrappedFunc.range = baseColorFn.range; + return wrappedFunc; } function colorArray2rbga(colorArray) { diff --git a/test/jasmine/tests/colorbar_test.js b/test/jasmine/tests/colorbar_test.js index 99fa90e5e5b..1d090051e74 100644 --- a/test/jasmine/tests/colorbar_test.js +++ b/test/jasmine/tests/colorbar_test.js @@ -550,4 +550,95 @@ describe('Test colorbar:', function() { .then(done, done.fail); }); }); + + describe('log ticks', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + it('should generate logarithmic ticks when type is log', function() { + Plotly.newPlot(gd, [{ + type: 'heatmap', + z: [[1, 10, 100], [10,100,1000]], + cmin: 1, + cmax: 1000, + colorbar: { + type: 'log', + tickmode: 'auto' + } + }]); + + // Extract the tick text elements + var tickTexts = []; + d3Select(gd).selectAll('.cbaxis text').each(function(t) { + tickTexts.push(t.text); + }); + + // The mock axis should have automatically generated exponents for 1, 10, 100, 1000 + expect(tickTexts).toContain('1'); + expect(tickTexts).toContain('2'); + expect(tickTexts).toContain('5'); + expect(tickTexts).toContain('10'); + expect(tickTexts).toContain('100'); + expect(tickTexts).toContain('1000'); + + // // Verify a linear tick like '500' was NOT generated + expect(tickTexts).not.toContain('500'); + }); + + it('should fall back to a linear colorbar when zmin is explicitly non-positive, instead of a NaN-derived range', function(done) { + Plotly.newPlot(gd, [{ + type: 'heatmap', + z: [[-5, 10, 100], [10, 100, 1000]], + zmin: -5, + zmax: 1000, + colorbar: { + type: 'log', + tickmode: 'auto' + } + }]) + .then(function() { + var ax; + gd._fullLayout._infolayer.selectAll('g.colorbar').each(function(opts) { + ax = opts._axis; + }); + + // a log colorbar can't represent a non-positive zmin, so the + // whole colorbar (and its mocked axis) falls back to linear + // instead of producing a NaN/folded log range + expect(gd._fullData[0].colorbar.type).toBe('linear'); + expect(ax.type).toBe('linear'); + expect(ax.range).toEqual([-5, 1000]); + }) + .then(done, done.fail); + }); + + it('should mask out non-positive z values and stay log when autoscaling with mixed-sign data', function(done) { + Plotly.newPlot(gd, [{ + type: 'heatmap', + z: [[-5, 2, 100], [10, 100, 1000]], + colorbar: { + type: 'log', + tickmode: 'auto' + } + }]) + .then(function() { + var ax; + gd._fullLayout._infolayer.selectAll('g.colorbar').each(function(opts) { + ax = opts._axis; + }); + + expect(gd._fullData[0].colorbar.type).toBe('log'); + expect(ax.type).toBe('log'); + // -5 is masked out of autorange; smallest positive value (2) is zmin + expect(ax.range[0]).toBeCloseTo(Math.log10(2), 5); + expect(ax.range[1]).toBeCloseTo(Math.log10(1000), 5); + }) + .then(done, done.fail); + }); + }); }); diff --git a/test/jasmine/tests/colorscale_test.js b/test/jasmine/tests/colorscale_test.js index f8b0fe91348..38842ee9a61 100644 --- a/test/jasmine/tests/colorscale_test.js +++ b/test/jasmine/tests/colorscale_test.js @@ -654,6 +654,53 @@ describe('Test colorscale:', function() { expect(fullLayout.coloraxis.cmax).toBe(4); expect(fullLayout.coloraxis._cmax).toBe(4); }); + + it('should fall back to a linear colorbar when zmin/zmax is explicitly non-positive for a log colorbar', function() { + trace = { + type: 'heatmap', + z: [[-5, 10, 100], [10, 100, 1000]], + zmin: -5, + zmax: 1000, + zauto: false, + colorbar: {type: 'log'} + }; + gd = _supply(trace); + calcColorscale(gd, trace, {vals: trace.z, containerStr: '', cLetter: 'z'}); + + expect(trace.colorbar.type).toBe('linear'); + expect(trace._zmin).toBe(-5); + expect(trace._zmax).toBe(1000); + }); + + it('should fall back to a linear colorbar when autoscaling finds no positive values for a log colorbar', function() { + trace = { + type: 'heatmap', + z: [[-5, -10], [-100, -1]], + colorbar: {type: 'log'} + }; + gd = _supply(trace); + calcColorscale(gd, trace, {vals: trace.z, containerStr: '', cLetter: 'z'}); + + expect(trace.colorbar.type).toBe('linear'); + expect(trace._zmin).toBe(-100); + expect(trace._zmax).toBe(-1); + }); + + it('should mask out non-positive values when autoscaling a log colorbar, like log cartesian axes do', function() { + trace = { + type: 'heatmap', + z: [[-5, 2, 100], [10, 100, 1000]], + colorbar: {type: 'log'} + }; + gd = _supply(trace); + calcColorscale(gd, trace, {vals: trace.z, containerStr: '', cLetter: 'z'}); + + // stays log - there is positive data to scale from + expect(trace.colorbar.type).toBe('log'); + // -5 is masked out; the smallest positive value (2) becomes zmin + expect(trace._zmin).toBe(2); + expect(trace._zmax).toBe(1000); + }); }); describe('extractScale + makeColorScaleFunc', function() { @@ -1198,4 +1245,107 @@ describe('Test colorscale restyle calls:', function() { }) .then(done, done.fail); }); + + describe('helpers log mapping', function() { + var makeColorScaleFuncFromTrace = Colorscale.makeColorScaleFuncFromTrace; + + it('should map domain values logarithmically to the color gradient when type is log', function() { + var trace = { + cmin: 1, + cmax: 100, + colorscale: [ + [0, 'rgb(0, 0, 0)'], // 0% + [1, 'rgb(100, 100, 100)'] // 100% + ], + colorbar: { + type: 'log' + } + }; + + var colorFn = makeColorScaleFuncFromTrace(trace); + + // log10(1) = 0 -> maps to 0% of the scale + expect(colorFn(1)).toEqual('rgb(0, 0, 0)'); + + // log10(10) = 1 -> maps to 50% of the scale (halfway between log10(1) and log10(100)) + expect(colorFn(10)).toEqual('rgb(50, 50, 50)'); + + // log10(100) = 2 -> maps to 100% of the scale + expect(colorFn(100)).toEqual('rgb(100, 100, 100)'); + }); + + it('should return a transparent color for zero or negative values on a log scale', function() { + var trace = { + cmin: 1, + cmax: 100, + colorscale: [ + [0, 'rgb(0, 0, 0)'], + [1, 'rgb(255, 255, 255)'] + ], + colorbar: { + type: 'log' + } + }; + + var colorFn = makeColorScaleFuncFromTrace(trace); + + // The logarithm of zero or a negative number is undefined, + // so our wrapper should catch it and return transparent black. + expect(colorFn(0)).toEqual('rgba(0,0,0,0)'); + expect(colorFn(-10)).toEqual('rgba(0,0,0,0)'); + }); + + it('should return numeric rgba arrays (not strings) when called with returnArray/noNumericCheck, as heatmap rendering does', function() { + var trace = { + cmin: 1, + cmax: 100, + colorscale: [ + [0, 'rgb(0, 0, 0)'], + [1, 'rgb(200, 100, 50)'] + ], + colorbar: { + type: 'log' + } + }; + + var colorFn = makeColorScaleFuncFromTrace(trace, {noNumericCheck: true, returnArray: true}); + + // log10(1) = 0 -> maps to 0% of the scale + var cLow = colorFn(1); + expect(cLow).toEqual([0, 0, 0, 1]); + expect(typeof cLow[0]).toBe('number'); + + // log10(100) = 2 -> maps to 100% of the scale + var cHigh = colorFn(100); + expect(cHigh).toEqual([200, 100, 50, 1]); + + // zero/negative must clamp to a numeric transparent array, not a css string, + // since callers like heatmap/plot.js index directly into the result (c[0], c[1], c[2]) + var cZero = colorFn(0); + expect(cZero).toEqual([0, 0, 0, 0]); + expect(typeof cZero[0]).toBe('number'); + + var cNeg = colorFn(-10); + expect(cNeg).toEqual([0, 0, 0, 0]); + expect(typeof cNeg[0]).toBe('number'); + }); + + it('should safely fall back to linear if type is undefined or linear', function() { + var trace = { + cmin: 1, + cmax: 100, + colorscale: [ + [0, 'rgb(0, 0, 0)'], + [1, 'rgb(100, 100, 100)'] + ], + // Omitting colorbar.type to simulate default behavior + }; + + var colorFn = makeColorScaleFuncFromTrace(trace); + + // Linear midpoint between 1 and 100 is 50.5 + expect(colorFn(50.5)).toEqual('rgb(50, 50, 50)'); + }); + }); + }); diff --git a/test/jasmine/tests/validate_test.js b/test/jasmine/tests/validate_test.js index 983d6288aaf..0724901e876 100644 --- a/test/jasmine/tests/validate_test.js +++ b/test/jasmine/tests/validate_test.js @@ -574,4 +574,34 @@ describe('Plotly.validate', function() { }]); expect(out).toBeUndefined(); }); + + describe('colorbar type validation', function() { + it('should accept valid colorbar types', function() { + var trace = { + type: 'heatmap', + z: [[1, 10], [100, 1000]], + colorbar: { + type: 'log' + } + }; + + // validate returns an array of errors. An empty array means success. + var out = Plotly.validate([trace]); + expect(out).toBeUndefined(); + }); + + it('should reject invalid colorbar types', function() { + var trace = { + type: 'heatmap', + z: [[1, 2], [3, 4]], + colorbar: { + type: 'exponential' // not in our enumerated list + } + }; + + var out = Plotly.validate([trace]); + expect(out.length).toEqual(1); + expect(out[0].msg).toEqual('In data trace 0, key colorbar.type is set to an invalid value (exponential)'); + }); + }); });