]> git.ipfire.org Git - thirdparty/Chart.js.git/commitdiff
New time scale `ticks.mode/.source` options (#4507)
authorSimon Brunel <simonbrunel@users.noreply.github.com>
Wed, 19 Jul 2017 19:04:15 +0000 (21:04 +0200)
committerGitHub <noreply@github.com>
Wed, 19 Jul 2017 19:04:15 +0000 (21:04 +0200)
`ticks.source` (`'auto'`(default)|`'labels'`): `auto` generates "optimal" ticks based on min, max and a few more options (current `time` implementation`). `labels` generates ticks from the user given `data.labels` values (two additional trailing and leading ticks can be added if min and max are provided).

`ticks.mode` (`'linear'`(default)|`series`): `series` displays ticks at the same distance from each other, whatever the time value they represent, while `linear` displays them linearly in time: the distance between each tick represent the amount of time between their time values.

src/helpers/helpers.time.js
src/scales/scale.time.js
test/specs/scale.time.tests.js

index f01dfaecd402d9a289e86968951ad3fe5b5d0e2f..b667df54c8a428251eafa9b8f1ded1ed0b19b208 100644 (file)
@@ -3,6 +3,8 @@
 var moment = require('moment');
 moment = typeof(moment) === 'function' ? moment : window.moment;
 
+var helpers = require('./helpers.core');
+
 var interval = {
        millisecond: {
                size: 1,
@@ -53,7 +55,7 @@ function generateTicksNiceRange(options, dataRange, niceRange) {
        var ticks = [];
        if (options.maxTicks) {
                var stepSize = options.stepSize;
-               var startTick = options.min !== undefined ? options.min : niceRange.min;
+               var startTick = helpers.isNullOrUndef(options.min)? niceRange.min : options.min;
                var majorUnit = options.majorUnit;
                var majorUnitStart = majorUnit ? moment(startTick).add(1, majorUnit).startOf(majorUnit) : startTick;
                var startRange = majorUnitStart.valueOf() - startTick;
index 41d022a7938ed6a95f7f38a8b83bc1d9883d3ef4..93e49887b03dc2eb46b3f76aa634c9e753cdca9a 100644 (file)
@@ -7,10 +7,132 @@ moment = typeof(moment) === 'function' ? moment : window.moment;
 var defaults = require('../core/core.defaults');
 var helpers = require('../helpers/index');
 
+function sorter(a, b) {
+       return a - b;
+}
+
+/**
+ * Returns an array of {time, pos} objects used to interpolate a specific `time` or position
+ * (`pos`) on the scale, by searching entries before and after the requested value. `pos` is
+ * a decimal between 0 and 1: 0 being the start of the scale (left or top) and 1 the other
+ * extremity (left + width or top + height). Note that it would be more optimized to directly
+ * store pre-computed pixels, but the scale dimensions are not guaranteed at the time we need
+ * to create the lookup table. The table ALWAYS contains at least two items: min and max.
+ *
+ * @param {Number[]} timestamps - timestamps sorted from lowest to highest.
+ * @param {Boolean} linear - If true, timestamps will be spread linearly along the min/max
+ * range, so basically, the table will contains only two items: {min, 0} and {max, 1}. If
+ * false, timestamps will be positioned at the same distance from each other. In this case,
+ * only timestamps that break the time linearity are registered, meaning that in the best
+ * case, all timestamps are linear, the table contains only min and max.
+ */
+function buildLookupTable(timestamps, min, max, linear) {
+       if (linear || !timestamps.length) {
+               return [
+                       {time: min, pos: 0},
+                       {time: max, pos: 1}
+               ];
+       }
+
+       var table = [];
+       var items = timestamps.slice(0);
+       var i, ilen, prev, curr, next;
+
+       if (min < timestamps[0]) {
+               items.unshift(min);
+       }
+       if (max > timestamps[timestamps.length - 1]) {
+               items.push(max);
+       }
+
+       for (i = 0, ilen = items.length; i<ilen; ++i) {
+               next = items[i + 1];
+               prev = items[i - 1];
+               curr = items[i];
+
+               // only add points that breaks the scale linearity
+               if (prev === undefined || next === undefined || Math.round((next + prev) / 2) !== curr) {
+                       table.push({time: curr, pos: i / (ilen - 1)});
+               }
+       }
+
+       return table;
+}
+
+// @see adapted from http://www.anujgakhar.com/2014/03/01/binary-search-in-javascript/
+function lookup(table, key, value) {
+       var lo = 0;
+       var hi = table.length - 1;
+       var mid, i0, i1;
+
+       while (lo >= 0 && lo <= hi) {
+               mid = (lo + hi) >> 1;
+               i0 = table[mid - 1] || null;
+               i1 = table[mid];
+
+               if (!i0) {
+                       // given value is outside table (before first item)
+                       return {lo: null, hi: i1};
+               } else if (i1[key] < value) {
+                       lo = mid + 1;
+               } else if (i0[key] > value) {
+                       hi = mid - 1;
+               } else {
+                       return {lo: i0, hi: i1};
+               }
+       }
+
+       // given value is outside table (after last item)
+       return {lo: i1, hi: null};
+}
+
+/**
+ * Linearly interpolates the given source `value` using the table items `skey` values and
+ * returns the associated `tkey` value. For example, interpolate(table, 'time', 42, 'pos')
+ * returns the position for a timestamp equal to 42. If value is out of bounds, values at
+ * index [0, 1] or [n - 1, n] are used for the interpolation.
+ */
+function interpolate(table, skey, sval, tkey) {
+       var range = lookup(table, skey, sval);
+
+       // Note: the lookup table ALWAYS contains at least 2 items (min and max)
+       var prev = !range.lo ? table[0] : !range.hi ? table[table.length - 2] : range.lo;
+       var next = !range.lo ? table[1] : !range.hi ? table[table.length - 1] : range.hi;
+
+       var span = next[skey] - prev[skey];
+       var ratio = span ? (sval - prev[skey]) / span : 0;
+       var offset = (next[tkey] - prev[tkey]) * ratio;
+
+       return prev[tkey] + offset;
+}
+
+function parse(input, scale) {
+       if (helpers.isNullOrUndef(input)) {
+               return null;
+       }
+
+       var round = scale.options.time.round;
+       var value = scale.getRightValue(input);
+       var time = value.isValid ? value : helpers.time.parseTime(scale, value);
+       if (!time || !time.isValid()) {
+               return null;
+       }
+
+       if (round) {
+               time.startOf(round);
+       }
+
+       return time.valueOf();
+}
+
 module.exports = function(Chart) {
 
        var timeHelpers = helpers.time;
 
+       // Integer constants are from the ES6 spec.
+       var MIN_INTEGER = Number.MIN_SAFE_INTEGER || -9007199254740991;
+       var MAX_INTEGER = Number.MAX_SAFE_INTEGER || 9007199254740991;
+
        var defaultConfig = {
                position: 'bottom',
 
@@ -37,7 +159,9 @@ module.exports = function(Chart) {
                        },
                },
                ticks: {
-                       autoSkip: false
+                       autoSkip: false,
+                       mode: 'linear',   // 'linear|series'
+                       source: 'auto'    // 'auto|labels'
                }
        };
 
@@ -51,246 +175,254 @@ module.exports = function(Chart) {
 
                        Chart.Scale.prototype.initialize.call(this);
                },
+
                determineDataLimits: function() {
                        var me = this;
-                       var timeOpts = me.options.time;
-
-                       // We store the data range as unix millisecond timestamps so dataMin and dataMax will always be integers.
-                       // Integer constants are from the ES6 spec.
-                       var dataMin = Number.MAX_SAFE_INTEGER || 9007199254740991;
-                       var dataMax = Number.MIN_SAFE_INTEGER || -9007199254740991;
-
-                       var chartData = me.chart.data;
-                       var parsedData = {
-                               labels: [],
-                               datasets: []
-                       };
-
-                       var timestamp;
-
-                       helpers.each(chartData.labels, function(label, labelIndex) {
-                               var labelMoment = timeHelpers.parseTime(me, label);
-
-                               if (labelMoment.isValid()) {
-                                       // We need to round the time
-                                       if (timeOpts.round) {
-                                               labelMoment.startOf(timeOpts.round);
-                                       }
-
-                                       timestamp = labelMoment.valueOf();
-                                       dataMin = Math.min(timestamp, dataMin);
-                                       dataMax = Math.max(timestamp, dataMax);
-
-                                       // Store this value for later
-                                       parsedData.labels[labelIndex] = timestamp;
-                               }
-                       });
-
-                       helpers.each(chartData.datasets, function(dataset, datasetIndex) {
-                               var timestamps = [];
+                       var chart = me.chart;
+                       var options = me.options;
+                       var datasets = chart.data.datasets || [];
+                       var min = MAX_INTEGER;
+                       var max = MIN_INTEGER;
+                       var timestamps = [];
+                       var labels = [];
+                       var i, j, ilen, jlen, data, timestamp;
+
+                       // Convert labels to timestamps
+                       for (i = 0, ilen = chart.data.labels.length; i < ilen; ++i) {
+                               timestamp = parse(chart.data.labels[i], me);
+                               min = Math.min(min, timestamp);
+                               max = Math.max(max, timestamp);
+                               labels.push(timestamp);
+                       }
 
-                               if (typeof dataset.data[0] === 'object' && dataset.data[0] !== null && me.chart.isDatasetVisible(datasetIndex)) {
-                                       // We have potential point data, so we need to parse this
-                                       helpers.each(dataset.data, function(value, dataIndex) {
-                                               var dataMoment = timeHelpers.parseTime(me, me.getRightValue(value));
+                       // Convert data to timestamps
+                       for (i = 0, ilen = datasets.length; i < ilen; ++i) {
+                               if (chart.isDatasetVisible(i)) {
+                                       data = datasets[i].data;
 
-                                               if (dataMoment.isValid()) {
-                                                       if (timeOpts.round) {
-                                                               dataMoment.startOf(timeOpts.round);
-                                                       }
+                                       // Let's consider that all data have the same format.
+                                       if (helpers.isObject(data[0])) {
+                                               timestamps[i] = [];
 
-                                                       timestamp = dataMoment.valueOf();
-                                                       dataMin = Math.min(timestamp, dataMin);
-                                                       dataMax = Math.max(timestamp, dataMax);
-                                                       timestamps[dataIndex] = timestamp;
+                                               for (j = 0, jlen = data.length; j < jlen; ++j) {
+                                                       timestamp = parse(data[j], me);
+                                                       min = Math.min(min, timestamp);
+                                                       max = Math.max(max, timestamp);
+                                                       timestamps[i][j] = timestamp;
                                                }
-                                       });
+                                       } else {
+                                               timestamps[i] = labels.slice(0);
+                                       }
                                } else {
-                                       // We have no x coordinates, so use the ones from the labels
-                                       timestamps = parsedData.labels.slice();
+                                       timestamps[i] = [];
                                }
+                       }
 
-                               parsedData.datasets[datasetIndex] = timestamps;
-                       });
-
-                       me.dataMin = dataMin;
-                       me.dataMax = dataMax;
-                       me._parsedData = parsedData;
+                       // Enforce limits with user min/max options
+                       min = parse(options.time.min, me) || min;
+                       max = parse(options.time.max, me) || max;
+
+                       // In case there is no valid min/max, let's use today limits
+                       min = min === MAX_INTEGER ? +moment().startOf('day') : min;
+                       max = max === MIN_INTEGER ? +moment().endOf('day') + 1 : max;
+
+                       me._model = {
+                               datasets: timestamps,
+                               horizontal: me.isHorizontal(),
+                               labels: labels.sort(sorter),    // Sort labels **after** data have been converted
+                               min: Math.min(min, max),        // Make sure that max is **strictly** higher ...
+                               max: Math.max(min + 1, max),    // ... than min (required by the lookup table)
+                               offset: null,
+                               size: null,
+                               table: []
+                       };
                },
+
                buildTicks: function() {
                        var me = this;
+                       var model = me._model;
+                       var min = model.min;
+                       var max = model.max;
                        var timeOpts = me.options.time;
-
-                       var minTimestamp;
-                       var maxTimestamp;
-                       var dataMin = me.dataMin;
-                       var dataMax = me.dataMax;
-
-                       if (timeOpts.min) {
-                               var minMoment = timeHelpers.parseTime(me, timeOpts.min);
-                               if (timeOpts.round) {
-                                       minMoment.startOf(timeOpts.round);
+                       var ticksOpts = me.options.ticks;
+                       var formats = timeOpts.displayFormats;
+                       var capacity = me.getLabelCapacity(min);
+                       var unit = timeOpts.unit || timeHelpers.determineUnit(timeOpts.minUnit, min, max, capacity);
+                       var majorUnit = timeHelpers.determineMajorUnit(unit);
+                       var ticks = [];
+                       var i, ilen, timestamp, stepSize;
+
+                       if (ticksOpts.source === 'labels') {
+                               for (i = 0, ilen = model.labels.length; i < ilen; ++i) {
+                                       timestamp = model.labels[i];
+                                       if (timestamp >= min && timestamp <= max) {
+                                               ticks.push(timestamp);
+                                       }
                                }
-                               minTimestamp = minMoment.valueOf();
-                       }
-
-                       if (timeOpts.max) {
-                               maxTimestamp = timeHelpers.parseTime(me, timeOpts.max).valueOf();
+                       } else {
+                               stepSize = helpers.valueOrDefault(timeOpts.stepSize, timeOpts.unitStepSize)
+                                       || timeHelpers.determineStepSize(min, max, unit, capacity);
+
+                               ticks = timeHelpers.generateTicks({
+                                       maxTicks: capacity,
+                                       min: parse(timeOpts.min, me),
+                                       max: parse(timeOpts.max, me),
+                                       stepSize: stepSize,
+                                       majorUnit: majorUnit,
+                                       unit: unit,
+                                       timeOpts: timeOpts
+                               }, {
+                                       min: min,
+                                       max: max
+                               });
+
+                               // Recompute min/max, the ticks generation might have changed them (BUG?)
+                               min = ticks.length ? ticks[0] : min;
+                               max = ticks.length ? ticks[ticks.length - 1] : max;
                        }
 
-                       var maxTicks = me.getLabelCapacity(minTimestamp || dataMin);
-
-                       var unit = timeOpts.unit || timeHelpers.determineUnit(timeOpts.minUnit, minTimestamp || dataMin, maxTimestamp || dataMax, maxTicks);
-                       var majorUnit = timeHelpers.determineMajorUnit(unit);
-
-                       me.displayFormat = timeOpts.displayFormats[unit];
-                       me.majorDisplayFormat = timeOpts.displayFormats[majorUnit];
+                       me.ticks = ticks;
+                       me.min = min;
+                       me.max = max;
                        me.unit = unit;
                        me.majorUnit = majorUnit;
+                       me.displayFormat = formats[unit];
+                       me.majorDisplayFormat = formats[majorUnit];
 
-                       var optionStepSize = helpers.valueOrDefault(timeOpts.stepSize, timeOpts.unitStepSize);
-                       var stepSize = optionStepSize || timeHelpers.determineStepSize(minTimestamp || dataMin, maxTimestamp || dataMax, unit, maxTicks);
-                       me.ticks = timeHelpers.generateTicks({
-                               maxTicks: maxTicks,
-                               min: minTimestamp,
-                               max: maxTimestamp,
-                               stepSize: stepSize,
-                               majorUnit: majorUnit,
-                               unit: unit,
-                               timeOpts: timeOpts
-                       }, {
-                               min: dataMin,
-                               max: dataMax
-                       });
-
-                       // At this point, we need to update our max and min given the tick values since we have expanded the
-                       // range of the scale
-                       me.max = helpers.max(me.ticks);
-                       me.min = helpers.min(me.ticks);
+                       model.table = buildLookupTable(ticks, min, max, ticksOpts.mode === 'linear');
                },
-               // Get tooltip label
+
                getLabelForIndex: function(index, datasetIndex) {
                        var me = this;
-                       var label = me.chart.data.labels && index < me.chart.data.labels.length ? me.chart.data.labels[index] : '';
-                       var value = me.chart.data.datasets[datasetIndex].data[index];
+                       var data = me.chart.data;
+                       var timeOpts = me.options.time;
+                       var label = data.labels && index < data.labels.length ? data.labels[index] : '';
+                       var value = data.datasets[datasetIndex].data[index];
 
-                       if (value !== null && typeof value === 'object') {
+                       if (helpers.isObject(value)) {
                                label = me.getRightValue(value);
                        }
-
-                       // Format nicely
-                       if (me.options.time.tooltipFormat) {
-                               label = timeHelpers.parseTime(me, label).format(me.options.time.tooltipFormat);
+                       if (timeOpts.tooltipFormat) {
+                               label = timeHelpers.parseTime(me, label).format(timeOpts.tooltipFormat);
                        }
 
                        return label;
                },
-               // Function to format an individual tick mark
+
+               /**
+                * Function to format an individual tick mark
+                * @private
+                */
                tickFormatFunction: function(tick, index, ticks) {
-                       var formattedTick;
-                       var tickClone = tick.clone();
-                       var tickTimestamp = tick.valueOf();
-                       var major = false;
-                       var tickOpts;
-                       if (this.majorUnit && this.majorDisplayFormat && tickTimestamp === tickClone.startOf(this.majorUnit).valueOf()) {
-                               // format as major unit
-                               formattedTick = tick.format(this.majorDisplayFormat);
-                               tickOpts = this.options.ticks.major;
-                               major = true;
-                       } else {
-                               // format as minor (base) unit
-                               formattedTick = tick.format(this.displayFormat);
-                               tickOpts = this.options.ticks.minor;
+                       var me = this;
+                       var options = me.options;
+                       var time = tick.valueOf();
+                       var majorUnit = me.majorUnit;
+                       var majorFormat = me.majorDisplayFormat;
+                       var majorTime = tick.clone().startOf(me.majorUnit).valueOf();
+                       var major = majorUnit && majorFormat && time === majorTime;
+                       var formattedTick = tick.format(major? majorFormat : me.displayFormat);
+                       var tickOpts = major? options.ticks.major : options.ticks.minor;
+                       var formatter = helpers.valueOrDefault(tickOpts.callback, tickOpts.userCallback);
+
+                       if (formatter) {
+                               formattedTick = formatter(formattedTick, index, ticks);
                        }
 
-                       var callback = helpers.valueOrDefault(tickOpts.callback, tickOpts.userCallback);
-
-                       if (callback) {
-                               return {
-                                       value: callback(formattedTick, index, ticks),
-                                       major: major
-                               };
-                       }
                        return {
                                value: formattedTick,
-                               major: major
+                               major: major,
+                               time: time,
                        };
                },
+
                convertTicksToLabels: function() {
-                       var me = this;
-                       me.ticksAsTimestamps = me.ticks;
-                       me.ticks = me.ticks.map(function(tick) {
-                               return moment(tick);
-                       }).map(me.tickFormatFunction, me);
-               },
-               getPixelForOffset: function(offset) {
-                       var me = this;
-                       var epochWidth = me.max - me.min;
-                       var decimal = epochWidth ? (offset - me.min) / epochWidth : 0;
+                       var ticks = this.ticks;
+                       var i, ilen;
 
-                       if (me.isHorizontal()) {
-                               var valueOffset = (me.width * decimal);
-                               return me.left + Math.round(valueOffset);
+                       for (i = 0, ilen = ticks.length; i < ilen; ++i) {
+                               ticks[i] = this.tickFormatFunction(moment(ticks[i]));
                        }
+               },
+
+               /**
+                * @private
+                */
+               getPixelForOffset: function(time) {
+                       var me = this;
+                       var model = me._model;
+                       var size = model.horizontal ? me.width : me.height;
+                       var start = model.horizontal ? me.left : me.top;
+                       var pos = interpolate(model.table, 'time', time, 'pos');
 
-                       var heightOffset = (me.height * decimal);
-                       return me.top + Math.round(heightOffset);
+                       return start + size * pos;
                },
+
                getPixelForValue: function(value, index, datasetIndex) {
                        var me = this;
-                       var offset = null;
+                       var time = null;
+
                        if (index !== undefined && datasetIndex !== undefined) {
-                               offset = me._parsedData.datasets[datasetIndex][index];
+                               time = me._model.datasets[datasetIndex][index];
                        }
 
-                       if (offset === null) {
-                               if (!value || !value.isValid) {
-                                       // not already a moment object
-                                       value = timeHelpers.parseTime(me, me.getRightValue(value));
-                               }
-
-                               if (value && value.isValid && value.isValid()) {
-                                       offset = value.valueOf();
-                               }
+                       if (time === null) {
+                               time = parse(value, me);
                        }
 
-                       if (offset !== null) {
-                               return me.getPixelForOffset(offset);
+                       if (time !== null) {
+                               return me.getPixelForOffset(time);
                        }
                },
+
                getPixelForTick: function(index) {
-                       return this.getPixelForOffset(this.ticksAsTimestamps[index]);
+                       return index >= 0 && index < this.ticks.length ?
+                               this.getPixelForOffset(this.ticks[index].time) :
+                               null;
                },
+
                getValueForPixel: function(pixel) {
                        var me = this;
-                       var innerDimension = me.isHorizontal() ? me.width : me.height;
-                       var offset = (pixel - (me.isHorizontal() ? me.left : me.top)) / innerDimension;
-                       return moment(me.min + (offset * (me.max - me.min)));
+                       var model = me._model;
+                       var size = model.horizontal ? me.width : me.height;
+                       var start = model.horizontal ? me.left : me.top;
+                       var pos = size ? (pixel - start) / size : 0;
+                       var time = interpolate(model.table, 'pos', pos, 'time');
+
+                       return moment(time);
                },
-               // Crude approximation of what the label width might be
+
+               /**
+                * Crude approximation of what the label width might be
+                * @private
+                */
                getLabelWidth: function(label) {
                        var me = this;
-                       var ticks = me.options.ticks;
-
+                       var ticksOpts = me.options.ticks;
                        var tickLabelWidth = me.ctx.measureText(label).width;
-                       var cosRotation = Math.cos(helpers.toRadians(ticks.maxRotation));
-                       var sinRotation = Math.sin(helpers.toRadians(ticks.maxRotation));
-                       var tickFontSize = helpers.valueOrDefault(ticks.fontSize, defaults.global.defaultFontSize);
+                       var angle = helpers.toRadians(ticksOpts.maxRotation);
+                       var cosRotation = Math.cos(angle);
+                       var sinRotation = Math.sin(angle);
+                       var tickFontSize = helpers.valueOrDefault(ticksOpts.fontSize, defaults.global.defaultFontSize);
+
                        return (tickLabelWidth * cosRotation) + (tickFontSize * sinRotation);
                },
+
+               /**
+                * @private
+                */
                getLabelCapacity: function(exampleTime) {
                        var me = this;
 
                        me.displayFormat = me.options.time.displayFormats.millisecond;  // Pick the longest format for guestimation
+
                        var exampleLabel = me.tickFormatFunction(moment(exampleTime), 0, []).value;
                        var tickLabelWidth = me.getLabelWidth(exampleLabel);
-
                        var innerWidth = me.isHorizontal() ? me.width : me.height;
-                       var labelCapacity = innerWidth / tickLabelWidth;
 
-                       return labelCapacity;
+                       return innerWidth / tickLabelWidth;
                }
        });
-       Chart.scaleService.registerScaleType('time', TimeScale, defaultConfig);
 
+       Chart.scaleService.registerScaleType('time', TimeScale, defaultConfig);
 };
index 2b8c50ed9694d60fb46b5a37e5271463e6ef9360..ff02bc8df13b3f449b6809ff584b71c54beb8194 100755 (executable)
@@ -23,6 +23,12 @@ describe('Time scale tests', function() {
                });
        }
 
+       function fetchTickPositions(scale) {
+               return scale.ticks.map(function(tick, index) {
+                       return scale.getPixelForTick(index);
+               });
+       }
+
        beforeEach(function() {
                // Need a time matcher for getValueFromPixel
                jasmine.addMatchers({
@@ -83,6 +89,8 @@ describe('Time scale tests', function() {
                                minRotation: 0,
                                maxRotation: 50,
                                mirror: false,
+                               mode: 'linear',
+                               source: 'auto',
                                padding: 0,
                                reverse: false,
                                display: true,
@@ -417,7 +425,8 @@ describe('Time scale tests', function() {
 
                var xScale = chart.scales.xScale0;
                xScale.update(800, 200);
-               var step = xScale.ticksAsTimestamps[1] - xScale.ticksAsTimestamps[0];
+
+               var step = xScale.ticks[1].time - xScale.ticks[0].time;
                var stepsAmount = Math.floor((xScale.max - xScale.min) / step);
 
                it('should be bounded by nearest step year starts', function() {
@@ -534,4 +543,246 @@ describe('Time scale tests', function() {
                expect(chart.scales['y-axis-0'].maxWidth).toEqual(0);
                expect(chart.width).toEqual(0);
        });
+
+       describe('when ticks.source', function() {
+               describe('is "labels"', function() {
+                       beforeEach(function() {
+                               this.chart = window.acquireChart({
+                                       type: 'line',
+                                       data: {
+                                               labels: ['2017', '2019', '2020', '2025', '2042'],
+                                               datasets: [{data: [0, 1, 2, 3, 4, 5]}]
+                                       },
+                                       options: {
+                                               scales: {
+                                                       xAxes: [{
+                                                               id: 'x',
+                                                               type: 'time',
+                                                               time: {
+                                                                       parser: 'YYYY'
+                                                               },
+                                                               ticks: {
+                                                                       source: 'labels'
+                                                               }
+                                                       }]
+                                               }
+                                       }
+                               });
+                       });
+
+                       it ('should generate ticks from "data.labels"', function() {
+                               var scale = this.chart.scales.x;
+
+                               expect(scale.min).toEqual(+moment('2017', 'YYYY'));
+                               expect(scale.max).toEqual(+moment('2042', 'YYYY'));
+                               expect(getTicksValues(scale.ticks)).toEqual([
+                                       '2017', '2019', '2020', '2025', '2042']);
+                       });
+                       it ('should not add ticks for min and max if they extend the labels range', function() {
+                               var chart = this.chart;
+                               var scale = chart.scales.x;
+                               var options = chart.options.scales.xAxes[0];
+
+                               options.time.min = '2012';
+                               options.time.max = '2051';
+                               chart.update();
+
+                               expect(scale.min).toEqual(+moment('2012', 'YYYY'));
+                               expect(scale.max).toEqual(+moment('2051', 'YYYY'));
+                               expect(getTicksValues(this.chart.scales.x.ticks)).toEqual([
+                                       '2017', '2019', '2020', '2025', '2042']);
+                       });
+                       it ('should remove ticks that are not inside the min and max time range', function() {
+                               var chart = this.chart;
+                               var scale = chart.scales.x;
+                               var options = chart.options.scales.xAxes[0];
+
+                               options.time.min = '2022';
+                               options.time.max = '2032';
+                               chart.update();
+
+                               expect(scale.min).toEqual(+moment('2022', 'YYYY'));
+                               expect(scale.max).toEqual(+moment('2032', 'YYYY'));
+                               expect(getTicksValues(this.chart.scales.x.ticks)).toEqual([
+                                       '2025']);
+                       });
+                       it ('should not duplicate ticks if min and max are the labels limits', function() {
+                               var chart = this.chart;
+                               var scale = chart.scales.x;
+                               var options = chart.options.scales.xAxes[0];
+
+                               options.time.min = '2017';
+                               options.time.max = '2042';
+                               chart.update();
+
+                               expect(scale.min).toEqual(+moment('2017', 'YYYY'));
+                               expect(scale.max).toEqual(+moment('2042', 'YYYY'));
+                               expect(getTicksValues(this.chart.scales.x.ticks)).toEqual([
+                                       '2017', '2019', '2020', '2025', '2042']);
+                       });
+                       it ('should correctly handle empty `data.labels`', function() {
+                               var chart = this.chart;
+                               var scale = chart.scales.x;
+
+                               chart.data.labels = [];
+                               chart.update();
+
+                               expect(scale.min).toEqual(+moment().startOf('day'));
+                               expect(scale.max).toEqual(+moment().endOf('day') + 1);
+                               expect(getTicksValues(this.chart.scales.x.ticks)).toEqual([]);
+                       });
+               });
+       });
+
+       describe('when ticks.mode', function() {
+               describe('is "series"', function() {
+                       beforeEach(function() {
+                               this.chart = window.acquireChart({
+                                       type: 'line',
+                                       data: {
+                                               labels: ['2017', '2019', '2020', '2025', '2042'],
+                                               datasets: [{data: [0, 1, 2, 3, 4, 5]}]
+                                       },
+                                       options: {
+                                               scales: {
+                                                       xAxes: [{
+                                                               id: 'x',
+                                                               type: 'time',
+                                                               time: {
+                                                                       parser: 'YYYY'
+                                                               },
+                                                               ticks: {
+                                                                       mode: 'series',
+                                                                       source: 'labels'
+                                                               }
+                                                       }],
+                                                       yAxes: [{
+                                                               display: false
+                                                       }]
+                                               }
+                                       }
+                               });
+                       });
+
+                       it ('should space ticks out with the same gap, whatever their time values', function() {
+                               var scale = this.chart.scales.x;
+                               var start = scale.left;
+                               var slice = scale.width / 4;
+                               var pixels = fetchTickPositions(scale);
+
+                               expect(pixels[0]).toBeCloseToPixel(start);
+                               expect(pixels[1]).toBeCloseToPixel(start + slice);
+                               expect(pixels[2]).toBeCloseToPixel(start + slice * 2);
+                               expect(pixels[3]).toBeCloseToPixel(start + slice * 3);
+                               expect(pixels[4]).toBeCloseToPixel(start + slice * 4);
+                       });
+                       it ('should add a step before if scale.min is before the first tick', function() {
+                               var chart = this.chart;
+                               var scale = chart.scales.x;
+                               var options = chart.options.scales.xAxes[0];
+
+                               options.time.min = '2012';
+                               chart.update();
+
+                               var start = scale.left;
+                               var slice = scale.width / 5;
+                               var pixels = fetchTickPositions(scale);
+
+                               expect(pixels[0]).toBeCloseToPixel(start + slice);
+                               expect(pixels[4]).toBeCloseToPixel(start + slice * 5);
+                       });
+                       it ('should add a step after if scale.max is after the last tick', function() {
+                               var chart = this.chart;
+                               var scale = chart.scales.x;
+                               var options = chart.options.scales.xAxes[0];
+
+                               options.time.max = '2050';
+                               chart.update();
+
+                               var start = scale.left;
+                               var slice = scale.width / 5;
+                               var pixels = fetchTickPositions(scale);
+
+                               expect(pixels[0]).toBeCloseToPixel(start);
+                               expect(pixels[4]).toBeCloseToPixel(start + slice * 4);
+                       });
+                       it ('should add steps before and after if scale.min/max are outside the labels range', function() {
+                               var chart = this.chart;
+                               var scale = chart.scales.x;
+                               var options = chart.options.scales.xAxes[0];
+
+                               options.time.min = '2012';
+                               options.time.max = '2050';
+                               chart.update();
+
+                               var start = scale.left;
+                               var slice = scale.width / 6;
+                               var pixels = fetchTickPositions(scale);
+
+                               expect(pixels[0]).toBeCloseToPixel(start + slice);
+                               expect(pixels[4]).toBeCloseToPixel(start + slice * 5);
+                       });
+               });
+               describe('is "linear"', function() {
+                       beforeEach(function() {
+                               this.chart = window.acquireChart({
+                                       type: 'line',
+                                       data: {
+                                               labels: ['2017', '2019', '2020', '2025', '2042'],
+                                               datasets: [{data: [0, 1, 2, 3, 4, 5]}]
+                                       },
+                                       options: {
+                                               scales: {
+                                                       xAxes: [{
+                                                               id: 'x',
+                                                               type: 'time',
+                                                               time: {
+                                                                       parser: 'YYYY'
+                                                               },
+                                                               ticks: {
+                                                                       mode: 'linear',
+                                                                       source: 'labels'
+                                                               }
+                                                       }],
+                                                       yAxes: [{
+                                                               display: false
+                                                       }]
+                                               }
+                                       }
+                               });
+                       });
+
+                       it ('should space ticks out with a gap relative to their time values', function() {
+                               var scale = this.chart.scales.x;
+                               var start = scale.left;
+                               var slice = scale.width / (2042 - 2017);
+                               var pixels = fetchTickPositions(scale);
+
+                               expect(pixels[0]).toBeCloseToPixel(start);
+                               expect(pixels[1]).toBeCloseToPixel(start + slice * (2019 - 2017));
+                               expect(pixels[2]).toBeCloseToPixel(start + slice * (2020 - 2017));
+                               expect(pixels[3]).toBeCloseToPixel(start + slice * (2025 - 2017));
+                               expect(pixels[4]).toBeCloseToPixel(start + slice * (2042 - 2017));
+                       });
+                       it ('should take in account scale min and max if outside the ticks range', function() {
+                               var chart = this.chart;
+                               var scale = chart.scales.x;
+                               var options = chart.options.scales.xAxes[0];
+
+                               options.time.min = '2012';
+                               options.time.max = '2050';
+                               chart.update();
+
+                               var start = scale.left;
+                               var slice = scale.width / (2050 - 2012);
+                               var pixels = fetchTickPositions(scale);
+
+                               expect(pixels[0]).toBeCloseToPixel(start + slice * (2017 - 2012));
+                               expect(pixels[1]).toBeCloseToPixel(start + slice * (2019 - 2012));
+                               expect(pixels[2]).toBeCloseToPixel(start + slice * (2020 - 2012));
+                               expect(pixels[3]).toBeCloseToPixel(start + slice * (2025 - 2012));
+                               expect(pixels[4]).toBeCloseToPixel(start + slice * (2042 - 2012));
+                       });
+               });
+       });
 });