From b3fd40d3b088114a4710ca857045266d7aa1e1bc Mon Sep 17 00:00:00 2001 From: Simon Brunel Date: Sat, 15 Jul 2017 11:43:06 +0200 Subject: [PATCH] New time scale `ticks.mode/.source` options `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 | 4 +- src/scales/scale.time.js | 477 +++++++++++++++++++++------------ test/specs/scale.time.tests.js | 154 ++++++++++- 3 files changed, 462 insertions(+), 173 deletions(-) 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..b638b2bd8d8 100644 --- a/src/scales/scale.time.js +++ b/src/scales/scale.time.js @@ -11,6 +11,10 @@ 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,10 +41,110 @@ module.exports = function(Chart) { }, }, ticks: { - autoSkip: false + autoSkip: false, + mode: 'linear', // 'linear|series' + source: 'auto' // 'auto|labels' } }; + 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. + + * @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. + * + * @private + */ + function buildLookupTable(timestamps, linear) { + var ilen = timestamps.length; + var table, i, prev, curr, next; + + if (ilen === 0) { + return []; + } + + if (linear) { + return [ + {time: timestamps[0], pos: 0}, + {time: timestamps[ilen - 1], pos: 1} + ]; + } + + table = []; + + for (i = 0; i 1 ? i / (ilen - 1) : 0}); + } + } + + 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}; + } + + 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 : timeHelpers.parseTime(scale, value); + if (!time || !time.isValid()) { + return null; + } + + if (round) { + time.startOf(round); + } + + return time.valueOf(); + } + var TimeScale = Chart.Scale.extend({ initialize: function() { if (!moment) { @@ -51,246 +155,277 @@ 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(); + } } 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 (ticks[0] > min) { + ticks.unshift(min); + } + if (ticks[ticks.length - 1] < max) { + ticks.push(max); + } + } else { + stepSize = helpers.valueOrDefault(timeOpts.stepSize, timeOpts.unitStepSize); + stepSize = stepSize || 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 + }); - if (timeOpts.max) { - maxTimestamp = timeHelpers.parseTime(me, timeOpts.max).valueOf(); } - 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 = ticks[0]; + me.max = ticks[ticks.length - 1]; 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, 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])); } + }, - var heightOffset = (me.height * decimal); - return me.top + Math.round(heightOffset); + /** + * @private + */ + getPixelForOffset: function(time) { + var me = this; + var model = me._model; + var table = model.table; + var range = lookup(table, 'time', time); + + // If value is out of bounds, use ticks [0, 1] or [n-1, n] for interpolation, + // note that 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.time - prev.time; + var ratio = span ? (time - prev.time) / span : 0; + var offset = (next.pos - prev.pos) * ratio; + var size = model.horizontal ? me.width : me.height; + var start = model.horizontal ? me.left : me.top; + + return start + size * (prev.pos + offset); }, + 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 table = model.table; + var size = model.horizontal ? me.width : me.height; + var start = model.horizontal ? me.left : me.top; + var pos = size ? (pixel - start) / size : 0; + var range = lookup(table, 'pos', pos); + + // if pixel is out of bounds, use ticks [0, 1] or [n-1, n] for interpolation, + // note that 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.pos - prev.pos; + var ratio = span? (pos - prev.pos) / span : 0; + var offset = (next.time - prev.time) * ratio; + + return moment(prev.time + offset); }, - // 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..289c2f8fa81 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,147 @@ 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: {}, + ticks: { + source: 'labels' + } + }] + } + } + }); + }); + + it ('should generate ticks from "data.labels"', function() { + expect(getTicksValues(this.chart.scales.x.ticks)).toEqual([ + '2017', '2019', '2020', '2025', '2042']); + }); + it ('should extend ticks with min and max if outside the time range', function() { + var chart = this.chart; + var options = chart.options.scales.xAxes[0]; + + options.time.min = '2012'; + options.time.max = '2051'; + chart.update(); + + expect(getTicksValues(this.chart.scales.x.ticks)).toEqual([ + '2012', '2017', '2019', '2020', '2025', '2042', '2051']); + }); + it ('should shrink ticks with min and max if inside the time range', function() { + var chart = this.chart; + var options = chart.options.scales.xAxes[0]; + + options.time.min = '2022'; + options.time.max = '2032'; + chart.update(); + + expect(getTicksValues(this.chart.scales.x.ticks)).toEqual([ + '2022', '2025', '2032']); + }); + it ('should not duplicate ticks if min and max are the labels limits', function() { + var chart = this.chart; + var options = chart.options.scales.xAxes[0]; + + options.time.min = '2017'; + options.time.max = '2042'; + chart.update(); + + expect(getTicksValues(this.chart.scales.x.ticks)).toEqual([ + '2017', '2019', '2020', '2025', '2042']); + }); + }); + }); + + describe('when ticks.mode', function() { + describe('is "series"', function() { + it ('should space ticks out with the same gap, whatever their time values', function() { + var 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: {}, + ticks: { + mode: 'series', + source: 'labels' + } + }], + yAxes: [{ + display: false + }] + } + } + }); + + var scale = 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); + }); + }); + describe('is "linear"', function() { + it ('should space ticks out with a gap relative to their time values', function() { + var 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: {}, + ticks: { + mode: 'linear', + source: 'labels' + } + }], + yAxes: [{ + display: false + }] + } + } + }); + + var scale = 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)); + }); + }); + }); });