diff --git a/samples/scales/time/line-point-data.html b/samples/scales/time/line-point-data.html index d880515f5a1..1eeefa3ada0 100644 --- a/samples/scales/time/line-point-data.html +++ b/samples/scales/time/line-point-data.html @@ -119,7 +119,8 @@ document.getElementById('addData').addEventListener('click', function() { if (config.data.datasets.length > 0) { - var lastTime = myLine.scales['x-axis-0'].labelMoments[0].length ? myLine.scales['x-axis-0'].labelMoments[0][myLine.scales['x-axis-0'].labelMoments[0].length - 1] : moment(); + var numTicks = myLine.scales['x-axis-0'].ticksAsTimestamps.length; + var lastTime = numTicks ? moment(myLine.scales['x-axis-0'].ticksAsTimestamps[numTicks - 1]) : moment(); var newTime = lastTime .clone() .add(1, 'day') diff --git a/src/scales/scale.time.js b/src/scales/scale.time.js index e922734fb00..567454d2951 100755 --- a/src/scales/scale.time.js +++ b/src/scales/scale.time.js @@ -7,35 +7,43 @@ moment = typeof(moment) === 'function' ? moment : window.moment; module.exports = function(Chart) { var helpers = Chart.helpers; - var time = { - units: [{ - name: 'millisecond', + var interval = { + millisecond: { + size: 1, steps: [1, 2, 5, 10, 20, 50, 100, 250, 500] - }, { - name: 'second', + }, + second: { + size: 1000, steps: [1, 2, 5, 10, 30] - }, { - name: 'minute', + }, + minute: { + size: 60000, steps: [1, 2, 5, 10, 30] - }, { - name: 'hour', + }, + hour: { + size: 3600000, steps: [1, 2, 3, 6, 12] - }, { - name: 'day', + }, + day: { + size: 86400000, steps: [1, 2, 5] - }, { - name: 'week', + }, + week: { + size: 604800000, maxStep: 4 - }, { - name: 'month', + }, + month: { + size: 2.628e9, maxStep: 3 - }, { - name: 'quarter', + }, + quarter: { + size: 7.884e9, maxStep: 4 - }, { - name: 'year', + }, + year: { + size: 3.154e10, maxStep: false - }] + } }; var defaultConfig = { @@ -61,253 +69,270 @@ module.exports = function(Chart) { month: 'MMM YYYY', // Sept 2015 quarter: '[Q]Q - YYYY', // Q3 year: 'YYYY' // 2015 - } + }, }, ticks: { autoSkip: false } }; - var TimeScale = 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'); + /** + * Helper function to parse time to a moment object + * @param axis {TimeAxis} the time axis + * @param label {Date|string|number|Moment} The thing to parse + * @return {Moment} parsed time + */ + function parseTime(axis, label) { + var timeOpts = axis.options.time; + if (typeof timeOpts.parser === 'string') { + return moment(label, timeOpts.parser); + } + if (typeof timeOpts.parser === 'function') { + return timeOpts.parser(label); + } + if (typeof label.getMonth === 'function' || typeof label === 'number') { + // Date objects + return moment(label); + } + if (label.isValid && label.isValid()) { + // Moment support + return label; + } + var format = timeOpts.format; + if (typeof format !== 'string' && format.call) { + // Custom parsing (return an instance of moment) + console.warn('options.time.format is deprecated and replaced by options.time.parser.'); + return format(label); + } + // Moment format parsing + return moment(label, format); + } + + /** + * Figure out which is the best unit for the scale + * @param minUnit {String} minimum unit to use + * @param min {Number} scale minimum + * @param max {Number} scale maximum + * @return {String} the unit to use + */ + function determineUnit(minUnit, min, max, maxTicks) { + var units = Object.keys(interval); + var unit; + var numUnits = units.length; + + for (var i = units.indexOf(minUnit); i < numUnits; i++) { + unit = units[i]; + var unitDetails = interval[unit]; + var steps = (unitDetails.steps && unitDetails.steps[unitDetails.steps.length - 1]) || unitDetails.maxStep; + if (steps === undefined || Math.ceil((max - min) / (steps * unitDetails.size)) <= maxTicks) { + break; } + } - Chart.Scale.prototype.initialize.call(this); - }, - getLabelDiff: function(datasetIndex, index) { - var me = this; - if (datasetIndex === null || index === null) { - return null; + return unit; + } + + /** + * Determines how we scale the unit + * @param min {Number} the scale minimum + * @param max {Number} the scale maximum + * @param unit {String} the unit determined by the {@see determineUnit} method + * @return {Number} the axis step size as a multiple of unit + */ + function determineStepSize(min, max, unit, maxTicks) { + // Using our unit, figoure out what we need to scale as + var unitDefinition = interval[unit]; + var unitSizeInMilliSeconds = unitDefinition.size; + var sizeInUnits = Math.ceil((max - min) / unitSizeInMilliSeconds); + var multiplier = 1; + + if (unitDefinition.steps) { + // Have an array of steps + var numSteps = unitDefinition.steps.length; + for (var i = 0; i < numSteps && sizeInUnits > maxTicks; i++) { + multiplier = unitDefinition.steps[i]; + sizeInUnits = Math.ceil((max - min) / (unitSizeInMilliSeconds * multiplier)); } - - if (me.labelDiffs === undefined) { - me.buildLabelDiffs(); + } else { + while (sizeInUnits > maxTicks) { + ++multiplier; + sizeInUnits = Math.ceil((max - min) / (unitSizeInMilliSeconds * multiplier)); } + } - if (typeof me.labelDiffs[datasetIndex] !== 'undefined') { - return me.labelDiffs[datasetIndex][index]; + return multiplier; + } + + /** + * Helper for generating axis labels. + * @param options {ITimeGeneratorOptions} the options for generation + * @param dataRange {IRange} the data range + * @param niceRange {IRange} the pretty range to display + * @return {Number[]} ticks + */ + function generateTicks(options, dataRange, niceRange) { + var ticks = []; + if (options.maxTicks) { + var stepSize = options.stepSize; + ticks.push(options.min !== undefined ? options.min : niceRange.min); + var cur = moment(niceRange.min); + while (cur.add(stepSize, options.unit).valueOf() < niceRange.max) { + ticks.push(cur.valueOf()); } + var realMax = options.max || niceRange.max; + if (ticks[ticks.length - 1] !== realMax) { + ticks.push(realMax); + } + } + return ticks; + } + + /** + * @function Chart.Ticks.generators.time + * @param options {ITimeGeneratorOptions} the options for generation + * @param dataRange {IRange} the data range + * @return {Number[]} ticks + */ + Chart.Ticks.generators.time = function(options, dataRange) { + var niceMin; + var niceMax; + var isoWeekday = options.isoWeekday; + if (options.unit === 'week' && isoWeekday !== false) { + niceMin = moment(dataRange.min).startOf('isoWeek').isoWeekday(isoWeekday).valueOf(); + niceMax = moment(dataRange.max).startOf('isoWeek').isoWeekday(isoWeekday); + if (dataRange.max - niceMax > 0) { + niceMax.add(1, 'week'); + } + niceMax = niceMax.valueOf(); + } else { + niceMin = moment(dataRange.min).startOf(options.unit).valueOf(); + niceMax = moment(dataRange.max).startOf(options.unit); + if (dataRange.max - niceMax > 0) { + niceMax.add(1, options.unit); + } + niceMax = niceMax.valueOf(); + } + return generateTicks(options, dataRange, { + min: niceMin, + max: niceMax + }); + }; - return null; - }, - getMomentStartOf: function(tick) { - var me = this; - if (me.options.time.unit === 'week' && me.options.time.isoWeekday !== false) { - return tick.clone().startOf('isoWeek').isoWeekday(me.options.time.isoWeekday); + var TimeScale = 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'); } - return tick.clone().startOf(me.tickUnit); + + Chart.Scale.prototype.initialize.call(this); }, determineDataLimits: function() { var me = this; - me.labelMoments = []; + var timeOpts = me.options.time; - function appendLabel(array, label) { - var labelMoment = me.parseTime(label); - if (labelMoment.isValid()) { - if (me.options.time.round) { - labelMoment.startOf(me.options.time.round); - } - array.push(labelMoment); - } - } + // We store the data range as unix millisecond timestamps so dataMin and dataMax will always be integers. + var dataMin = Number.MAX_SAFE_INTEGER; + var dataMax = Number.MIN_SAFE_INTEGER; - // Only parse these once. If the dataset does not have data as x,y pairs, we will use - // these - var scaleLabelMoments = []; - if (me.chart.data.labels && me.chart.data.labels.length > 0) { - helpers.each(me.chart.data.labels, function(label) { - appendLabel(scaleLabelMoments, label); - }, me); - - me.firstTick = moment.min(scaleLabelMoments); - me.lastTick = moment.max(scaleLabelMoments); - } else { - me.firstTick = null; - me.lastTick = null; - } + var chartData = me.chart.data; + var parsedData = { + labels: [], + datasets: [] + }; - helpers.each(me.chart.data.datasets, function(dataset, datasetIndex) { - var momentsForDataset = []; + var timestamp; - if (typeof dataset.data[0] === 'object' && dataset.data[0] !== null) { - helpers.each(dataset.data, function(value) { - appendLabel(momentsForDataset, me.getRightValue(value)); - }, me); + helpers.each(chartData.labels, function(label, labelIndex) { + var labelMoment = parseTime(me, label); - if (me.chart.isDatasetVisible(datasetIndex)) { - // May have gone outside the scale ranges, make sure we keep the first and last ticks updated - var min = moment.min(momentsForDataset); - var max = moment.max(momentsForDataset); - me.firstTick = me.firstTick !== null ? moment.min(me.firstTick, min) : min; - me.lastTick = me.lastTick !== null ? moment.max(me.lastTick, max) : max; + if (labelMoment.isValid()) { + // We need to round the time + if (timeOpts.round) { + labelMoment.startOf(timeOpts.round); } - } else { - // We have no labels. Use the ones from the scale - momentsForDataset = scaleLabelMoments; - } - - me.labelMoments.push(momentsForDataset); - }, me); - // Set these after we've done all the data - if (me.options.time.min) { - me.firstTick = me.parseTime(me.options.time.min); - } + timestamp = labelMoment.valueOf(); + dataMin = Math.min(timestamp, dataMin); + dataMax = Math.max(timestamp, dataMax); - if (me.options.time.max) { - me.lastTick = me.parseTime(me.options.time.max); - } - - // We will modify these, so clone for later - me.firstTick = (me.firstTick || moment()).clone(); - me.lastTick = (me.lastTick || moment()).clone(); - }, - buildLabelDiffs: function() { - var me = this; - - me.labelDiffs = me.labelMoments.map(function(datasetLabels) { - return datasetLabels.map(function(label) { - return label.diff(me.firstTick, me.tickUnit, true); - }); + // Store this value for later + parsedData.labels[labelIndex] = timestamp; + } }); - }, - buildTicks: function() { - var me = this; - - me.ctx.save(); - var tickFontSize = helpers.getValueOrDefault(me.options.ticks.fontSize, Chart.defaults.global.defaultFontSize); - var tickFontStyle = helpers.getValueOrDefault(me.options.ticks.fontStyle, Chart.defaults.global.defaultFontStyle); - var tickFontFamily = helpers.getValueOrDefault(me.options.ticks.fontFamily, Chart.defaults.global.defaultFontFamily); - var tickLabelFont = helpers.fontString(tickFontSize, tickFontStyle, tickFontFamily); - me.ctx.font = tickLabelFont; - - me.ticks = []; - me.unitScale = 1; // How much we scale the unit by, ie 2 means 2x unit per step - me.scaleSizeInUnits = 0; // How large the scale is in the base unit (seconds, minutes, etc) - - // Set unit override if applicable - if (me.options.time.unit) { - me.tickUnit = me.options.time.unit || 'day'; - me.displayFormat = me.options.time.displayFormats[me.tickUnit]; - me.scaleSizeInUnits = me.lastTick.diff(me.firstTick, me.tickUnit, true); - me.unitScale = helpers.getValueOrDefault(me.options.time.unitStepSize, 1); - } else { - // Determine the smallest needed unit of the time - var innerWidth = me.isHorizontal() ? me.width : me.height; - - // Crude approximation of what the label length might be - var tempFirstLabel = me.tickFormatFunction(me.firstTick, 0, []); - var tickLabelWidth = me.ctx.measureText(tempFirstLabel).width; - var cosRotation = Math.cos(helpers.toRadians(me.options.ticks.maxRotation)); - var sinRotation = Math.sin(helpers.toRadians(me.options.ticks.maxRotation)); - tickLabelWidth = (tickLabelWidth * cosRotation) + (tickFontSize * sinRotation); - var labelCapacity = innerWidth / (tickLabelWidth); - - // Start as small as possible - me.tickUnit = me.options.time.minUnit; - me.scaleSizeInUnits = me.lastTick.diff(me.firstTick, me.tickUnit, true); - me.displayFormat = me.options.time.displayFormats[me.tickUnit]; - - var unitDefinitionIndex = 0; - var unitDefinition = time.units[unitDefinitionIndex]; - - // While we aren't ideal and we don't have units left - while (unitDefinitionIndex < time.units.length) { - // Can we scale this unit. If `false` we can scale infinitely - me.unitScale = 1; - - if (helpers.isArray(unitDefinition.steps) && Math.ceil(me.scaleSizeInUnits / labelCapacity) < helpers.max(unitDefinition.steps)) { - // Use one of the predefined steps - for (var idx = 0; idx < unitDefinition.steps.length; ++idx) { - if (unitDefinition.steps[idx] >= Math.ceil(me.scaleSizeInUnits / labelCapacity)) { - me.unitScale = helpers.getValueOrDefault(me.options.time.unitStepSize, unitDefinition.steps[idx]); - break; - } - } - break; - } else if ((unitDefinition.maxStep === false) || (Math.ceil(me.scaleSizeInUnits / labelCapacity) < unitDefinition.maxStep)) { - // We have a max step. Scale this unit - me.unitScale = helpers.getValueOrDefault(me.options.time.unitStepSize, Math.ceil(me.scaleSizeInUnits / labelCapacity)); - break; - } else { - // Move to the next unit up - ++unitDefinitionIndex; - unitDefinition = time.units[unitDefinitionIndex]; - - me.tickUnit = unitDefinition.name; - var leadingUnitBuffer = me.firstTick.diff(me.getMomentStartOf(me.firstTick), me.tickUnit, true); - var trailingUnitBuffer = me.getMomentStartOf(me.lastTick.clone().add(1, me.tickUnit)).diff(me.lastTick, me.tickUnit, true); - me.scaleSizeInUnits = me.lastTick.diff(me.firstTick, me.tickUnit, true) + leadingUnitBuffer + trailingUnitBuffer; - me.displayFormat = me.options.time.displayFormats[unitDefinition.name]; - } - } - } + helpers.each(chartData.datasets, function(dataset, datasetIndex) { + var timestamps = []; - var roundedStart; + 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 = parseTime(me, me.getRightValue(value)); - // Only round the first tick if we have no hard minimum - if (!me.options.time.min) { - me.firstTick = me.getMomentStartOf(me.firstTick); - roundedStart = me.firstTick; - } else { - roundedStart = me.getMomentStartOf(me.firstTick); - } + if (dataMoment.isValid()) { + if (timeOpts.round) { + dataMoment.startOf(timeOpts.round); + } - // Only round the last tick if we have no hard maximum - if (!me.options.time.max) { - var roundedEnd = me.getMomentStartOf(me.lastTick); - var delta = roundedEnd.diff(me.lastTick, me.tickUnit, true); - if (delta < 0) { - // Do not use end of because we need me to be in the next time unit - me.lastTick = me.getMomentStartOf(me.lastTick.add(1, me.tickUnit)); - } else if (delta >= 0) { - me.lastTick = roundedEnd; + timestamp = dataMoment.valueOf(); + dataMin = Math.min(timestamp, dataMin); + dataMax = Math.max(timestamp, dataMax); + timestamps[dataIndex] = timestamp; + } + }); + } else { + // We have no x coordinates, so use the ones from the labels + timestamps = parsedData.labels.slice(); } - me.scaleSizeInUnits = me.lastTick.diff(me.firstTick, me.tickUnit, true); - } - - // Tick displayFormat override - if (me.options.time.displayFormat) { - me.displayFormat = me.options.time.displayFormat; - } + parsedData.datasets[datasetIndex] = timestamps; + }); - // first tick. will have been rounded correctly if options.time.min is not specified - me.ticks.push(me.firstTick.clone()); + me.dataMin = dataMin; + me.dataMax = dataMax; + me._parsedData = parsedData; + }, + buildTicks: function() { + var me = this; + var timeOpts = me.options.time; - // For every unit in between the first and last moment, create a moment and add it to the ticks tick - for (var i = me.unitScale; i <= me.scaleSizeInUnits; i += me.unitScale) { - var newTick = roundedStart.clone().add(i, me.tickUnit); + var minTimestamp; + var maxTimestamp; + var dataMin = me.dataMin; + var dataMax = me.dataMax; - // Are we greater than the max time - if (me.options.time.max && newTick.diff(me.lastTick, me.tickUnit, true) >= 0) { - break; + if (timeOpts.min) { + var minMoment = parseTime(me, timeOpts.min); + if (timeOpts.round) { + minMoment.round(timeOpts.round); } - - me.ticks.push(newTick); + minTimestamp = minMoment.valueOf(); } - // Always show the right tick - var diff = me.ticks[me.ticks.length - 1].diff(me.lastTick, me.tickUnit); - if (diff !== 0 || me.scaleSizeInUnits === 0) { - // this is a weird case. If the option is the same as the end option, we can't just diff the times because the tick was created from the roundedStart - // but the last tick was not rounded. - if (me.options.time.max) { - me.ticks.push(me.lastTick.clone()); - me.scaleSizeInUnits = me.lastTick.diff(me.ticks[0], me.tickUnit, true); - } else { - me.ticks.push(me.lastTick.clone()); - me.scaleSizeInUnits = me.lastTick.diff(me.firstTick, me.tickUnit, true); - } + if (timeOpts.max) { + maxTimestamp = parseTime(me, timeOpts.max).valueOf(); } - me.ctx.restore(); + var maxTicks = me.getLabelCapacity(minTimestamp || dataMin); + var unit = timeOpts.unit || determineUnit(timeOpts.minUnit, minTimestamp || dataMin, maxTimestamp || dataMax, maxTicks); + me.displayFormat = timeOpts.displayFormats[unit]; + + var stepSize = timeOpts.stepSize || determineStepSize(minTimestamp || dataMin, maxTimestamp || dataMax, unit, maxTicks); + var ticks = me.ticks = Chart.Ticks.generators.time({ + maxTicks: maxTicks, + min: minTimestamp, + max: maxTimestamp, + stepSize: stepSize, + unit: unit, + isoWeekday: timeOpts.isoWeekday + }, { + min: dataMin, + max: dataMax + }); - // Invalidate label diffs cache - me.labelDiffs = undefined; + // 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(ticks); + me.min = helpers.min(ticks); }, // Get tooltip label getLabelForIndex: function(index, datasetIndex) { @@ -321,7 +346,7 @@ module.exports = function(Chart) { // Format nicely if (me.options.time.tooltipFormat) { - label = me.parseTime(label).format(me.options.time.tooltipFormat); + label = parseTime(me, label).format(me.options.time.tooltipFormat); } return label; @@ -339,71 +364,76 @@ module.exports = function(Chart) { }, convertTicksToLabels: function() { var me = this; - me.tickMoments = me.ticks; - me.ticks = me.ticks.map(me.tickFormatFunction, me); + 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; + + if (me.isHorizontal()) { + var valueOffset = (me.width * decimal); + return me.left + Math.round(valueOffset); + } + + var heightOffset = (me.height * decimal); + return me.top + Math.round(heightOffset); }, getPixelForValue: function(value, index, datasetIndex) { var me = this; var offset = null; if (index !== undefined && datasetIndex !== undefined) { - offset = me.getLabelDiff(datasetIndex, index); + offset = me._parsedData.datasets[datasetIndex][index]; } if (offset === null) { if (!value || !value.isValid) { // not already a moment object - value = me.parseTime(me.getRightValue(value)); + value = parseTime(me, me.getRightValue(value)); } + if (value && value.isValid && value.isValid()) { - offset = value.diff(me.firstTick, me.tickUnit, true); + offset = value.valueOf(); } } if (offset !== null) { - var decimal = offset !== 0 ? offset / me.scaleSizeInUnits : offset; - - if (me.isHorizontal()) { - var valueOffset = (me.width * decimal); - return me.left + Math.round(valueOffset); - } - - var heightOffset = (me.height * decimal); - return me.top + Math.round(heightOffset); + return me.getPixelForOffset(offset); } }, getPixelForTick: function(index) { - return this.getPixelForValue(this.tickMoments[index], null, null); + 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; - offset *= me.scaleSizeInUnits; - return me.firstTick.clone().add(moment.duration(offset, me.tickUnit).asSeconds(), 'seconds'); + return moment(me.min + (offset * (me.max - me.min))); }, - parseTime: function(label) { + // Crude approximation of what the label width might be + getLabelWidth: function(label) { var me = this; - if (typeof me.options.time.parser === 'string') { - return moment(label, me.options.time.parser); - } - if (typeof me.options.time.parser === 'function') { - return me.options.time.parser(label); - } - // Date objects - if (typeof label.getMonth === 'function' || typeof label === 'number') { - return moment(label); - } - // Moment support - if (label.isValid && label.isValid()) { - return label; - } - // Custom parsing (return an instance of moment) - if (typeof me.options.time.format !== 'string' && me.options.time.format.call) { - console.warn('options.time.format is deprecated and replaced by options.time.parser. See http://nnnick.github.io/Chart.js/docs-v2/#scales-time-scale'); - return me.options.time.format(label); - } - // Moment format parsing - return moment(label, me.options.time.format); + 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.getValueOrDefault(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, []); + 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); diff --git a/test/specs/scale.time.tests.js b/test/specs/scale.time.tests.js index 1aa3fa2bf2f..076acfbf01a 100755 --- a/test/specs/scale.time.tests.js +++ b/test/specs/scale.time.tests.js @@ -1,5 +1,22 @@ // Time scale tests describe('Time scale tests', function() { + function createScale(data, options) { + var scaleID = 'myScale'; + var mockContext = window.createMockContext(); + var Constructor = Chart.scaleService.getScaleConstructor('time'); + var scale = new Constructor({ + ctx: mockContext, + options: options, + chart: { + data: data + }, + id: scaleID + }); + + scale.update(400, 50); + return scale; + } + beforeEach(function() { // Need a time matcher for getValueFromPixel jasmine.addMatchers({ @@ -9,7 +26,7 @@ describe('Time scale tests', function() { var result = false; var diff = actual.diff(expected.value, expected.unit, true); - result = Math.abs(diff) < (expected.threshold !== undefined ? expected.threshold : 0.5); + result = Math.abs(diff) < (expected.threshold !== undefined ? expected.threshold : 0.01); return { pass: result @@ -94,113 +111,76 @@ describe('Time scale tests', function() { expect(defaultConfig.ticks.callback).toEqual(jasmine.any(Function)); }); - it('should build ticks using days', function() { - var scaleID = 'myScale'; - - var mockData = { - 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 - }; - - var mockContext = window.createMockContext(); - var Constructor = Chart.scaleService.getScaleConstructor('time'); - var scale = new Constructor({ - ctx: mockContext, - options: Chart.scaleService.getScaleDefaults('time'), // use default config for scale - chart: { - data: mockData - }, - id: scaleID - }); - - // scale.buildTicks(); - scale.update(400, 50); - - // Counts down because the lines are drawn top to bottom - expect(scale.ticks).toEqual(['Dec 28, 2014', 'Jan 4, 2015', 'Jan 11, 2015']); - }); - - it('should build ticks using date objects', 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(); } - var scaleID = 'myScale'; - var mockData = { - labels: [newDateFromRef(0), newDateFromRef(1), newDateFromRef(2), newDateFromRef(4), newDateFromRef(6), newDateFromRef(7), newDateFromRef(9)], // days - }; + 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 mockContext = window.createMockContext(); - var Constructor = Chart.scaleService.getScaleConstructor('time'); - var scale = new Constructor({ - ctx: mockContext, - options: Chart.scaleService.getScaleDefaults('time'), // use default config for scale - chart: { - data: mockData - }, - id: scaleID + var scale = createScale(mockData, Chart.scaleService.getScaleDefaults('time')); + scale.update(1000, 200); + expect(scale.ticks).toEqual(['Jan 1, 2015', 'Jan 2, 2015', 'Jan 3, 2015', 'Jan 4, 2015', 'Jan 5, 2015', 'Jan 6, 2015', 'Jan 7, 2015', 'Jan 8, 2015', 'Jan 9, 2015', 'Jan 10, 2015', 'Jan 11, 2015']); }); - scale.update(400, 50); - - // Counts down because the lines are drawn top to bottom - expect(scale.ticks).toEqual(['Dec 28, 2014', 'Jan 4, 2015', 'Jan 11, 2015']); - }); - - it('should build ticks when the data is xy points', 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 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('time')); + scale.update(1000, 200); + expect(scale.ticks).toEqual(['Jan 1, 2015', 'Jan 2, 2015', 'Jan 3, 2015', 'Jan 4, 2015', 'Jan 5, 2015', 'Jan 6, 2015', 'Jan 7, 2015', 'Jan 8, 2015', 'Jan 9, 2015', 'Jan 10, 2015', 'Jan 11, 2015']); + }); - var chart = window.acquireChart({ - type: 'line', - data: { - datasets: [{ - xAxisID: 'xScale0', - yAxisID: 'yScale0', - 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: 'time', - position: 'bottom' + 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 + }] }], - yAxes: [{ - id: 'yScale0', - type: 'linear' - }] + }, + options: { + scales: { + xAxes: [{ + id: 'xScale0', + type: 'time', + position: 'bottom' + }], + } } - } - }); + }); - // Counts down because the lines are drawn top to bottom - var xScale = chart.scales.xScale0; - expect(xScale.ticks).toEqual(['Jan 1, 2015', 'Jan 3, 2015', 'Jan 5, 2015', 'Jan 7, 2015', 'Jan 9, 2015', 'Jan 11, 2015']); + var xScale = chart.scales.xScale0; + xScale.update(800, 200); + expect(xScale.ticks).toEqual(['Jan 1, 2015', 'Jan 2, 2015', 'Jan 3, 2015', 'Jan 4, 2015', 'Jan 5, 2015', 'Jan 6, 2015', 'Jan 7, 2015', 'Jan 8, 2015', 'Jan 9, 2015', 'Jan 10, 2015', 'Jan 11, 2015']); + }); }); it('should allow custom time parsers', function() { @@ -209,7 +189,6 @@ describe('Time scale tests', function() { data: { datasets: [{ xAxisID: 'xScale0', - yAxisID: 'yScale0', data: [{ x: 375068900, y: 1 @@ -230,10 +209,6 @@ describe('Time scale tests', function() { } } }], - yAxes: [{ - id: 'yScale0', - type: 'linear' - }] } } }); @@ -247,111 +222,74 @@ describe('Time scale tests', function() { }); it('should build ticks using the config unit', function() { - var scaleID = 'myScale'; - var mockData = { labels: ['2015-01-01T20:00:00', '2015-01-02T21:00:00'], // days }; - var mockContext = window.createMockContext(); var config = Chart.helpers.clone(Chart.scaleService.getScaleDefaults('time')); config.time.unit = 'hour'; - var Constructor = Chart.scaleService.getScaleConstructor('time'); - var scale = new Constructor({ - ctx: mockContext, - options: config, // use default config for scale - chart: { - data: mockData - }, - id: scaleID - }); - // scale.buildTicks(); - scale.update(400, 50); + var scale = createScale(mockData, config); + scale.update(2500, 200); expect(scale.ticks).toEqual(['Jan 1, 8PM', 'Jan 1, 9PM', 'Jan 1, 10PM', 'Jan 1, 11PM', 'Jan 2, 12AM', 'Jan 2, 1AM', 'Jan 2, 2AM', 'Jan 2, 3AM', 'Jan 2, 4AM', 'Jan 2, 5AM', 'Jan 2, 6AM', 'Jan 2, 7AM', 'Jan 2, 8AM', 'Jan 2, 9AM', 'Jan 2, 10AM', 'Jan 2, 11AM', 'Jan 2, 12PM', 'Jan 2, 1PM', 'Jan 2, 2PM', 'Jan 2, 3PM', 'Jan 2, 4PM', 'Jan 2, 5PM', 'Jan 2, 6PM', 'Jan 2, 7PM', 'Jan 2, 8PM', 'Jan 2, 9PM']); }); it('build ticks honoring the minUnit', function() { - var scaleID = 'myScale'; - var mockData = { labels: ['2015-01-01T20:00:00', '2015-01-02T21:00:00'], // days }; - var mockContext = window.createMockContext(); var config = Chart.helpers.clone(Chart.scaleService.getScaleDefaults('time')); config.time.minUnit = 'day'; - var Constructor = Chart.scaleService.getScaleConstructor('time'); - var scale = new Constructor({ - ctx: mockContext, - options: config, // use default config for scale - chart: { - data: mockData - }, - id: scaleID - }); - // scale.buildTicks(); - scale.update(400, 50); + var scale = createScale(mockData, config); expect(scale.ticks).toEqual(['Jan 1, 2015', 'Jan 2, 2015', 'Jan 3, 2015']); }); it('should build ticks using the config diff', function() { - var scaleID = 'myScale'; - var mockData = { labels: ['2015-01-01T20:00:00', '2015-02-02T21:00:00', '2015-02-21T01:00:00'], // days }; - var mockContext = window.createMockContext(); var config = Chart.helpers.clone(Chart.scaleService.getScaleDefaults('time')); config.time.unit = 'week'; config.time.round = 'week'; - var Constructor = Chart.scaleService.getScaleConstructor('time'); - var scale = new Constructor({ - ctx: mockContext, - options: config, // use default config for scale - chart: { - data: mockData - }, - id: scaleID - }); - // scale.buildTicks(); - scale.update(400, 50); + var scale = createScale(mockData, config); + scale.update(800, 200); // last date is feb 15 because we round to start of week expect(scale.ticks).toEqual(['Dec 28, 2014', 'Jan 4, 2015', 'Jan 11, 2015', 'Jan 18, 2015', 'Jan 25, 2015', 'Feb 1, 2015', 'Feb 8, 2015', 'Feb 15, 2015']); }); - it('Should use the min and max options', function() { - var scaleID = 'myScale'; - + describe('when specifying limits', function() { var mockData = { - labels: ['2015-01-01T20:00:00', '2015-01-02T20:00:00', '2015-01-03T20:00:00'], // days + labels: ['2015-01-01T20:00:00', '2015-01-02T20:00:00', '2015-01-03T20:00:00'], }; - var mockContext = window.createMockContext(); - var config = Chart.helpers.clone(Chart.scaleService.getScaleDefaults('time')); - config.time.min = '2015-01-01T04:00:00'; - config.time.max = '2015-01-05T06:00:00'; - var Constructor = Chart.scaleService.getScaleConstructor('time'); - var scale = new Constructor({ - ctx: mockContext, - options: config, // use default config for scale - chart: { - data: mockData - }, - id: scaleID + var config; + beforeEach(function() { + config = Chart.helpers.clone(Chart.scaleService.getScaleDefaults('time')); }); - scale.update(400, 50); - expect(scale.ticks).toEqual(['Jan 1, 2015', 'Jan 5, 2015']); + it('should use the min option', function() { + config.time.unit = 'day'; + config.time.min = '2014-12-29T04:00:00'; + + var scale = createScale(mockData, config); + expect(scale.ticks[0]).toEqual('Dec 29, 2014'); + }); + + it('should use the max option', function() { + config.time.unit = 'day'; + config.time.max = '2015-01-05T06:00:00'; + + var scale = createScale(mockData, config); + expect(scale.ticks[scale.ticks.length - 1]).toEqual('Jan 5, 2015'); + }); }); it('Should use the isoWeekday option', function() { - var scaleID = 'myScale'; - var mockData = { labels: [ '2015-01-01T20:00:00', // Thursday @@ -360,32 +298,20 @@ describe('Time scale tests', function() { ] }; - var mockContext = window.createMockContext(); var config = Chart.helpers.clone(Chart.scaleService.getScaleDefaults('time')); config.time.unit = 'week'; // Wednesday config.time.isoWeekday = 3; - var Constructor = Chart.scaleService.getScaleConstructor('time'); - var scale = new Constructor({ - ctx: mockContext, - options: config, // use default config for scale - chart: { - data: mockData - }, - id: scaleID - }); - - scale.update(400, 50); + var scale = createScale(mockData, config); expect(scale.ticks).toEqual(['Dec 31, 2014', 'Jan 7, 2015']); }); - it('should get the correct pixel for a value', function() { + describe('when rendering several days', function() { var chart = window.acquireChart({ type: 'line', data: { datasets: [{ xAxisID: 'xScale0', - yAxisID: 'yScale0', data: [] }], 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 @@ -397,29 +323,94 @@ describe('Time scale tests', function() { type: 'time', position: 'bottom' }], - yAxes: [{ - id: 'yScale0', - type: 'linear', - position: 'left' - }] } } }); var xScale = chart.scales.xScale0; - expect(xScale.getPixelForValue('', 0, 0)).toBeCloseToPixel(71); - expect(xScale.getPixelForValue('', 6, 0)).toBeCloseToPixel(452); - expect(xScale.getPixelForValue('2015-01-01T20:00:00')).toBeCloseToPixel(71); + it('should be bounded by the nearest day beginnings', function() { + expect(xScale.getValueForPixel(xScale.left)).toBeCloseToTime({ + value: moment(chart.data.labels[0]).startOf('day'), + unit: 'hour', + }); + expect(xScale.getValueForPixel(xScale.right)).toBeCloseToTime({ + value: moment(chart.data.labels[chart.data.labels.length - 1]).endOf('day'), + unit: 'hour', + }); + }); + + it('should convert between screen coordinates and times', function() { + var timeRange = moment('2015-01-11').valueOf() - moment('2015-01-01').valueOf(); + var msInHour = 3600000; + var firstLabelAlong = 20 * msInHour / timeRange; + var firstLabelPixel = xScale.left + (xScale.width * firstLabelAlong); + var lastLabelAlong = (timeRange - (12 * msInHour)) / timeRange; + var lastLabelPixel = xScale.left + (xScale.width * lastLabelAlong); + + expect(xScale.getPixelForValue('', 0, 0)).toBeCloseToPixel(firstLabelPixel); + expect(xScale.getPixelForValue(chart.data.labels[0])).toBeCloseToPixel(firstLabelPixel); + expect(xScale.getValueForPixel(firstLabelPixel)).toBeCloseToTime({ + value: moment(chart.data.labels[0]), + unit: 'hour', + }); + + expect(xScale.getPixelForValue('', 6, 0)).toBeCloseToPixel(lastLabelPixel); + expect(xScale.getValueForPixel(lastLabelPixel)).toBeCloseToTime({ + value: moment(chart.data.labels[6]), + unit: 'hour' + }); + }); + }); - expect(xScale.getValueForPixel(71)).toBeCloseToTime({ - value: moment(chart.data.labels[0]), - unit: 'hour', - threshold: 0.75 + describe('when rendering several years', function() { + var chart = window.acquireChart({ + type: 'line', + data: { + labels: ['2005-07-04', '2017-01-20'], + }, + options: { + scales: { + xAxes: [{ + id: 'xScale0', + type: 'time', + position: 'bottom' + }], + } + } + }); + + var xScale = chart.scales.xScale0; + xScale.update(800, 200); + + it('should be bounded by nearest year starts', function() { + expect(xScale.getValueForPixel(xScale.left)).toBeCloseToTime({ + value: moment(chart.data.labels[0]).startOf('year'), + unit: 'hour', + }); + expect(xScale.getValueForPixel(xScale.right)).toBeCloseToTime({ + value: moment(chart.data.labels[chart.data.labels - 1]).endOf('year'), + unit: 'hour', + }); }); - expect(xScale.getValueForPixel(452)).toBeCloseToTime({ - value: moment(chart.data.labels[6]), - unit: 'hour' + + it('should build the correct ticks', function() { + // Where 'correct' is a two year spacing, except the last tick which is the year end of the last point. + expect(xScale.ticks).toEqual(['2005', '2007', '2009', '2011', '2013', '2015', '2017', '2018']); + }); + + it('should have ticks with accurate labels', function() { + var ticks = xScale.ticks; + var pixelsPerYear = xScale.width / 13; + + for (var i = 0; i < ticks.length - 1; i++) { + var offset = 2 * pixelsPerYear * i; + expect(xScale.getValueForPixel(xScale.left + offset)).toBeCloseToTime({ + value: moment(ticks[i] + '-01-01'), + unit: 'day', + threshold: 0.5, + }); + } }); }); @@ -429,7 +420,6 @@ describe('Time scale tests', function() { data: { datasets: [{ xAxisID: 'xScale0', - yAxisID: 'yScale0', 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 @@ -441,11 +431,6 @@ describe('Time scale tests', function() { type: 'time', position: 'bottom' }], - yAxes: [{ - id: 'yScale0', - type: 'linear', - position: 'left' - }] } } }); @@ -484,7 +469,6 @@ describe('Time scale tests', function() { expect(xScale.getValueForPixel(62)).toBeCloseToTime({ value: moment(chart.data.labels[0]), unit: 'day', - threshold: 0.75 }); }); });