diff --git a/src/core/core.scale.js b/src/core/core.scale.js index 8f532ca754f..1fedb68065e 100644 --- a/src/core/core.scale.js +++ b/src/core/core.scale.js @@ -201,6 +201,76 @@ function parseTickFontOptions(options) { return {minor: minor, major: major}; } +function calculateSpacing(majorIndices, ticks, axisLength, ticksLimit) { + var evenMajorSpacing = majorIndices.length > 1 ? majorIndices.reduce(function(acc, val, idx, arr) { + var diff = idx === 0 ? acc : arr[idx] - arr[idx - 1]; + return acc && acc === diff ? diff : false; + }, majorIndices[1] - majorIndices[0]) : false; + var spacing = (ticks.length - 1) / ticksLimit; + var factors, factor, i, ilen; + + // If the major ticks are evenly spaced apart, place the minor ticks + // so that they divide the major ticks into even chunks + if (evenMajorSpacing) { + factors = helpers.math._factorize(evenMajorSpacing); + for (i = 0, ilen = factors.length - 1; i < ilen; i++) { + factor = factors[i]; + if (factor > spacing) { + return factor; + } + } + } + return Math.max(spacing, 1); +} + +function getMajorIndices(ticks) { + var result = []; + var i, ilen; + for (i = 0, ilen = ticks.length; i < ilen; i++) { + if (ticks[i].major) { + result.push(i); + } + } + return result; +} + +function skipMajors(ticks, majorIndices, spacing) { + var ticksToKeep = {}; + var i; + + spacing = Math.ceil(spacing); + for (i = 0; i < majorIndices.length; i += spacing) { + ticksToKeep[majorIndices[i]] = 1; + } + for (i = 0; i < ticks.length; i++) { + if (!ticksToKeep[i]) { + delete ticks[i].label; + } + } +} + +function skip(ticks, spacing, majorStart, majorEnd) { + var ticksToKeep = {}; + var start = valueOrDefault(majorStart, 0); + var end = Math.min(valueOrDefault(majorEnd, ticks.length), ticks.length); + var length, i, tick; + + spacing = Math.ceil(spacing); + if (majorEnd) { + length = majorEnd - majorStart; + spacing = length / Math.floor(length / spacing); + } + for (i = 0, tick = start; tick < end; i++) { + tick = Math.round(start + i * spacing); + ticksToKeep[tick] = 1; + } + for (i = Math.max(start, 0); i < end; i++) { + if (!ticksToKeep[i]) { + delete ticks[i].label; + } + } +} + var Scale = Element.extend({ /** * Get the padding needed for the scale @@ -355,7 +425,7 @@ var Scale = Element.extend({ me.fit(); me.afterFit(); // Auto-skip - me._ticksToDraw = tickOpts.display && tickOpts.autoSkip ? me._autoSkip(me._ticks) : me._ticks; + me._ticksToDraw = tickOpts.display && (tickOpts.autoSkip || tickOpts.source === 'auto') ? me._autoSkip(me._ticks) : me._ticks; me.afterUpdate(); @@ -806,39 +876,33 @@ var Scale = Element.extend({ */ _autoSkip: function(ticks) { var me = this; - var optionTicks = me.options.ticks; - var tickCount = ticks.length; - var skipRatio = false; - var maxTicks = optionTicks.maxTicksLimit; - - // Total space needed to display all ticks. First and last ticks are - // drawn as their center at end of axis, so tickCount-1 - var ticksLength = me._tickSize() * (tickCount - 1); - + var tickOpts = me.options.ticks; var axisLength = me._length; - var result = []; - var i, tick; - - if (ticksLength > axisLength) { - skipRatio = 1 + Math.floor(ticksLength / axisLength); + var ticksLimit = tickOpts.maxTicksLimit || axisLength / me._tickSize() + 1; + var majorIndices = tickOpts.major.enabled ? getMajorIndices(ticks) : []; + var first = majorIndices[0]; + var last = majorIndices[majorIndices.length - 1]; + var i, ilen, spacing, avgMajorSpacing; + + // If there are too many major ticks to display them all + if (majorIndices.length > ticksLimit) { + skipMajors(ticks, majorIndices, majorIndices.length / ticksLimit); + return ticks; } - // if they defined a max number of optionTicks, - // increase skipRatio until that number is met - if (tickCount > maxTicks) { - skipRatio = Math.max(skipRatio, 1 + Math.floor(tickCount / maxTicks)); - } + spacing = calculateSpacing(majorIndices, ticks, axisLength, ticksLimit); - for (i = 0; i < tickCount; i++) { - tick = ticks[i]; - - if (skipRatio > 1 && i % skipRatio > 0) { - // leave tick in place but make sure it's not displayed (#4635) - delete tick.label; + if (majorIndices.length > 0) { + for (i = 0, ilen = majorIndices.length - 1; i < ilen; i++) { + skip(ticks, spacing, majorIndices[i], majorIndices[i + 1]); } - result.push(tick); + avgMajorSpacing = majorIndices.length > 1 ? (last - first) / (majorIndices.length - 1) : null; + skip(ticks, spacing, helpers.isNullOrUndef(avgMajorSpacing) ? 0 : first - avgMajorSpacing, first); + skip(ticks, spacing, last, helpers.isNullOrUndef(avgMajorSpacing) ? ticks.length : last + avgMajorSpacing); + return ticks; } - return result; + skip(ticks, spacing); + return ticks; }, /** diff --git a/src/scales/scale.time.js b/src/scales/scale.time.js index 9b29916c8d3..b2c9cf9f8c3 100644 --- a/src/scales/scale.time.js +++ b/src/scales/scale.time.js @@ -5,8 +5,8 @@ var defaults = require('../core/core.defaults'); var helpers = require('../helpers/index'); var Scale = require('../core/core.scale'); +var resolve = helpers.options.resolve; var valueOrDefault = helpers.valueOrDefault; -var factorize = helpers.math._factorize; // Integer constants are from the ES6 spec. var MIN_INTEGER = Number.MIN_SAFE_INTEGER || -9007199254740991; @@ -16,42 +16,42 @@ var INTERVALS = { millisecond: { common: true, size: 1, - steps: factorize(1000) + steps: 1000 }, second: { common: true, size: 1000, - steps: factorize(60) + steps: 60 }, minute: { common: true, size: 60000, - steps: factorize(60) + steps: 60 }, hour: { common: true, size: 3600000, - steps: factorize(24) + steps: 24 }, day: { common: true, size: 86400000, - steps: factorize(10) + steps: 30 }, week: { common: false, size: 604800000, - steps: factorize(4) + steps: 4 }, month: { common: true, size: 2.628e9, - steps: factorize(12) + steps: 12 }, quarter: { common: false, size: 7.884e9, - steps: factorize(4) + steps: 4 }, year: { common: true, @@ -248,31 +248,6 @@ function parse(scale, input) { return value; } -/** - * Returns the number of unit to skip to be able to display up to `capacity` number of ticks - * in `unit` for the given `min` / `max` range and respecting the interval steps constraints. - */ -function determineStepSize(min, max, unit, capacity) { - var range = max - min; - var interval = INTERVALS[unit]; - var milliseconds = interval.size; - var steps = interval.steps; - var i, ilen, factor; - - if (!steps) { - return Math.ceil(range / (capacity * milliseconds)); - } - - for (i = 0, ilen = steps.length; i < ilen; ++i) { - factor = steps[i]; - if (Math.ceil(range / (milliseconds * factor)) <= capacity) { - break; - } - } - - return factor; -} - /** * Figures out what unit results in an appropriate number of auto-generated ticks */ @@ -282,7 +257,7 @@ function determineUnitForAutoTicks(minUnit, min, max, capacity) { for (i = UNITS.indexOf(minUnit); i < ilen - 1; ++i) { interval = INTERVALS[UNITS[i]]; - factor = interval.steps ? interval.steps[interval.steps.length - 1] : MAX_INTEGER; + factor = interval.steps ? interval.steps / 2 : MAX_INTEGER; if (interval.common && Math.ceil((max - min) / (factor * interval.size)) <= capacity) { return UNITS[i]; @@ -296,10 +271,9 @@ function determineUnitForAutoTicks(minUnit, min, max, capacity) { * Figures out what unit to format a set of ticks with */ function determineUnitForFormatting(scale, ticks, minUnit, min, max) { - var ilen = UNITS.length; var i, unit; - for (i = ilen - 1; i >= UNITS.indexOf(minUnit); i--) { + for (i = UNITS.length - 1; i >= UNITS.indexOf(minUnit); i--) { unit = UNITS[i]; if (INTERVALS[unit].common && scale._adapter.diff(max, min, unit) >= ticks.length - 1) { return unit; @@ -309,17 +283,9 @@ function determineUnitForFormatting(scale, ticks, minUnit, min, max) { return UNITS[minUnit ? UNITS.indexOf(minUnit) : 0]; } -function determineMajorUnit(unit) { - for (var i = UNITS.indexOf(unit) + 1, ilen = UNITS.length; i < ilen; ++i) { - if (INTERVALS[UNITS[i]].common) { - return UNITS[i]; - } - } -} - /** * Generates a maximum of `capacity` timestamps between min and max, rounded to the - * `minor` unit, aligned on the `major` unit and using the given scale time `options`. + * `minor` unit using the given scale time `options`. * Important: this method can return ticks outside the min and max range, it's the * responsibility of the calling code to clamp values if needed. */ @@ -328,51 +294,33 @@ function generate(scale, min, max, capacity) { var options = scale.options; var timeOpts = options.time; var minor = timeOpts.unit || determineUnitForAutoTicks(timeOpts.minUnit, min, max, capacity); - var major = determineMajorUnit(minor); - var stepSize = valueOrDefault(timeOpts.stepSize, timeOpts.unitStepSize); + var stepSize = resolve([timeOpts.stepSize, timeOpts.unitStepSize, 1]); var weekday = minor === 'week' ? timeOpts.isoWeekday : false; - var majorTicksEnabled = options.ticks.major.enabled; - var interval = INTERVALS[minor]; var first = min; - var last = max; var ticks = []; var time; - if (!stepSize) { - stepSize = determineStepSize(min, max, minor, capacity); - } - // For 'week' unit, handle the first day of week option if (weekday) { first = +adapter.startOf(first, 'isoWeek', weekday); - last = +adapter.startOf(last, 'isoWeek', weekday); } - // Align first/last ticks on unit + // Align first ticks on unit first = +adapter.startOf(first, weekday ? 'day' : minor); - last = +adapter.startOf(last, weekday ? 'day' : minor); - // Make sure that the last tick include max - if (last < max) { - last = +adapter.add(last, 1, minor); + // Prevent browser from freezing in case user options request millions of milliseconds + if (adapter.diff(max, min, minor) > 100000 * stepSize) { + throw min + ' and ' + max + ' are too far apart with stepSize of ' + stepSize + ' ' + minor; } - time = first; - - if (majorTicksEnabled && major && !weekday && !timeOpts.round) { - // Align the first tick on the previous `minor` unit aligned on the `major` unit: - // we first aligned time on the previous `major` unit then add the number of full - // stepSize there is between first and the previous major time. - time = +adapter.startOf(time, major); - time = +adapter.add(time, ~~((first - time) / (interval.size * stepSize)) * stepSize, minor); + for (time = first; time < max; time = +adapter.add(time, stepSize, minor)) { + ticks.push(time); } - for (; time < last; time = +adapter.add(time, stepSize, minor)) { - ticks.push(+time); + if (time === max || options.bounds === 'ticks') { + ticks.push(time); } - ticks.push(+time); - return ticks; } @@ -609,18 +557,17 @@ module.exports = Scale.extend({ var timeOpts = options.time; var timestamps = me._timestamps; var ticks = []; + var capacity = me.getLabelCapacity(min); + var source = options.ticks.source; + var distribution = options.distribution; var i, ilen, timestamp; - switch (options.ticks.source) { - case 'data': + if (source === 'data' || (source === 'auto' && distribution === 'series')) { timestamps = timestamps.data; - break; - case 'labels': + } else if (source === 'labels') { timestamps = timestamps.labels; - break; - case 'auto': - default: - timestamps = generate(me, min, max, me.getLabelCapacity(min), options); + } else { + timestamps = generate(me, min, max, capacity, options); } if (options.bounds === 'ticks' && timestamps.length) { @@ -645,8 +592,11 @@ module.exports = Scale.extend({ // PRIVATE me._unit = timeOpts.unit || determineUnitForFormatting(me, ticks, timeOpts.minUnit, me.min, me.max); - me._majorUnit = determineMajorUnit(me._unit); - me._table = buildLookupTable(me._timestamps.data, min, max, options.distribution); + // Make sure the major unit fits. Usually it will just be the next largest unit + // But if you have a lot of ticks it could be larger. E.g. if you have 8000 day ticks the majorUnit may be year + me._majorUnit = !options.ticks.major.enabled || me._unit === 'year' ? undefined + : determineUnitForAutoTicks(UNITS[UNITS.indexOf(me._unit) + 1], me.min, me.max, capacity); + me._table = buildLookupTable(me._timestamps.data, min, max, distribution); me._offsets = computeOffsets(me._table, ticks, min, max, options); if (options.ticks.reverse) { @@ -690,11 +640,10 @@ module.exports = Scale.extend({ var majorFormat = formats[majorUnit]; var tick = ticks[index]; var tickOpts = options.ticks; - var majorTickOpts = tickOpts.major; - var major = majorTickOpts.enabled && majorUnit && majorFormat && tick && tick.major; + var major = majorUnit && majorFormat && tick && tick.major; var label = adapter.format(time, format ? format : major ? majorFormat : minorFormat); - var nestedTickOpts = major ? majorTickOpts : tickOpts.minor; - var formatter = helpers.options.resolve([ + var nestedTickOpts = major ? tickOpts.major : tickOpts.minor; + var formatter = resolve([ nestedTickOpts.callback, nestedTickOpts.userCallback, tickOpts.callback, diff --git a/test/specs/scale.time.tests.js b/test/specs/scale.time.tests.js index 08292521297..071f49259a2 100755 --- a/test/specs/scale.time.tests.js +++ b/test/specs/scale.time.tests.js @@ -617,21 +617,22 @@ describe('Time scale tests', function() { }); it('should build the correct ticks', function() { - // Where 'correct' is a two year spacing. - expect(getTicksLabels(this.scale)).toEqual(['2005', '2007', '2009', '2011', '2013', '2015', '2017', '2019']); + expect(getTicksLabels(this.scale)).toEqual(['2005', '2006', '2007', '2008', '2009', '2010', '2011', '2012', '2013', '2014', '2015', '2016', '2017', '2018']); }); it('should have ticks with accurate labels', function() { var scale = this.scale; var ticks = scale.getTicks(); - var pixelsPerYear = scale.width / 14; + // pixelsPerTick is an aproximation which assumes same number of milliseconds per year (not true) + // we use a threshold of 1 day so that we still match these values + var pixelsPerTick = scale.width / (ticks.length - 1); for (var i = 0; i < ticks.length - 1; i++) { - var offset = 2 * pixelsPerYear * i; + var offset = pixelsPerTick * i; expect(scale.getValueForPixel(scale.left + offset)).toBeCloseToTime({ value: moment(ticks[i].label + '-01-01'), unit: 'day', - threshold: 0.5, + threshold: 1, }); } }); @@ -700,10 +701,9 @@ describe('Time scale tests', function() { it('should get the correct labels for ticks', function() { var scale = this.scale; - expect(scale._ticks.map(function(tick) { - return tick.major; - })).toEqual([true, false, false, false, false, false, true]); - expect(scale.ticks).toEqual(['<8:00:00>', '<8:00:10>', '<8:00:20>', '<8:00:30>', '<8:00:40>', '<8:00:50>', '<8:01:00>']); + expect(scale.ticks.length).toEqual(61); + expect(scale.ticks[0]).toEqual('<8:00:00>'); + expect(scale.ticks[scale.ticks.length - 1]).toEqual('<8:01:00>'); }); it('should update ticks.callback correctly', function() { @@ -714,7 +714,9 @@ describe('Time scale tests', function() { return '{' + value + '}'; }; chart.update(); - expect(scale.ticks).toEqual(['{8:00:00}', '{8:00:10}', '{8:00:20}', '{8:00:30}', '{8:00:40}', '{8:00:50}', '{8:01:00}']); + expect(scale.ticks.length).toEqual(61); + expect(scale.ticks[0]).toEqual('{8:00:00}'); + expect(scale.ticks[scale.ticks.length - 1]).toEqual('{8:01:00}'); }); }); @@ -760,10 +762,9 @@ describe('Time scale tests', function() { it('should get the correct labels for major and minor ticks', function() { var scale = this.scale; - expect(scale._ticks.map(function(tick) { - return tick.major; - })).toEqual([true, false, false, false, false, false, true]); - expect(scale.ticks).toEqual(['[[8:00 pm]]', '(8:00:10 pm)', '(8:00:20 pm)', '(8:00:30 pm)', '(8:00:40 pm)', '(8:00:50 pm)', '[[8:01 pm]]']); + expect(scale.ticks.length).toEqual(61); + expect(scale.ticks[0]).toEqual('[[8:00 pm]]'); + expect(scale.ticks[scale.ticks.length - 1]).toEqual('[[8:01 pm]]'); }); it('should only use ticks.minor callback if ticks.major.enabled is false', function() { @@ -772,7 +773,9 @@ describe('Time scale tests', function() { chart.options.scales.xAxes[0].ticks.major.enabled = false; chart.update(); - expect(scale.ticks).toEqual(['(8:00:00 pm)', '(8:00:10 pm)', '(8:00:20 pm)', '(8:00:30 pm)', '(8:00:40 pm)', '(8:00:50 pm)', '(8:01:00 pm)']); + expect(scale.ticks.length).toEqual(61); + expect(scale.ticks[0]).toEqual('(8:00:00 pm)'); + expect(scale.ticks[scale.ticks.length - 1]).toEqual('(8:01:00 pm)'); }); it('should use ticks.callback if ticks.major.callback is omitted', function() { @@ -781,7 +784,9 @@ describe('Time scale tests', function() { chart.options.scales.xAxes[0].ticks.major.callback = undefined; chart.update(); - expect(scale.ticks).toEqual(['<8:00 pm>', '(8:00:10 pm)', '(8:00:20 pm)', '(8:00:30 pm)', '(8:00:40 pm)', '(8:00:50 pm)', '<8:01 pm>']); + expect(scale.ticks.length).toEqual(61); + expect(scale.ticks[0]).toEqual('<8:00 pm>'); + expect(scale.ticks[scale.ticks.length - 1]).toEqual('<8:01 pm>'); }); it('should use ticks.callback if ticks.minor.callback is omitted', function() { @@ -790,7 +795,9 @@ describe('Time scale tests', function() { chart.options.scales.xAxes[0].ticks.minor.callback = undefined; chart.update(); - expect(scale.ticks).toEqual(['[[8:00 pm]]', '<8:00:10 pm>', '<8:00:20 pm>', '<8:00:30 pm>', '<8:00:40 pm>', '<8:00:50 pm>', '[[8:01 pm]]']); + expect(scale.ticks.length).toEqual(61); + expect(scale.ticks[0]).toEqual('[[8:00 pm]]'); + expect(scale.ticks[scale.ticks.length - 1]).toEqual('[[8:01 pm]]'); }); });