diff --git a/src/helpers/helpers.time.js b/src/helpers/helpers.time.js index f01dfaecd40..b667df54c8a 100644 --- a/src/helpers/helpers.time.js +++ b/src/helpers/helpers.time.js @@ -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; diff --git a/src/scales/scale.time.js b/src/scales/scale.time.js index 41d022a7938..93e49887b03 100644 --- a/src/scales/scale.time.js +++ b/src/scales/scale.time.js @@ -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= 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); }; diff --git a/test/specs/scale.time.tests.js b/test/specs/scale.time.tests.js index 2b8c50ed969..ff02bc8df13 100755 --- a/test/specs/scale.time.tests.js +++ b/test/specs/scale.time.tests.js @@ -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)); + }); + }); + }); });