From 97cc62bd66576eb4a8df8c72afa23cd110617b5e Mon Sep 17 00:00:00 2001 From: Ilya Beliaev Date: Mon, 22 May 2017 15:59:41 +0300 Subject: [PATCH] TimeSeries scale added; time series refactoring (#4189) --- docs/SUMMARY.md | 3 +- docs/axes/cartesian/timeseries.md | 93 +++++ samples/samples.js | 12 + samples/scales/timeseries/combo.html | 175 ++++++++ .../scales/timeseries/line-point-data.html | 152 +++++++ samples/scales/timeseries/line.html | 175 ++++++++ src/chart.js | 2 + src/helpers/helpers.time.js | 1 - src/scales/scale.time.js | 87 +--- src/scales/scale.timebase.js | 101 +++++ src/scales/scale.timeseries.js | 190 +++++++++ test/specs/scale.category.tests.js | 2 +- test/specs/scale.timeseries.tests.js | 375 ++++++++++++++++++ 13 files changed, 1285 insertions(+), 83 deletions(-) create mode 100644 docs/axes/cartesian/timeseries.md create mode 100644 samples/scales/timeseries/combo.html create mode 100644 samples/scales/timeseries/line-point-data.html create mode 100644 samples/scales/timeseries/line.html create mode 100644 src/scales/scale.timebase.js create mode 100644 src/scales/scale.timeseries.js create mode 100644 test/specs/scale.timeseries.tests.js diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 041be7062af..34528383972 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -34,7 +34,8 @@ * [Category](axes/cartesian/category.md) * [Linear](axes/cartesian/linear.md) * [Logarithmic](axes/cartesian/logarithmic.md) - * [Time](axes/cartesian/time.md) + * [Time](axes/cartesian/time.md) + * [TimeSeries](axes/cartesian/timeseries.md) * [Radial](axes/radial/README.md) * [Linear](axes/radial/linear.md) * [Labelling](axes/labelling.md) diff --git a/docs/axes/cartesian/timeseries.md b/docs/axes/cartesian/timeseries.md new file mode 100644 index 00000000000..baffcc09d17 --- /dev/null +++ b/docs/axes/cartesian/timeseries.md @@ -0,0 +1,93 @@ +# TimeSeries Cartesian Axis + +The timeseries scale is used to display times and dates. When building its ticks, it automatically calculate dates display format. Unlike time scale it shows all points from all datasets with same distance by x axes. + +## Configuration Options + +The following options are provided by the timeseries scale. They are all located in the `time` sub options. These options extend the [common tick configuration](README.md#tick-configuration). + +| Name | Type | Default | Description +| -----| ---- | --------| ----------- +| `displayFormats` | `Object` | | Sets how different time units are displayed. [more...](#display-formats) +| `parser` | `String` or `Function` | | Custom parser for dates. [more...](#parser) +| `round` | `String` | `false` | If defined, dates will be rounded to the start of this unit. See [Time Units](#scales-time-units) below for the allowed units. +| `tooltipFormat` | `String` | | The moment js format string to use for the tooltip. +| `unit` | `String` | `false` | If defined, will force the unit to be a certain type. See [Time Units](#scales-time-units) section below for details. +| `minUnit` | `String` | `millisecond` | The minimum display format to be used for a time unit. + +## Date Formats + +When providing data for the time scale, Chart.js supports all of the formats that Moment.js accepts. See [Moment.js docs](http://momentjs.com/docs/#/parsing/) for details. + +## Time Units + +The following time measurements are supported. The names can be passed as strings to the `time.unit` config option to force a certain unit. + +* millisecond +* second +* minute +* hour +* day +* week +* month +* quarter +* year + +For example, to create a chart with a time scale that always displayed units per month, the following config could be used. + +```javascript +var chart = new Chart(ctx, { + type: 'line', + data: data, + options: { + scales: { + xAxes: [{ + time: { + unit: 'month' + } + }] + } + } +}) +``` + +## Display Formats +The following display formats are used to configure how different time units are formed into strings for the axis tick marks. See [moment.js](http://momentjs.com/docs/#/displaying/format/) for the allowable format strings. + +Name | Default +--- | --- +millisecond | 'SSS [ms]' +second | 'h:mm:ss a' +minute | 'h:mm:ss a' +hour | 'MMM D, hA' +day | 'll' +week | 'll' +month | 'MMM YYYY' +quarter | '[Q]Q - YYYY' +year | 'YYYY' + +For example, to set the display format for the 'quarter' unit to show the month and year, the following config would be passed to the chart constructor. + +```javascript +var chart = new Chart(ctx, { + type: 'line', + data: data, + options: { + scales: { + xAxes: [{ + type: 'timeseries', + time: { + displayFormats: { + quarter: 'MMM YYYY' + } + } + }] + } + } +}) +``` + +## Parser +If this property is defined as a string, it is interpreted as a custom format to be used by moment to parse the date. + +If this is a function, it must return a moment.js object given the appropriate data value. diff --git a/samples/samples.js b/samples/samples.js index 235685bf2fa..5c5f1bdb6ff 100644 --- a/samples/samples.js +++ b/samples/samples.js @@ -116,6 +116,18 @@ title: 'Combo', path: 'scales/time/combo.html' }] + }, { + title: 'TimeSeries scale', + items: [{ + title: 'Line', + path: 'scales/timeseries/line.html' + }, { + title: 'Line (point data)', + path: 'scales/timeseries/line-point-data.html' + }, { + title: 'Combo', + path: 'scales/timeseries/combo.html' + }] }, { title: 'Scale options', items: [{ diff --git a/samples/scales/timeseries/combo.html b/samples/scales/timeseries/combo.html new file mode 100644 index 00000000000..45daae8caed --- /dev/null +++ b/samples/scales/timeseries/combo.html @@ -0,0 +1,175 @@ + + + + + Line Chart - Combo Time Scale + + + + + + + +
+ +
+
+
+ + + + + + + + + diff --git a/samples/scales/timeseries/line-point-data.html b/samples/scales/timeseries/line-point-data.html new file mode 100644 index 00000000000..c14db8d3eac --- /dev/null +++ b/samples/scales/timeseries/line-point-data.html @@ -0,0 +1,152 @@ + + + + + Time Scale Point Data + + + + + + + +
+ +
+
+
+ + + + + + + diff --git a/samples/scales/timeseries/line.html b/samples/scales/timeseries/line.html new file mode 100644 index 00000000000..65b2ed1b852 --- /dev/null +++ b/samples/scales/timeseries/line.html @@ -0,0 +1,175 @@ + + + + + Line Chart + + + + + + + +
+ +
+
+
+ + + + + + + + + diff --git a/src/chart.js b/src/chart.js index 8026cc856b7..3996f24fbd0 100644 --- a/src/chart.js +++ b/src/chart.js @@ -28,11 +28,13 @@ require('./elements/element.point')(Chart); require('./elements/element.rectangle')(Chart); require('./scales/scale.linearbase')(Chart); +require('./scales/scale.timebase')(Chart); require('./scales/scale.category')(Chart); require('./scales/scale.linear')(Chart); require('./scales/scale.logarithmic')(Chart); require('./scales/scale.radialLinear')(Chart); require('./scales/scale.time')(Chart); +require('./scales/scale.timeseries')(Chart); // Controllers must be loaded after elements // See Chart.core.datasetController.dataElementType diff --git a/src/helpers/helpers.time.js b/src/helpers/helpers.time.js index f3e6e96b1c4..ce55bd4bb60 100644 --- a/src/helpers/helpers.time.js +++ b/src/helpers/helpers.time.js @@ -219,7 +219,6 @@ module.exports = function(Chart) { max: niceMax }); } - }; }; diff --git a/src/scales/scale.time.js b/src/scales/scale.time.js index 14621ba7a8b..560f4481ef0 100644 --- a/src/scales/scale.time.js +++ b/src/scales/scale.time.js @@ -5,7 +5,6 @@ var moment = require('moment'); moment = typeof(moment) === 'function' ? moment : window.moment; module.exports = function(Chart) { - var helpers = Chart.helpers; var timeHelpers = helpers.time; @@ -39,7 +38,7 @@ module.exports = function(Chart) { } }; - var TimeScale = Chart.Scale.extend({ + var TimeScale = Chart.TimeScaleBase.extend({ initialize: function() { if (!moment) { throw new Error('Chart.js - Moment.js could not be found! You must include it before Chart.js to use the time scale. Download at https://momentjs.com'); @@ -49,6 +48,7 @@ module.exports = function(Chart) { Chart.Scale.prototype.initialize.call(this); }, + determineDataLimits: function() { var me = this; var timeOpts = me.options.time; @@ -115,6 +115,7 @@ module.exports = function(Chart) { me.dataMax = dataMax; me._parsedData = parsedData; }, + buildTicks: function() { var me = this; var timeOpts = me.options.time; @@ -166,61 +167,7 @@ module.exports = function(Chart) { me.max = helpers.max(me.ticks); me.min = helpers.min(me.ticks); }, - // 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]; - - if (value !== null && typeof value === 'object') { - label = me.getRightValue(value); - } - - // Format nicely - if (me.options.time.tooltipFormat) { - label = timeHelpers.parseTime(me, label).format(me.options.time.tooltipFormat); - } - return label; - }, - // Function to format an individual tick mark - 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 callback = helpers.valueOrDefault(tickOpts.callback, tickOpts.userCallback); - - if (callback) { - return { - value: callback(formattedTick, index, ticks), - major: major - }; - } - return { - value: formattedTick, - major: major - }; - }, - 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; @@ -234,6 +181,7 @@ module.exports = function(Chart) { var heightOffset = (me.height * decimal); return me.top + Math.round(heightOffset); }, + getPixelForValue: function(value, index, datasetIndex) { var me = this; var offset = null; @@ -256,39 +204,18 @@ module.exports = function(Chart) { return me.getPixelForOffset(offset); } }, + getPixelForTick: function(index) { return this.getPixelForOffset(this.ticksAsTimestamps[index]); }, + 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))); }, - // Crude approximation of what the label width might be - getLabelWidth: function(label) { - var me = this; - var ticks = 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, Chart.defaults.global.defaultFontSize); - return (tickLabelWidth * cosRotation) + (tickFontSize * sinRotation); - }, - 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; - } }); - Chart.scaleService.registerScaleType('time', TimeScale, defaultConfig); + Chart.scaleService.registerScaleType('time', TimeScale, defaultConfig); }; diff --git a/src/scales/scale.timebase.js b/src/scales/scale.timebase.js new file mode 100644 index 00000000000..295fffd7119 --- /dev/null +++ b/src/scales/scale.timebase.js @@ -0,0 +1,101 @@ +/* global window: false */ +'use strict'; + +var moment = require('moment'); +moment = typeof(moment) === 'function' ? moment : window.moment; + +module.exports = function(Chart) { + + var helpers = Chart.helpers; + var timeHelpers = helpers.time; + + Chart.TimeScaleBase = Chart.Scale.extend({ + initialize: function() { + if (!moment) { + throw new Error('Chart.js - Moment.js could not be found! You must include it before Chart.js to use the time scale. Download at https://momentjs.com'); + } + + this.mergeTicksOptions(); + + Chart.Scale.prototype.initialize.call(this); + }, + // 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]; + + if (value !== null && typeof value === 'object') { + label = me.getRightValue(value); + } + + // Format nicely + if (me.options.time.tooltipFormat) { + label = timeHelpers.parseTime(me, label).format(me.options.time.tooltipFormat); + } + + return label; + }, + // Function to format an individual tick mark + 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 callback = helpers.valueOrDefault(tickOpts.callback, tickOpts.userCallback); + + if (callback) { + return { + value: callback(formattedTick, index, ticks), + major: major + }; + } + return { + value: formattedTick, + major: major + }; + }, + convertTicksToLabels: function() { + var me = this; + me.ticksAsTimestamps = me.ticks; + me.ticks = me.ticks.map(function(tick) { + return moment(tick); + }).map(me.tickFormatFunction, me); + }, + // Crude approximation of what the label width might be + getLabelWidth: function(label) { + var me = this; + var ticks = 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, Chart.defaults.global.defaultFontSize); + return (tickLabelWidth * cosRotation) + (tickFontSize * sinRotation); + }, + 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; + } + }); +}; diff --git a/src/scales/scale.timeseries.js b/src/scales/scale.timeseries.js new file mode 100644 index 00000000000..8b9d8277113 --- /dev/null +++ b/src/scales/scale.timeseries.js @@ -0,0 +1,190 @@ +'use strict'; + +module.exports = function(Chart) { + + var helpers = Chart.helpers; + var timeHelpers = helpers.time; + + // Default config for a timeseries scale + var defaultConfig = { + position: 'bottom', + + time: { + parser: false, // false == a pattern string from http://momentjs.com/docs/#/parsing/string-format/ or a custom callback that converts its argument to a moment + format: false, // DEPRECATED false == date objects, moment object, callback or a pattern string from http://momentjs.com/docs/#/parsing/string-format/ + unit: false, // false == automatic or override with week, month, year, etc. + round: false, // none, or override with week, month, year, etc. + displayFormat: false, // DEPRECATED + minUnit: 'millisecond', + + // defaults to unit's corresponding unitFormat below or override using pattern string from http://momentjs.com/docs/#/displaying/format/ + displayFormats: { + millisecond: 'h:mm:ss.SSS a', // 11:20:01.123 AM, + second: 'h:mm:ss a', // 11:20:01 AM + minute: 'h:mm:ss a', // 11:20:01 AM + hour: 'MMM D, hA', // Sept 4, 5PM + day: 'll', // Sep 4 2015 + week: 'll', // Week 46, or maybe "[W]WW - YYYY" ? + month: 'MMM YYYY', // Sept 2015 + quarter: '[Q]Q - YYYY', // Q3 + year: 'YYYY' // 2015 + }, + }, + ticks: { + autoSkip: true + } + }; + + function arrayUnique(arr) { + var result = []; + for (var i = 0; i < arr.length; i++) { + if (result.indexOf(arr[i]) === -1) { + result.push(arr[i]); + } + } + return result; + } + + var TimeSeriesScale = Chart.TimeScaleBase.extend({ + /** + * Internal function to get the correct labels. If data.xLabels or data.yLabels are defined, use those + * else fall back to data.labels + * @private + */ + getLabels: function() { + var data = this.chart.data; + return data.xLabels || data.labels; + }, + + determineDataLimits: function() { + var me = this; + var timeOpts = me.options.time; + + var chartData = me.chart.data; + var parsedData = { + labels: [], + datasets: [] + }; + + helpers.each(me.getLabels(), 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); + } + // Store this value for later + parsedData.labels[labelIndex] = labelMoment.valueOf(); + } + }); + + helpers.each(chartData.datasets, function(dataset, datasetIndex) { + var timestamps = []; + + 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)); + + if (dataMoment.isValid()) { + if (timeOpts.round) { + dataMoment.startOf(timeOpts.round); + } + + timestamps[dataIndex] = dataMoment.valueOf(); + } + }); + } else { + // We have no x coordinates, so use the ones from the labels + timestamps = parsedData.labels.slice(); + } + + parsedData.datasets[datasetIndex] = timestamps; + }); + + var allTimestamps = parsedData.labels; + helpers.each(parsedData.datasets, function(value) { + allTimestamps = allTimestamps.concat(value); + }); + + allTimestamps = arrayUnique(allTimestamps).sort(function(a, b) { + return a - b; + }); + + parsedData.allTimestamps = allTimestamps; + + me._parsedData = parsedData; + }, + + buildTicks: function() { + var me = this; + var timeOpts = me.options.time; + + var allTimestamps = me._parsedData.allTimestamps; + var dataMin = allTimestamps[0]; + var dataMax = allTimestamps[allTimestamps.length - 1]; + + var maxTicks = me.getLabelCapacity(dataMin); + var unit = timeOpts.unit || timeHelpers.determineUnit(timeOpts.minUnit, dataMin, dataMax, maxTicks); + me.displayFormat = timeOpts.displayFormats[unit]; + + me._tickTimestamps = allTimestamps; + me.ticks = allTimestamps; + }, + + // Used to get data value locations. Value can either be an index or a numerical value + getPixelForValue: function(value, index, datasetIndex, includeOffset) { + var me = this; + + var offsetAmt = Math.max((me.ticks.length - ((me.options.gridLines.offsetGridLines) ? 0 : 1)), 1); + + if (typeof datasetIndex === 'number') { + var timestamp = me._parsedData.datasets[datasetIndex][index]; + var indexByTimestamp = me._tickTimestamps.indexOf(timestamp); + index = indexByTimestamp !== -1 ? indexByTimestamp : index; + } + + var valueWidth = me.width / offsetAmt; + var widthOffset = valueWidth * index; + + if (me.options.gridLines.offsetGridLines && includeOffset || me.ticks.length === 1 && includeOffset) { + widthOffset += (valueWidth / 2); + } + + return me.left + Math.round(widthOffset); + }, + getPixelForTick: function(index, includeOffset) { + if (this.ticks.length === 1) { + includeOffset = true; + } + return this.getPixelForValue(this.ticks[index], index, null, includeOffset); + }, + getValueForPixel: function(pixel) { + var me = this; + var value; + var offsetAmt = Math.max((me.ticks.length - ((me.options.gridLines.offsetGridLines) ? 0 : 1)), 1); + var valueDimension = me.width / offsetAmt; + + pixel -= me.left; + + if (me.options.gridLines.offsetGridLines) { + pixel -= (valueDimension / 2); + } + + if (pixel <= 0) { + value = 0; + } else { + value = Math.round(pixel / valueDimension); + } + + return value; + }, + getBasePixel: function() { + return this.bottom; + } + }); + + Chart.scaleService.registerScaleType('timeseries', TimeSeriesScale, defaultConfig); + +}; diff --git a/test/specs/scale.category.tests.js b/test/specs/scale.category.tests.js index d63fc59bf0e..2cdc58481a0 100644 --- a/test/specs/scale.category.tests.js +++ b/test/specs/scale.category.tests.js @@ -108,7 +108,7 @@ describe('Category scale tests', function() { expect(scale.ticks).toEqual(mockData.xLabels); }); - it('Should generate ticks from the data xLabels', function() { + it('Should generate ticks from the data yLabels', function() { var scaleID = 'myScale'; var mockData = { diff --git a/test/specs/scale.timeseries.tests.js b/test/specs/scale.timeseries.tests.js new file mode 100644 index 00000000000..137b3c822c7 --- /dev/null +++ b/test/specs/scale.timeseries.tests.js @@ -0,0 +1,375 @@ +// Time scale tests +describe('TimeSeries scale tests', function() { + function createScale(data, options) { + var scaleID = 'myScale'; + var mockContext = window.createMockContext(); + var Constructor = Chart.scaleService.getScaleConstructor('timeseries'); + var scale = new Constructor({ + ctx: mockContext, + options: options, + chart: { + data: data + }, + id: scaleID + }); + + scale.update(400, 50); + return scale; + } + + function getTicksValues(ticks) { + return ticks.map(function(tick) { + return tick.value; + }); + } + + beforeEach(function() { + // Need a time matcher for getValueFromPixel + jasmine.addMatchers({ + toBeCloseToTime: function() { + return { + compare: function(actual, expected) { + var result = false; + + var diff = actual.diff(expected.value, expected.unit, true); + result = Math.abs(diff) < (expected.threshold !== undefined ? expected.threshold : 0.01); + + return { + pass: result + }; + } + }; + } + }); + }); + + it('Should load moment.js as a dependency', function() { + expect(window.moment).not.toBe(undefined); + }); + + it('Should register the constructor with the scale service', function() { + var Constructor = Chart.scaleService.getScaleConstructor('timeseries'); + expect(Constructor).not.toBe(undefined); + expect(typeof Constructor).toBe('function'); + }); + + it('Should have the correct default config', function() { + var defaultConfig = Chart.scaleService.getScaleDefaults('timeseries'); + expect(defaultConfig).toEqual({ + display: true, + gridLines: { + color: 'rgba(0, 0, 0, 0.1)', + drawBorder: true, + drawOnChartArea: true, + drawTicks: true, + tickMarkLength: 10, + lineWidth: 1, + offsetGridLines: false, + display: true, + zeroLineColor: 'rgba(0,0,0,0.25)', + zeroLineWidth: 1, + zeroLineBorderDash: [], + zeroLineBorderDashOffset: 0.0, + borderDash: [], + borderDashOffset: 0.0 + }, + position: 'bottom', + scaleLabel: { + labelString: '', + display: false + }, + ticks: { + beginAtZero: false, + minRotation: 0, + maxRotation: 50, + mirror: false, + padding: 0, + reverse: false, + display: true, + callback: defaultConfig.ticks.callback, // make this nicer, then check explicitly below, + autoSkip: true, + autoSkipPadding: 0, + labelOffset: 0, + minor: {}, + major: {}, + }, + time: { + parser: false, + format: false, + unit: false, + round: false, + displayFormat: false, + minUnit: 'millisecond', + displayFormats: { + millisecond: 'h:mm:ss.SSS a', // 11:20:01.123 AM + second: 'h:mm:ss a', // 11:20:01 AM + minute: 'h:mm:ss a', // 11:20:01 AM + hour: 'MMM D, hA', // Sept 4, 5PM + day: 'll', // Sep 4 2015 + week: 'll', // Week 46, or maybe "[W]WW - YYYY" ? + month: 'MMM YYYY', // Sept 2015 + quarter: '[Q]Q - YYYY', // Q3 + year: 'YYYY' // 2015 + } + } + }); + + // Is this actually a function + expect(defaultConfig.ticks.callback).toEqual(jasmine.any(Function)); + }); +// + describe('when given inputs of different types', function() { + // Helper to build date objects + function newDateFromRef(days) { + return moment('01/01/2015 12:00', 'DD/MM/YYYY HH:mm').add(days, 'd').toDate(); + } + + it('should accept labels as strings', function() { + var mockData = { + labels: ['2015-01-01T12:00:00', '2015-01-02T21:00:00', '2015-01-03T22:00:00', '2015-01-05T23:00:00', '2015-01-07T03:00', '2015-01-08T10:00', '2015-01-10T12:00'], // days + }; + + var scale = createScale(mockData, Chart.scaleService.getScaleDefaults('timeseries')); + scale.update(1000, 200); + expect(getTicksValues(scale.ticks)).toEqual(['Jan 1, 2015', 'Jan 2, 2015', 'Jan 3, 2015', 'Jan 5, 2015', 'Jan 7, 2015', 'Jan 8, 2015', 'Jan 10, 2015']); + }); + + it('should accept labels as date objects', function() { + var mockData = { + labels: [newDateFromRef(0), newDateFromRef(1), newDateFromRef(2), newDateFromRef(4), newDateFromRef(6), newDateFromRef(7), newDateFromRef(9)], // days + }; + var scale = createScale(mockData, Chart.scaleService.getScaleDefaults('timeseries')); + scale.update(1000, 200); + expect(getTicksValues(scale.ticks)).toEqual(['Jan 1, 2015', 'Jan 2, 2015', 'Jan 3, 2015', 'Jan 5, 2015', 'Jan 7, 2015', 'Jan 8, 2015', 'Jan 10, 2015']); + }); + + it('should accept data as xy points', function() { + var chart = window.acquireChart({ + type: 'line', + data: { + datasets: [{ + xAxisID: 'xScale0', + data: [{ + x: newDateFromRef(0), + y: 1 + }, { + x: newDateFromRef(1), + y: 10 + }, { + x: newDateFromRef(2), + y: 0 + }, { + x: newDateFromRef(4), + y: 5 + }, { + x: newDateFromRef(6), + y: 77 + }, { + x: newDateFromRef(7), + y: 9 + }, { + x: newDateFromRef(9), + y: 5 + }] + }], + }, + options: { + scales: { + xAxes: [{ + id: 'xScale0', + type: 'timeseries', + position: 'bottom' + }], + } + } + }); + + var xScale = chart.scales.xScale0; + xScale.update(800, 200); + expect(getTicksValues(xScale.ticks)).toEqual(['Jan 1, 2015', 'Jan 2, 2015', 'Jan 3, 2015', 'Jan 5, 2015', 'Jan 7, 2015', 'Jan 8, 2015', 'Jan 10, 2015']); + }); + }); + + it('should allow custom time parsers', function() { + var chart = window.acquireChart({ + type: 'line', + data: { + datasets: [{ + xAxisID: 'xScale0', + data: [{ + x: 375068900, + y: 1 + }] + }], + }, + options: { + scales: { + xAxes: [{ + id: 'xScale0', + type: 'timeseries', + position: 'bottom', + time: { + unit: 'day', + round: true, + parser: function(label) { + return moment.unix(label); + } + } + }], + } + } + }); + + // Counts down because the lines are drawn top to bottom + var xScale = chart.scales.xScale0; + + // Counts down because the lines are drawn top to bottom + expect(getTicksValues(xScale.ticks)[0]).toEqualOneOf(['Nov 19, 1981', 'Nov 20, 1981', 'Nov 21, 1981']); // handle time zone changes + }); + + it('should build ticks using the config unit', function() { + var mockData = { + labels: ['2015-01-01T20:00:00', '2015-01-02T21:00:00'], // days + }; + + var config = Chart.helpers.clone(Chart.scaleService.getScaleDefaults('timeseries')); + config.time.unit = 'hour'; + + var scale = createScale(mockData, config); + scale.update(2500, 200); + expect(getTicksValues(scale.ticks)).toEqual(['Jan 1, 8PM', 'Jan 2, 9PM']); + }); + + it('build ticks honoring the minUnit', function() { + var mockData = { + labels: ['2015-01-01T20:00:00', '2016-01-02T21:00:00'], // days + }; + + var config = Chart.helpers.clone(Chart.scaleService.getScaleDefaults('timeseries')); + config.time.minUnit = 'year'; + + var scale = createScale(mockData, config); + expect(getTicksValues(scale.ticks)).toEqual(['2015', '2016']); + }); + + it('should build ticks using the config diff', function() { + var mockData = { + labels: ['2015-01-01T20:00:00', '2015-02-02T21:00:00', '2015-02-21T01:00:00'], // days + }; + + var config = Chart.helpers.clone(Chart.scaleService.getScaleDefaults('time')); + config.time.unit = 'week'; + config.time.round = 'week'; + + var scale = createScale(mockData, config); + scale.update(800, 200); + + // last date is feb 15 because we round to start of week + expect(getTicksValues(scale.ticks)).toEqual(['Dec 28, 2014', 'Feb 1, 2015', 'Feb 15, 2015']); + }); + + it('should get the correct label for a data value', function() { + var chart = window.acquireChart({ + type: 'line', + data: { + datasets: [{ + xAxisID: 'xScale0', + data: [null, 10, 3] + }], + labels: ['2015-01-01T20:00:00', '2015-01-02T21:00:00', '2015-01-03T22:00:00', '2015-01-05T23:00:00', '2015-01-07T03:00', '2015-01-08T10:00', '2015-01-10T12:00'], // days + }, + options: { + scales: { + xAxes: [{ + id: 'xScale0', + type: 'time', + position: 'bottom' + }], + } + } + }); + + var xScale = chart.scales.xScale0; + expect(xScale.getLabelForIndex(0, 0)).toBeTruthy(); + expect(xScale.getLabelForIndex(0, 0)).toBe('2015-01-01T20:00:00'); + expect(xScale.getLabelForIndex(6, 0)).toBe('2015-01-10T12:00'); + }); + + it ('Should get the correct pixel for a value', function() { + var chart = window.acquireChart({ + type: 'line', + data: { + datasets: [{ + xAxisID: 'xScale0', + yAxisID: 'yScale0', + data: [10, 5, 0, 25, 78] + }], + labels: ['2015-01-01T20:00:00', '2015-01-02T21:00:00', '2015-01-03T22:00:00', '2015-01-05T23:00:00', '2015-01-07T03:00'] + }, + options: { + scales: { + xAxes: [{ + id: 'xScale0', + type: 'timeseries', + position: 'bottom' + }], + yAxes: [{ + id: 'yScale0', + type: 'linear' + }] + } + } + }); + + var xScale = chart.scales.xScale0; + expect(xScale.getPixelForValue(0, 0, 0, false)).toBeCloseToPixel(42); + expect(xScale.getPixelForValue(0, 0, 0, true)).toBeCloseToPixel(42); + expect(xScale.getValueForPixel(33)).toBe(0); + + expect(xScale.getPixelForValue(0, 4, 0, false)).toBeCloseToPixel(470); + expect(xScale.getPixelForValue(0, 4, 0, true)).toBeCloseToPixel(470); + expect(xScale.getValueForPixel(487)).toBe(4); + + xScale.options.gridLines.offsetGridLines = true; + + expect(xScale.getPixelForValue(0, 0, 0, false)).toBeCloseToPixel(42); + expect(xScale.getPixelForValue(0, 0, 0, true)).toBeCloseToPixel(85); + expect(xScale.getValueForPixel(33)).toBe(0); + expect(xScale.getValueForPixel(78)).toBe(0); + + expect(xScale.getPixelForValue(0, 4, 0, false)).toBeCloseToPixel(384); + expect(xScale.getPixelForValue(0, 4, 0, true)).toBeCloseToPixel(427); + expect(xScale.getValueForPixel(410)).toBe(4); + expect(xScale.getValueForPixel(433)).toBe(4); + }); + + it('does not create a negative width chart when hidden', function() { + var chart = window.acquireChart({ + type: 'line', + data: { + datasets: [{ + data: [] + }] + }, + options: { + scales: { + xAxes: [{ + type: 'time', + time: { + min: moment().subtract(1, 'months'), + max: moment(), + } + }], + }, + responsive: true, + }, + }, { + wrapper: { + style: 'display: none', + }, + }); + expect(chart.scales['y-axis-0'].width).toEqual(0); + expect(chart.scales['y-axis-0'].maxWidth).toEqual(0); + expect(chart.width).toEqual(0); + }); +});