diff --git a/src/components/colorbar/attributes.js b/src/components/colorbar/attributes.js index 67ce2557895..aa2c456c9de 100644 --- a/src/components/colorbar/attributes.js +++ b/src/components/colorbar/attributes.js @@ -166,6 +166,7 @@ module.exports = overrideAll({ }), tickangle: axesAttrs.tickangle, tickformat: axesAttrs.tickformat, + tickformatstops: axesAttrs.tickformatstops, tickprefix: axesAttrs.tickprefix, showtickprefix: axesAttrs.showtickprefix, ticksuffix: axesAttrs.ticksuffix, diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index 59e94a3ca60..64506c23eb9 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -1232,7 +1232,7 @@ function tickTextObj(ax, x, text) { function formatDate(ax, out, hover, extraPrecision) { var tr = ax._tickround, - fmt = (hover && ax.hoverformat) || ax.tickformat; + fmt = (hover && ax.hoverformat) || axes.getTickFormat(ax); if(extraPrecision) { // second or sub-second precision: extra always shows max digits. @@ -1288,7 +1288,8 @@ function formatDate(ax, out, hover, extraPrecision) { function formatLog(ax, out, hover, extraPrecision, hideexp) { var dtick = ax.dtick, - x = out.x; + x = out.x, + tickformat = ax.tickformat; if(hideexp === 'never') { // If this is a hover label, then we must *never* hide the exponent @@ -1302,7 +1303,7 @@ function formatLog(ax, out, hover, extraPrecision, hideexp) { if(extraPrecision && ((typeof dtick !== 'string') || dtick.charAt(0) !== 'L')) dtick = 'L3'; - if(ax.tickformat || (typeof dtick === 'string' && dtick.charAt(0) === 'L')) { + if(tickformat || (typeof dtick === 'string' && dtick.charAt(0) === 'L')) { out.text = numFormat(Math.pow(10, x), ax, hideexp, extraPrecision); } else if(isNumeric(dtick) || ((dtick.charAt(0) === 'D') && (Lib.mod(x + 0.01, 1) < 0.1))) { @@ -1397,7 +1398,7 @@ function numFormat(v, ax, fmtoverride, hover) { tickRound = ax._tickround, exponentFormat = fmtoverride || ax.exponentformat || 'B', exponent = ax._tickexponent, - tickformat = ax.tickformat, + tickformat = axes.getTickFormat(ax), separatethousands = ax.separatethousands; // special case for hover: set exponent just for this value, and @@ -1498,6 +1499,76 @@ function numFormat(v, ax, fmtoverride, hover) { return v; } +axes.getTickFormat = function(ax) { + var i; + + function convertToMs(dtick) { + return typeof dtick !== 'string' ? dtick : Number(dtick.replace('M', '')) * ONEAVGMONTH; + } + + function compareLogTicks(left, right) { + var priority = ['L', 'D']; + if(typeof left === typeof right) { + if(typeof left === 'number') { + return left - right; + } else { + var leftPriority = priority.indexOf(left.charAt(0)); + var rightPriority = priority.indexOf(right.charAt(0)); + if(leftPriority === rightPriority) { + return Number(left.replace(/(L|D)/g, '')) - Number(right.replace(/(L|D)/g, '')); + } else { + return leftPriority - rightPriority; + } + } + } else { + return typeof left === 'number' ? 1 : -1; + } + } + + function isProperStop(dtick, range, convert) { + var convertFn = convert || function(x) { return x;}; + var leftDtick = range[0]; + var rightDtick = range[1]; + return ((!leftDtick && typeof leftDtick !== 'number') || convertFn(leftDtick) <= convertFn(dtick)) && + ((!rightDtick && typeof rightDtick !== 'number') || convertFn(rightDtick) >= convertFn(dtick)); + } + + function isProperLogStop(dtick, range) { + var isLeftDtickNull = range[0] === null; + var isRightDtickNull = range[1] === null; + var isDtickInRangeLeft = compareLogTicks(dtick, range[0]) >= 0; + var isDtickInRangeRight = compareLogTicks(dtick, range[1]) <= 0; + return (isLeftDtickNull || isDtickInRangeLeft) && (isRightDtickNull || isDtickInRangeRight); + } + + var tickstop; + if(ax.tickformatstops && ax.tickformatstops.length > 0) { + switch(ax.type) { + case 'date': + case 'linear': { + for(i = 0; i < ax.tickformatstops.length; i++) { + if(isProperStop(ax.dtick, ax.tickformatstops[i].dtickrange, convertToMs)) { + tickstop = ax.tickformatstops[i]; + break; + } + } + break; + } + case 'log': { + for(i = 0; i < ax.tickformatstops.length; i++) { + if(isProperLogStop(ax.dtick, ax.tickformatstops[i].dtickrange)) { + tickstop = ax.tickformatstops[i]; + break; + } + } + break; + } + default: + } + } + return tickstop ? tickstop.value : ax.tickformat; +}; + axes.subplotMatch = /^x([0-9]*)y([0-9]*)$/; // getSubplots - extract all combinations of axes we need to make plots for diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js index 52571086cde..aae0ac26b43 100644 --- a/src/plots/cartesian/layout_attributes.js +++ b/src/plots/cartesian/layout_attributes.js @@ -492,6 +492,34 @@ module.exports = { '*%H~%M~%S.%2f* would display *09~15~23.46*' ].join(' ') }, + tickformatstops: { + _isLinkedToArray: 'tickformatstop', + + dtickrange: { + valType: 'info_array', + role: 'info', + items: [ + {valType: 'any', editType: 'ticks'}, + {valType: 'any', editType: 'ticks'} + ], + editType: 'ticks', + description: [ + 'range [*min*, *max*], where *min*, *max* - dtick values', + 'which describe some zoom level, it is possible to omit *min*', + 'or *max* value by passing *null*' + ].join(' ') + }, + value: { + valType: 'string', + dflt: '', + role: 'style', + editType: 'ticks', + description: [ + 'string - dtickformat for described zoom level, the same as *tickformat*' + ].join(' ') + }, + editType: 'ticks' + }, hoverformat: { valType: 'string', dflt: '', diff --git a/src/plots/cartesian/tick_label_defaults.js b/src/plots/cartesian/tick_label_defaults.js index 5f37680d331..96af6b1be8b 100644 --- a/src/plots/cartesian/tick_label_defaults.js +++ b/src/plots/cartesian/tick_label_defaults.js @@ -10,7 +10,7 @@ 'use strict'; var Lib = require('../../lib'); - +var layoutAttributes = require('./layout_attributes'); /** * options: inherits font, outerTicks, noHover from axes.handleAxisDefaults @@ -40,6 +40,7 @@ module.exports = function handleTickLabelDefaults(containerIn, containerOut, coe if(axType !== 'category') { var tickFormat = coerce('tickformat'); + tickformatstopsDefaults(containerIn, containerOut); if(!tickFormat && axType !== 'date') { coerce('showexponent', showAttrDflt); coerce('exponentformat'); @@ -80,3 +81,26 @@ function getShowAttrDflt(containerIn) { return containerIn[showAttrs[0]]; } } + +function tickformatstopsDefaults(tickformatIn, tickformatOut) { + var valuesIn = tickformatIn.tickformatstops; + var valuesOut = tickformatOut.tickformatstops = []; + + if(!Array.isArray(valuesIn)) return; + + var valueIn, valueOut; + + function coerce(attr, dflt) { + return Lib.coerce(valueIn, valueOut, layoutAttributes.tickformatstops, attr, dflt); + } + + for(var i = 0; i < valuesIn.length; i++) { + valueIn = valuesIn[i]; + valueOut = {}; + + coerce('dtickrange'); + coerce('value'); + + valuesOut.push(valueOut); + } +} diff --git a/src/plots/gl3d/layout/axis_attributes.js b/src/plots/gl3d/layout/axis_attributes.js index f6e91b3daa3..7375c0c553a 100644 --- a/src/plots/gl3d/layout/axis_attributes.js +++ b/src/plots/gl3d/layout/axis_attributes.js @@ -101,6 +101,7 @@ module.exports = overrideAll({ exponentformat: axesAttrs.exponentformat, separatethousands: axesAttrs.separatethousands, tickformat: axesAttrs.tickformat, + tickformatstops: axesAttrs.tickformatstops, hoverformat: axesAttrs.hoverformat, // lines and grids showline: axesAttrs.showline, diff --git a/src/plots/ternary/layout/axis_attributes.js b/src/plots/ternary/layout/axis_attributes.js index fa35570ecb3..073cfa2fadf 100644 --- a/src/plots/ternary/layout/axis_attributes.js +++ b/src/plots/ternary/layout/axis_attributes.js @@ -39,6 +39,7 @@ module.exports = { tickfont: axesAttrs.tickfont, tickangle: axesAttrs.tickangle, tickformat: axesAttrs.tickformat, + tickformatstops: axesAttrs.tickformatstops, hoverformat: axesAttrs.hoverformat, // lines and grids showline: extendFlat({}, axesAttrs.showline, {dflt: true}), diff --git a/src/traces/carpet/axis_attributes.js b/src/traces/carpet/axis_attributes.js index 75f4f37132b..8478132e1bb 100644 --- a/src/traces/carpet/axis_attributes.js +++ b/src/traces/carpet/axis_attributes.js @@ -10,6 +10,8 @@ var fontAttrs = require('../../plots/font_attributes'); var colorAttrs = require('../../components/color/attributes'); +var axesAttrs = require('../../plots/cartesian/layout_attributes'); +var overrideAll = require('../../plot_api/edit_types').overrideAll; module.exports = { color: { @@ -290,6 +292,7 @@ module.exports = { '*%H~%M~%S.%2f* would display *09~15~23.46*' ].join(' ') }, + tickformatstops: overrideAll(axesAttrs.tickformatstops, 'calc', 'from-root'), categoryorder: { valType: 'enumerated', values: [ diff --git a/tasks/util/strict_d3.js b/tasks/util/strict_d3.js index 18b8ff25915..d667f6d9d74 100644 --- a/tasks/util/strict_d3.js +++ b/tasks/util/strict_d3.js @@ -6,7 +6,6 @@ var pathToStrictD3Module = path.join( constants.pathToImageTest, 'strict-d3.js' ); - /** * Transform `require('d3')` expressions to `require(/path/to/strict-d3.js)` */ @@ -18,6 +17,8 @@ module.exports = transformTools.makeRequireTransform('requireTransform', var pathOut; if(pathIn === 'd3' && opts.file !== pathToStrictD3Module) { + // JSON.stringify: fix npm-scripts for windows users, for whom + // path has \ in it, without stringify that turns into control chars. pathOut = 'require(' + JSON.stringify(pathToStrictD3Module) + ')'; } diff --git a/test/image/baselines/tickformatstops.png b/test/image/baselines/tickformatstops.png new file mode 100644 index 00000000000..3f0e66f942d Binary files /dev/null and b/test/image/baselines/tickformatstops.png differ diff --git a/test/image/mocks/tickformatstops.json b/test/image/mocks/tickformatstops.json new file mode 100644 index 00000000000..af59b7606ec --- /dev/null +++ b/test/image/mocks/tickformatstops.json @@ -0,0 +1,46 @@ +{ + "data": [ + { + "x": ["2005-01","2005-02","2005-03","2005-04","2005-05","2005-06","2005-07"], + "y": [-20,10,-5,0,5,-10,20] + } + ], + "layout": { + "xaxis": { + "tickformatstops": [ + { + "dtickrange": [null, 1000], + "value": "%H:%M:%S.%L ms" + }, + { + "dtickrange": [1000, 60000], + "value": "%H:%M:%S s" + }, + { + "dtickrange": [60000, 3600000], + "value": "%H:%M m" + }, + { + "dtickrange": [3600000, 86400000], + "value": "%H:%M h" + }, + { + "dtickrange": [86400000, 604800000], + "value": "%e. %b d" + }, + { + "dtickrange": [604800000, "M1"], + "value": "%e. %b w" + }, + { + "dtickrange": ["M1", "M12"], + "value": "%b '%y M" + }, + { + "dtickrange": ["M12", null], + "value": "%Y Y" + } + ] + } + } +} diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index 3bae4c904ac..fb269708016 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -1,4 +1,5 @@ var Plotly = require('@lib/index'); +var d3 = require('d3'); var Plots = require('@src/plots/plots'); var Lib = require('@src/lib'); @@ -7,10 +8,12 @@ var tinycolor = require('tinycolor2'); var handleTickValueDefaults = require('@src/plots/cartesian/tick_value_defaults'); var Axes = require('@src/plots/cartesian/axes'); +var Fx = require('@src/components/fx'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var failTest = require('../assets/fail_test'); +var selectButton = require('../assets/modebar_button'); describe('Test axes', function() { @@ -2481,3 +2484,301 @@ describe('Test axes', function() { }); }); }); + +function getZoomInButton(gd) { + return selectButton(gd._fullLayout._modeBar, 'zoomIn2d'); +} + +function getZoomOutButton(gd) { + return selectButton(gd._fullLayout._modeBar, 'zoomOut2d'); +} + +function getFormatter(format) { + return d3.time.format.utc(format); +} + +describe('Test Axes.getTickformat', function() { + 'use strict'; + + it('get proper tickformatstop for linear axis', function() { + var lineartickformatstops = [ + { + dtickrange: [null, 1], + value: '.f2', + }, + { + dtickrange: [1, 100], + value: '.f1', + }, + { + dtickrange: [100, null], + value: 'g', + } + ]; + expect(Axes.getTickFormat({ + type: 'linear', + tickformatstops: lineartickformatstops, + dtick: 0.1 + })).toEqual(lineartickformatstops[0].value); + + expect(Axes.getTickFormat({ + type: 'linear', + tickformatstops: lineartickformatstops, + dtick: 1 + })).toEqual(lineartickformatstops[0].value); + + expect(Axes.getTickFormat({ + type: 'linear', + tickformatstops: lineartickformatstops, + dtick: 99 + })).toEqual(lineartickformatstops[1].value); + expect(Axes.getTickFormat({ + type: 'linear', + tickformatstops: lineartickformatstops, + dtick: 99999 + })).toEqual(lineartickformatstops[2].value); + }); + + it('get proper tickformatstop for date axis', function() { + var MILLISECOND = 1; + var SECOND = MILLISECOND * 1000; + var MINUTE = SECOND * 60; + var HOUR = MINUTE * 60; + var DAY = HOUR * 24; + var WEEK = DAY * 7; + var MONTH = 'M1'; // or YEAR / 12; + var YEAR = 'M12'; // or 365.25 * DAY; + var datetickformatstops = [ + { + dtickrange: [null, SECOND], + value: '%H:%M:%S.%L ms' // millisecond + }, + { + dtickrange: [SECOND, MINUTE], + value: '%H:%M:%S s' // second + }, + { + dtickrange: [MINUTE, HOUR], + value: '%H:%M m' // minute + }, + { + dtickrange: [HOUR, DAY], + value: '%H:%M h' // hour + }, + { + dtickrange: [DAY, WEEK], + value: '%e. %b d' // day + }, + { + dtickrange: [WEEK, MONTH], + value: '%e. %b w' // week + }, + { + dtickrange: [MONTH, YEAR], + value: '%b \'%y M' // month + }, + { + dtickrange: [YEAR, null], + value: '%Y Y' // year + } + ]; + expect(Axes.getTickFormat({ + type: 'date', + tickformatstops: datetickformatstops, + dtick: 100 + })).toEqual(datetickformatstops[0].value); // millisecond + + expect(Axes.getTickFormat({ + type: 'date', + tickformatstops: datetickformatstops, + dtick: 1000 + })).toEqual(datetickformatstops[0].value); // millisecond + + expect(Axes.getTickFormat({ + type: 'date', + tickformatstops: datetickformatstops, + dtick: 1000 * 60 * 60 * 3 // three hours + })).toEqual(datetickformatstops[3].value); // hour + + expect(Axes.getTickFormat({ + type: 'date', + tickformatstops: datetickformatstops, + dtick: 1000 * 60 * 60 * 24 * 7 * 2 // two weeks + })).toEqual(datetickformatstops[5].value); // week + + expect(Axes.getTickFormat({ + type: 'date', + tickformatstops: datetickformatstops, + dtick: 'M1' + })).toEqual(datetickformatstops[5].value); // week + + expect(Axes.getTickFormat({ + type: 'date', + tickformatstops: datetickformatstops, + dtick: 'M5' + })).toEqual(datetickformatstops[6].value); // month + + expect(Axes.getTickFormat({ + type: 'date', + tickformatstops: datetickformatstops, + dtick: 'M24' + })).toEqual(datetickformatstops[7].value); // year + }); + + it('get proper tickformatstop for log axis', function() { + var logtickformatstops = [ + { + dtickrange: [null, 'L0.01'], + value: '.f3', + }, + { + dtickrange: ['L0.01', 'L1'], + value: '.f2', + }, + { + dtickrange: ['D1', 'D2'], + value: '.f1', + }, + { + dtickrange: [1, null], + value: 'g' + } + ]; + expect(Axes.getTickFormat({ + type: 'log', + tickformatstops: logtickformatstops, + dtick: 'L0.0001' + })).toEqual(logtickformatstops[0].value); + + expect(Axes.getTickFormat({ + type: 'log', + tickformatstops: logtickformatstops, + dtick: 'L0.1' + })).toEqual(logtickformatstops[1].value); + + expect(Axes.getTickFormat({ + type: 'log', + tickformatstops: logtickformatstops, + dtick: 'L2' + })).toEqual(undefined); + expect(Axes.getTickFormat({ + type: 'log', + tickformatstops: logtickformatstops, + dtick: 'D2' + })).toEqual(logtickformatstops[2].value); + expect(Axes.getTickFormat({ + type: 'log', + tickformatstops: logtickformatstops, + dtick: 1 + })).toEqual(logtickformatstops[3].value); + }); +}); + +describe('Test tickformatstops:', function() { + 'use strict'; + + var mock = require('@mocks/tickformatstops.json'); + + var mockCopy, gd; + + beforeEach(function() { + gd = createGraphDiv(); + mockCopy = Lib.extendDeep({}, mock); + }); + + afterEach(destroyGraphDiv); + + it('handles zooming-in until milliseconds zoom level', function(done) { + var promise = Plotly.plot(gd, mockCopy.data, mockCopy.layout); + + var testCount = 0; + + var zoomIn = function() { + promise = promise.then(function() { + getZoomInButton(gd).click(); + var xLabels = Axes.calcTicks(gd._fullLayout.xaxis); + var formatter = getFormatter(Axes.getTickFormat(gd._fullLayout.xaxis)); + var expectedLabels = xLabels.map(function(d) {return formatter(new Date(d.x));}); + var actualLabels = xLabels.map(function(d) {return d.text;}); + expect(expectedLabels).toEqual(actualLabels); + testCount++; + + if(gd._fullLayout.xaxis.dtick > 1) { + zoomIn(); + } else { + // make sure we tested as many levels as we thought we would + expect(testCount).toBe(32); + done(); + } + }); + }; + zoomIn(); + }); + + it('handles zooming-out until years zoom level', function(done) { + var promise = Plotly.plot(gd, mockCopy.data, mockCopy.layout); + + var testCount = 0; + + var zoomOut = function() { + promise = promise.then(function() { + getZoomOutButton(gd).click(); + var xLabels = Axes.calcTicks(gd._fullLayout.xaxis); + var formatter = getFormatter(Axes.getTickFormat(gd._fullLayout.xaxis)); + var expectedLabels = xLabels.map(function(d) {return formatter(new Date(d.x));}); + var actualLabels = xLabels.map(function(d) {return d.text;}); + expect(expectedLabels).toEqual(actualLabels); + testCount++; + + if(typeof gd._fullLayout.xaxis.dtick === 'number' || + typeof gd._fullLayout.xaxis.dtick === 'string' && parseInt(gd._fullLayout.xaxis.dtick.replace(/\D/g, '')) < 48) { + zoomOut(); + } else { + // make sure we tested as many levels as we thought we would + expect(testCount).toBe(5); + done(); + } + }); + }; + zoomOut(); + }); + + it('responds to hover', function(done) { + var evt = { xpx: 270, ypx: 10 }; + + Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(function() { + Fx.hover(gd, evt, 'xy'); + + var hoverTrace = gd._hoverdata[0]; + var formatter = getFormatter(Axes.getTickFormat(gd._fullLayout.xaxis)); + + expect(hoverTrace.curveNumber).toEqual(0); + expect(hoverTrace.pointNumber).toEqual(3); + expect(hoverTrace.x).toEqual('2005-04-01'); + expect(hoverTrace.y).toEqual(0); + + expect(d3.selectAll('g.axistext').size()).toEqual(1); + expect(d3.selectAll('g.hovertext').size()).toEqual(1); + expect(d3.selectAll('g.axistext').select('text').html()).toEqual(formatter(new Date(hoverTrace.x))); + expect(d3.selectAll('g.hovertext').select('text').html()).toEqual('0'); + }) + .catch(failTest) + .then(done); + }); + + it('doesn\'t fail on bad input', function(done) { + var promise = Plotly.plot(gd, mockCopy.data, mockCopy.layout); + + [1, {a: 1, b: 2}, 'boo'].forEach(function(v) { + promise = promise.then(function() { + return Plotly.relayout(gd, {'xaxis.tickformatstops': v}); + }).then(function() { + expect(gd._fullLayout.xaxis.tickformatstops).toEqual([]); + }); + }); + + promise + .catch(failTest) + .then(done); + }); +});