diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index bd86bb76b06..cb07516caae 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -611,8 +611,6 @@ axes.autoBin = function(data, ax, nbins, is2d) { // in any case, set tickround to # of digits to round tick labels to, // or codes to this effect for log and date scales axes.calcTicks = function calcTicks(ax) { - if(ax.tickmode === 'array') return arrayTicks(ax); - var rng = ax.range.map(ax.r2l); // calculate max number of (auto) ticks to display based on plot size @@ -629,6 +627,11 @@ axes.calcTicks = function calcTicks(ax) { nt = Lib.constrain(ax._length / minPx, 4, 9) + 1; } } + + // add a couple of extra digits for filling in ticks when we + // have explicit tickvals without tick text + if(ax.tickmode === 'array') nt *= 100; + axes.autoTicks(ax, Math.abs(rng[1] - rng[0]) / nt); // check for a forced minimum dtick if(ax._minDtick > 0 && ax.dtick < ax._minDtick * 2) { @@ -645,6 +648,10 @@ axes.calcTicks = function calcTicks(ax) { // now figure out rounding of tick values autoTickRound(ax); + // now that we've figured out the auto values for formatting + // in case we're missing some ticktext, we can break out for array ticks + if(ax.tickmode === 'array') return arrayTicks(ax); + // find the first tick ax._tmin = axes.tickFirst(ax); @@ -672,11 +679,11 @@ axes.calcTicks = function calcTicks(ax) { // show the exponent only on the last one ax._tmax = vals[vals.length - 1]; - // for showing date suffixes: ax._prevSuffix holds what we showed most - // recently. Start with it cleared and mark that we're in calcTicks (ie - // calculating a whole string of these so we should care what the previous - // suffix was!) - ax._prevSuffix = ''; + // for showing the rest of a date when the main tick label is only the + // latter part: ax._prevDateHead holds what we showed most recently. + // Start with it cleared and mark that we're in calcTicks (ie calculating a + // whole string of these so we should care what the previous date head was!) + ax._prevDateHead = ''; ax._inCalcTicks = true; var ticksOut = new Array(vals.length); @@ -704,8 +711,17 @@ function arrayTicks(ax) { // except with more precision to the numbers if(!Array.isArray(text)) text = []; + // make sure showing ticks doesn't accidentally add new categories + var tickVal2l = ax.type === 'category' ? ax.d2l_noadd : ax.d2l; + + // array ticks on log axes always show the full number + // (if no explicit ticktext overrides it) + if(ax.type === 'log' && String(ax.dtick).charAt(0) !== 'L') { + ax.dtick = 'L' + Math.pow(10, Math.floor(Math.min(ax.range[0], ax.range[1])) - 1); + } + for(i = 0; i < vals.length; i++) { - vali = ax.d2l(vals[i]); + vali = tickVal2l(vals[i]); if(vali > tickMin && vali < tickMax) { if(text[i] === undefined) ticksOut[j] = axes.tickText(ax, vali); else ticksOut[j] = tickTextObj(ax, vali, String(text[i])); @@ -1030,13 +1046,14 @@ axes.tickText = function(ax, x, hover) { hideexp, arrayMode = ax.tickmode === 'array', extraPrecision = hover || arrayMode, - i; + i, + tickVal2l = ax.type === 'category' ? ax.d2l_noadd : ax.d2l; if(arrayMode && Array.isArray(ax.ticktext)) { var rng = ax.range.map(ax.r2l), minDiff = Math.abs(rng[1] - rng[0]) / 10000; for(i = 0; i < ax.ticktext.length; i++) { - if(Math.abs(x - ax.d2l(ax.tickvals[i])) < minDiff) break; + if(Math.abs(x - tickVal2l(ax.tickvals[i])) < minDiff) break; } if(i < ax.ticktext.length) { out.text = String(ax.ticktext[i]); @@ -1089,12 +1106,11 @@ function tickTextObj(ax, x, text) { function formatDate(ax, out, hover, extraPrecision) { var x = out.x, tr = ax._tickround, - trOriginal = tr, d = new Date(x), - // suffix completes the full date info, to be included + // headPart completes the full date info, to be included // with only the first tick or if any info before what's // shown has changed - suffix, + headPart, tt; if(hover && ax.hoverformat) { tt = modDateFormat(ax.hoverformat, x); @@ -1113,12 +1129,12 @@ function formatDate(ax, out, hover, extraPrecision) { else if(tr === 'm') tt = monthFormat(d); else { if(tr === 'd') { - if(!hover) suffix = '
' + yearFormat(d); + headPart = yearFormat(d); tt = dayFormat(d); } else { - if(!hover) suffix = '
' + yearMonthDayFormat(d); + headPart = yearMonthDayFormat(d); tt = minuteFormat(d); if(tr !== 'M') { @@ -1128,17 +1144,34 @@ function formatDate(ax, out, hover, extraPrecision) { .substr(1); } } - else if(trOriginal === 'd') { - // for hover on axes with day ticks, minuteFormat (which - // only includes %H:%M) isn't enough, you want the date too - tt = dayFormat(d) + ' ' + tt; - } } } } - if(suffix && (!ax._inCalcTicks || (suffix !== ax._prevSuffix))) { - tt += suffix; - ax._prevSuffix = suffix; + if(hover || ax.tickmode === 'array') { + // we get extra precision in array mode or hover, + // but it may be useless, strip it off + if(tt === '00:00:00' || tt === '00:00') { + tt = headPart; + headPart = ''; + } + else if(tt.length === 8) { + // strip off seconds if they're zero (zero fractional seconds + // are already omitted) + tt = tt.replace(/:00$/, ''); + } + } + + if(headPart) { + if(hover) { + // hover puts it all on one line, so headPart works best up front + // except for year headPart: turn this into "Jan 1, 2000" etc. + if(tr === 'd') tt += ', ' + headPart; + else tt = headPart + (tt ? ', ' + tt : ''); + } + else if(!ax._inCalcTicks || (headPart !== ax._prevDateHead)) { + tt += '
' + headPart; + ax._prevDateHead = headPart; + } } out.text = tt; } diff --git a/src/plots/cartesian/set_convert.js b/src/plots/cartesian/set_convert.js index e4fae71290f..69a24545b40 100644 --- a/src/plots/cartesian/set_convert.js +++ b/src/plots/cartesian/set_convert.js @@ -313,6 +313,14 @@ module.exports = function setConvert(ax) { return c === -1 ? BADNUM : c; }; + ax.d2l_noadd = function(v) { + // d2c variant that that won't add categories but will also + // allow numbers to be mapped to the linearized axis positions + var index = ax._categories.indexOf(v); + if(index !== -1) return index; + if(typeof v === 'number') return v; + }; + ax.d2l = ax.d2c; ax.r2l = num; ax.l2r = num; diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index 90433bd53af..897e9512626 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -1393,7 +1393,7 @@ describe('Test axes', function() { }); }); - describe('calcTicks', function() { + describe('calcTicks and tickText', function() { function mockCalc(ax) { Axes.setConvert(ax); ax.tickfont = {}; @@ -1401,14 +1401,28 @@ describe('Test axes', function() { return Axes.calcTicks(ax).map(function(v) { return v.text; }); } + function mockHoverText(ax, x) { + var xCalc = (ax.d2l_noadd || ax.d2c)(x); + var tickTextObj = Axes.tickText(ax, xCalc, true); + return tickTextObj.text; + } + + function checkHovers(ax, specArray) { + specArray.forEach(function(v) { + expect(mockHoverText(ax, v[0])) + .toBe(v[1], ax.dtick + ' - ' + v[0]); + }); + } + it('provides a new date suffix whenever the suffix changes', function() { - var textOut = mockCalc({ + var ax = { type: 'date', tickmode: 'linear', tick0: '2000-01-01', dtick: 14 * 24 * 3600 * 1000, // 14 days range: ['1999-12-01', '2000-02-15'] - }); + }; + var textOut = mockCalc(ax); var expectedText = [ 'Dec 4
1999', @@ -1419,14 +1433,17 @@ describe('Test axes', function() { 'Feb 12' ]; expect(textOut).toEqual(expectedText); + expect(mockHoverText(ax, ax.d2c('1999-12-18 15:34:33.3'))) + .toBe('Dec 18, 1999, 15:34'); - textOut = mockCalc({ + ax = { type: 'date', tickmode: 'linear', tick0: '2000-01-01', dtick: 12 * 3600 * 1000, // 12 hours range: ['2000-01-03 11:00', '2000-01-06'] - }); + }; + textOut = mockCalc(ax); expectedText = [ '12:00
Jan 3, 2000', @@ -1437,14 +1454,17 @@ describe('Test axes', function() { '00:00
Jan 6, 2000' ]; expect(textOut).toEqual(expectedText); + expect(mockHoverText(ax, ax.d2c('2000-01-04 15:34:33.3'))) + .toBe('Jan 4, 2000, 15:34:33'); - textOut = mockCalc({ + ax = { type: 'date', tickmode: 'linear', tick0: '2000-01-01', dtick: 1000, // 1 sec range: ['2000-02-03 23:59:57', '2000-02-04 00:00:02'] - }); + }; + textOut = mockCalc(ax); expectedText = [ '23:59:57
Feb 3, 2000', @@ -1455,16 +1475,21 @@ describe('Test axes', function() { '00:00:02' ]; expect(textOut).toEqual(expectedText); + expect(mockHoverText(ax, ax.d2c('2000-02-04 00:00:00.123456'))) + .toBe('Feb 4, 2000, 00:00:00.1235'); + expect(mockHoverText(ax, ax.d2c('2000-02-04 00:00:00'))) + .toBe('Feb 4, 2000'); }); it('should give dates extra precision if tick0 is weird', function() { - var textOut = mockCalc({ + var ax = { type: 'date', tickmode: 'linear', tick0: '2000-01-01 00:05', dtick: 14 * 24 * 3600 * 1000, // 14 days range: ['1999-12-01', '2000-02-15'] - }); + }; + var textOut = mockCalc(ax); var expectedText = [ '00:05
Dec 4, 1999', @@ -1475,16 +1500,21 @@ describe('Test axes', function() { '00:05
Feb 12, 2000' ]; expect(textOut).toEqual(expectedText); + expect(mockHoverText(ax, ax.d2c('2000-02-04 00:00:00.123456'))) + .toBe('Feb 4, 2000'); + expect(mockHoverText(ax, ax.d2c('2000-02-04 00:00:05.123456'))) + .toBe('Feb 4, 2000, 00:00:05'); }); it('should never give dates more than 100 microsecond precision', function() { - var textOut = mockCalc({ + var ax = { type: 'date', tickmode: 'linear', tick0: '2000-01-01', dtick: 1.1333, range: ['2000-01-01', '2000-01-01 00:00:00.01'] - }); + }; + var textOut = mockCalc(ax); var expectedText = [ '00:00:00
Jan 1, 2000', @@ -1499,5 +1529,185 @@ describe('Test axes', function() { ]; expect(textOut).toEqual(expectedText); }); + + it('should handle edge cases with dates and tickvals', function() { + var ax = { + type: 'date', + tickmode: 'array', + tickvals: [ + '2012-01-01', + new Date(2012, 2, 1).getTime(), + '2012-08-01 00:00:00', + '2012-10-01 12:00:00', + new Date(2013, 0, 1, 0, 0, 1).getTime(), + '2010-01-01', '2014-01-01' // off the axis + ], + // only the first two have text + ticktext: ['New year', 'February'], + + // required to get calcTicks to run + range: ['2011-12-10', '2013-01-23'], + nticks: 10 + }; + var textOut = mockCalc(ax); + + var expectedText = [ + 'New year', + 'February', + 'Aug 1, 2012', + '12:00
Oct 1, 2012', + '00:00:01
Jan 1, 2013' + ]; + expect(textOut).toEqual(expectedText); + expect(mockHoverText(ax, ax.d2c('2012-01-01'))) + .toBe('New year'); + expect(mockHoverText(ax, ax.d2c('2012-01-01 12:34:56.1234'))) + .toBe('Jan 1, 2012, 12:34:56'); + }); + + it('should handle tickvals edge cases with linear and log axes', function() { + ['linear', 'log'].forEach(function(axType) { + var ax = { + type: axType, + tickmode: 'array', + tickvals: [1, 1.5, 2.6999999, 30, 39.999, 100, 0.1], + ticktext: ['One', '...and a half'], + // I'll be so happy when I can finally get rid of this switch! + range: axType === 'log' ? [-0.2, 1.8] : [0.5, 50], + nticks: 10 + }; + var textOut = mockCalc(ax); + + var expectedText = [ + 'One', + '...and a half', // the first two get explicit labels + '2.7', // 2.6999999 gets rounded to 2.7 + '30', + '39.999' // 39.999 does not get rounded + // 10 and 0.1 are off scale + ]; + expect(textOut).toEqual(expectedText, axType); + expect(mockHoverText(ax, ax.c2l(1))).toBe('One'); + expect(mockHoverText(ax, ax.c2l(19.999))).toBe('19.999'); + }); + }); + + it('should handle tickvals edge cases with category axes', function() { + var ax = { + type: 'category', + _categories: ['a', 'b', 'c', 'd'], + tickmode: 'array', + tickvals: ['a', 1, 1.5, 'c', 2.7, 3, 'e', 4, 5, -2], + ticktext: ['A!', 'B?', 'B->C'], + range: [-0.5, 4.5], + nticks: 10 + }; + var textOut = mockCalc(ax); + + var expectedText = [ + 'A!', // category position, explicit text + 'B?', // integer position, explicit text + 'B->C', // non-integer position, explicit text + 'c', // category position, no text: use category + 'd', // non-integer position, no text: use closest category + 'd', // integer position, no text: use category + '' // 4: number with no close category: leave blank + // but still include it so we get a tick mark & grid + // 'e', 5, -2: bad category and numbers out of range: omitted + ]; + expect(textOut).toEqual(expectedText); + expect(mockHoverText(ax, 0)).toBe('A!'); + expect(mockHoverText(ax, 2)).toBe('c'); + expect(mockHoverText(ax, 4)).toBe(''); + + // make sure we didn't add any more categories accidentally + expect(ax._categories).toEqual(['a', 'b', 'c', 'd']); + }); + + it('should always start at year for date axis hover', function() { + var ax = { + type: 'date', + tickmode: 'linear', + tick0: '2000-01-01', + dtick: 'M1200', + range: ['1000-01-01', '3000-01-01'], + nticks: 10 + }; + mockCalc(ax); + + checkHovers(ax, [ + ['2000-01-01', 'Jan 2000'], + ['2000-01-01 11:00', 'Jan 2000'], + ['2000-01-01 11:14', 'Jan 2000'], + ['2000-01-01 11:00:15', 'Jan 2000'], + ['2000-01-01 11:00:00.1', 'Jan 2000'], + ['2000-01-01 11:00:00.0001', 'Jan 2000'] + ]); + + ax.dtick = 'M1'; + ax.range = ['1999-06-01', '2000-06-01']; + mockCalc(ax); + + checkHovers(ax, [ + ['2000-01-01', 'Jan 1, 2000'], + ['2000-01-01 11:00', 'Jan 1, 2000'], + ['2000-01-01 11:14', 'Jan 1, 2000'], + ['2000-01-01 11:00:15', 'Jan 1, 2000'], + ['2000-01-01 11:00:00.1', 'Jan 1, 2000'], + ['2000-01-01 11:00:00.0001', 'Jan 1, 2000'] + ]); + + ax.dtick = 24 * 3600000; // one day + ax.range = ['1999-12-15', '2000-01-15']; + mockCalc(ax); + + checkHovers(ax, [ + ['2000-01-01', 'Jan 1, 2000'], + ['2000-01-01 11:00', 'Jan 1, 2000, 11:00'], + ['2000-01-01 11:14', 'Jan 1, 2000, 11:14'], + ['2000-01-01 11:00:15', 'Jan 1, 2000, 11:00'], + ['2000-01-01 11:00:00.1', 'Jan 1, 2000, 11:00'], + ['2000-01-01 11:00:00.0001', 'Jan 1, 2000, 11:00'] + ]); + + ax.dtick = 3600000; // one hour + ax.range = ['1999-12-31', '2000-01-02']; + mockCalc(ax); + + checkHovers(ax, [ + ['2000-01-01', 'Jan 1, 2000'], + ['2000-01-01 11:00', 'Jan 1, 2000, 11:00'], + ['2000-01-01 11:14', 'Jan 1, 2000, 11:14'], + ['2000-01-01 11:00:15', 'Jan 1, 2000, 11:00:15'], + ['2000-01-01 11:00:00.1', 'Jan 1, 2000, 11:00'], + ['2000-01-01 11:00:00.0001', 'Jan 1, 2000, 11:00'] + ]); + + ax.dtick = 60000; // one minute + ax.range = ['1999-12-31 23:00', '2000-01-01 01:00']; + mockCalc(ax); + + checkHovers(ax, [ + ['2000-01-01', 'Jan 1, 2000'], + ['2000-01-01 11:00', 'Jan 1, 2000, 11:00'], + ['2000-01-01 11:14', 'Jan 1, 2000, 11:14'], + ['2000-01-01 11:00:15', 'Jan 1, 2000, 11:00:15'], + ['2000-01-01 11:00:00.1', 'Jan 1, 2000, 11:00'], + ['2000-01-01 11:00:00.0001', 'Jan 1, 2000, 11:00'] + ]); + + ax.dtick = 1000; // one second + ax.range = ['1999-12-31 23:59', '2000-01-01 00:01']; + mockCalc(ax); + + checkHovers(ax, [ + ['2000-01-01', 'Jan 1, 2000'], + ['2000-01-01 11:00', 'Jan 1, 2000, 11:00'], + ['2000-01-01 11:14', 'Jan 1, 2000, 11:14'], + ['2000-01-01 11:00:15', 'Jan 1, 2000, 11:00:15'], + ['2000-01-01 11:00:00.1', 'Jan 1, 2000, 11:00:00.1'], + ['2000-01-01 11:00:00.0001', 'Jan 1, 2000, 11:00:00.0001'] + ]); + }); }); });