diff --git a/src/lib/dates.js b/src/lib/dates.js index c5c05fcfda9..28c3e63d9cb 100644 --- a/src/lib/dates.js +++ b/src/lib/dates.js @@ -451,6 +451,7 @@ function yearMonthDayFormatWorld(cDate) { return cDate.formatDate('M d, yyyy'); * tr: tickround ('y', 'm', 'd', 'M', 'S', or # digits) * used if no explicit fmt is provided * calendar: optional string, the world calendar system to use + * dtick: optional string or number, side of tick for custom formatting * * returns the date/time as a string, potentially with the leading portion * on a separate line (after '\n') @@ -458,13 +459,40 @@ function yearMonthDayFormatWorld(cDate) { return cDate.formatDate('M d, yyyy'); * the axis may choose to strip things after it when they don't change from * one tick to the next (as it does with automatic formatting) */ -exports.formatDate = function(x, fmt, tr, calendar) { +exports.formatDate = function(x, fmt, tr, calendar, dtick) { var headStr, dateStr; calendar = isWorldCalendar(calendar) && calendar; - if(fmt) return modDateFormat(fmt, x, calendar); + if(fmt) { + if(typeof fmt === 'string') return modDateFormat(fmt, x, calendar); + if(typeof fmt === 'object') { + var unit = ''; + if(typeof dtick === 'string') { + if(Number(dtick.replace('M', '')) > 6) { + unit = 'year'; + } else if(Number(dtick.replace('M', '')) >= 1) { + unit = 'month'; + } + } else if(dtick >= ONEDAY * 7) { + unit = 'week'; + } else if(dtick >= ONEDAY) { + unit = 'day'; + } else if(dtick >= ONEHOUR) { + unit = 'hour'; + } else if(dtick >= ONEMIN) { + unit = 'minute'; + } else if(dtick >= ONESEC) { + unit = 'second'; + } else if(dtick >= 0) { + unit = 'millisecond'; + } + if(fmt[unit]) { + return modDateFormat(fmt[unit], x, calendar); + } + } + } if(calendar) { try { diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index 5a2767c0d55..cc269119c81 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -1250,7 +1250,7 @@ function formatDate(ax, out, hover, extraPrecision) { else tr = {y: 'm', m: 'd', d: 'M', M: 'S', S: 4}[tr]; } - var dateStr = Lib.formatDate(out.x, fmt, tr, ax.calendar), + var dateStr = Lib.formatDate(out.x, fmt, tr, ax.calendar, ax.dtick), headStr; var splitIndex = dateStr.indexOf('\n'); diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js index 90855d7452a..a3639d9fbfc 100644 --- a/src/plots/cartesian/layout_attributes.js +++ b/src/plots/cartesian/layout_attributes.js @@ -434,7 +434,7 @@ module.exports = { ].join(' ') }, tickformat: { - valType: 'string', + valType: 'any', dflt: '', role: 'style', description: [ @@ -445,7 +445,10 @@ module.exports = { 'https://github.com/d3/d3-time-format/blob/master/README.md#locale_format', 'We add one item to d3\'s date formatter: *%{n}f* for fractional seconds', 'with n digits. For example, *2016-10-13 09:15:23.456* with tickformat', - '*%H~%M~%S.%2f* would display *09~15~23.46*' + '*%H~%M~%S.%2f* would display *09~15~23.46* Also now you can specify date', + 'format for each zooming level independently using following configuration', + 'object {hour: *%H:%M*, day: *%e %b*}. Available following zooming levels:', + 'year, month, week, day, hour, minute, second, millisecond' ].join(' ') }, hoverformat: { diff --git a/tasks/util/strict_d3.js b/tasks/util/strict_d3.js index 1e51ab8912b..db853e179ee 100644 --- a/tasks/util/strict_d3.js +++ b/tasks/util/strict_d3.js @@ -6,6 +6,7 @@ var pathToStrictD3Module = path.join( constants.pathToImageTest, 'strict-d3.js' ); +var normalizedPathToStrictD3Module = pathToStrictD3Module.replace(/\\/g, '/'); // replacing of "\" for windows users /** * Transform `require('d3')` expressions to `require(/path/to/strict-d3.js)` @@ -18,7 +19,7 @@ module.exports = transformTools.makeRequireTransform('requireTransform', var pathOut; if(pathIn === 'd3' && opts.file !== pathToStrictD3Module) { - pathOut = 'require(\'' + pathToStrictD3Module + '\')'; + pathOut = 'require(\'' + normalizedPathToStrictD3Module + '\')'; } if(pathOut) return cb(null, pathOut); diff --git a/test/image/baselines/custom_tickformat.png b/test/image/baselines/custom_tickformat.png new file mode 100644 index 00000000000..0a7b09e0f46 Binary files /dev/null and b/test/image/baselines/custom_tickformat.png differ diff --git a/test/image/mocks/custom_tickformat.json b/test/image/mocks/custom_tickformat.json new file mode 100644 index 00000000000..8ca46f96fd2 --- /dev/null +++ b/test/image/mocks/custom_tickformat.json @@ -0,0 +1,23 @@ +{ + "data": [ + { + "x": ["2010-01-01","2010-01-02","2010-01-03","2010-01-04","2010-01-05","2010-01-06","2010-01-07"], + "y": [-5,3,-1,7,-1,3,-5] + } + ], + "layout": { + "title": "tickformat", + "xaxis": { + "tickformat": { + "millisecond": "%H:%M:%S.%L ms", + "second": "%H:%M:%S s", + "minute": "%H:%M m", + "hour": "%H:%M h", + "day": "%e %b d", + "week": "%b %d w", + "month": "%b %y M", + "year": "%Y Y" + } + } + } +} diff --git a/test/jasmine/tests/custom_tickformat_test.js b/test/jasmine/tests/custom_tickformat_test.js new file mode 100644 index 00000000000..826e4b0ce7e --- /dev/null +++ b/test/jasmine/tests/custom_tickformat_test.js @@ -0,0 +1,138 @@ +var Plotly = require('@lib/index'); +var Lib = require('@src/lib'); +var Axes = require('@src/plots/cartesian/axes'); +var Fx = require('@src/components/fx'); +var d3 = require('d3'); +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var selectButton = require('../assets/modebar_button'); +var constants = require('@src/constants/numerical'); + +var mock = require('@mocks/custom_tickformat.json'); +var tickFormat = mock.layout.xaxis.tickformat; + +function getZoomInButton(gd) { + return selectButton(gd._fullLayout._modeBar, 'zoomIn2d'); +} + +function getZoomOutButton(gd) { + return selectButton(gd._fullLayout._modeBar, 'zoomOut2d'); +} + +function getFormatter(dtick) { + var unit = ''; + if(typeof dtick === 'string') { + if(Number(dtick.replace('M', '')) > 6) { + unit = 'year'; + } else if(Number(dtick.replace('M', '')) >= 1) { + unit = 'month'; + } + } else if(dtick >= constants.ONEDAY * 7) { + unit = 'week'; + } else if(dtick >= constants.ONEDAY) { + unit = 'day'; + } else if(dtick >= constants.ONEHOUR) { + unit = 'hour'; + } else if(dtick >= constants.ONEMIN) { + unit = 'minute'; + } else if(dtick >= constants.ONESEC) { + unit = 'second'; + } else if(dtick >= 0) { + unit = 'millisecond'; + } + if(tickFormat[unit]) { + return d3.time.format.utc(tickFormat[unit]); + } + return function(mock) {return mock;}; +} + +describe('Test extended tickformat:', function() { + + var mockCopy, gd; + + beforeEach(function() { + gd = createGraphDiv(); + mockCopy = Lib.extendDeep({}, mock); + }); + + afterEach(destroyGraphDiv); + + it('Zooming-in until milliseconds zoom level', function(done) { + var promise = Plotly.plot(gd, mockCopy.data, mockCopy.layout); + + var zoomIn = function() { + promise = promise.then(function() { + getZoomInButton(gd).click(); + var xLabels = Axes.calcTicks(gd._fullLayout.xaxis); + var formatter = getFormatter(gd._fullLayout.xaxis.dtick); + 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); + if(gd._fullLayout.xaxis.dtick > 1) { + zoomIn(); + } else { + done(); + } + }); + }; + zoomIn(); + }); + + it('Zooming-out until years zoom level', function(done) { + var promise = Plotly.plot(gd, mockCopy.data, mockCopy.layout); + + var zoomOut = function() { + promise = promise.then(function() { + getZoomOutButton(gd).click(); + var xLabels = Axes.calcTicks(gd._fullLayout.xaxis); + var formatter = getFormatter(gd._fullLayout.xaxis.dtick); + 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); + if(typeof gd._fullLayout.xaxis.dtick === 'number' || + typeof gd._fullLayout.xaxis.dtick === 'string' && parseInt(gd._fullLayout.xaxis.dtick.replace(/\D/g, '')) < 48) { + zoomOut(); + } else { + done(); + } + }); + }; + zoomOut(); + }); + + describe('Check tickformat for hover', function() { + 'use strict'; + + var evt = { xpx: 270, ypx: 10 }; + + afterEach(destroyGraphDiv); + + beforeEach(function() { + gd = createGraphDiv(); + mockCopy = Lib.extendDeep({}, mock); + }); + + it('tickformat for hover and xaxes should coincide', function(done) { + var mockCopy = Lib.extendDeep({}, mock); + + Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(function() { + Fx.hover(gd, evt, 'xy'); + + var hoverTrace = gd._hoverdata[0]; + var formatter = getFormatter(gd._fullLayout.xaxis.dtick); + + expect(hoverTrace.curveNumber).toEqual(0); + expect(hoverTrace.pointNumber).toEqual(3); + expect(hoverTrace.x).toEqual('2010-01-04'); + expect(hoverTrace.y).toEqual(7); + + 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('7'); + done(); + }); + }); + }); + +});