From f0f2fda02d924721b079abaa70b65b1b1ebd6dfd Mon Sep 17 00:00:00 2001 From: Simon Brunel Date: Tue, 18 Jul 2017 18:33:47 +0200 Subject: [PATCH] Move and rewrite time helpers Move time helpers back into time scale, remove the `Chart.helpers.time namespace` and attempt to make the auto generation logic a bit simpler. The generate method doesn't anymore enforce min/max, the calling code needs to clamp timestamps if needed. --- src/core/core.ticks.js | 4 +- src/helpers/helpers.time.js | 232 ---------------------------- src/helpers/index.js | 1 - src/scales/scale.time.js | 271 +++++++++++++++++++++++++++------ test/specs/scale.time.tests.js | 2 +- 5 files changed, 229 insertions(+), 281 deletions(-) delete mode 100644 src/helpers/helpers.time.js diff --git a/src/core/core.ticks.js b/src/core/core.ticks.js index 11f44142c47..eb7be8714c5 100644 --- a/src/core/core.ticks.js +++ b/src/core/core.ticks.js @@ -139,9 +139,7 @@ module.exports = { ticks.push(lastTick); return ticks; - }, - - time: helpers.time.generateTicks + } }, /** diff --git a/src/helpers/helpers.time.js b/src/helpers/helpers.time.js deleted file mode 100644 index 5bbcf3eaed0..00000000000 --- a/src/helpers/helpers.time.js +++ /dev/null @@ -1,232 +0,0 @@ -'use strict'; - -var moment = require('moment'); -moment = typeof moment === 'function' ? moment : window.moment; - -var helpers = require('./helpers.core'); - -var interval = { - millisecond: { - size: 1, - steps: [1, 2, 5, 10, 20, 50, 100, 250, 500] - }, - second: { - size: 1000, - steps: [1, 2, 5, 10, 30] - }, - minute: { - size: 60000, - steps: [1, 2, 5, 10, 30] - }, - hour: { - size: 3600000, - steps: [1, 2, 3, 6, 12] - }, - day: { - size: 86400000, - steps: [1, 2, 5] - }, - week: { - size: 604800000, - maxStep: 4 - }, - month: { - size: 2.628e9, - maxStep: 3 - }, - quarter: { - size: 7.884e9, - maxStep: 4 - }, - year: { - size: 3.154e10, - maxStep: false - } -}; - -/** - * 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 generateTicksNiceRange(options, dataRange, niceRange) { - var ticks = []; - if (options.maxTicks) { - var stepSize = options.stepSize; - 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; - var stepValue = interval[options.unit].size * stepSize; - var startFraction = startRange % stepValue; - var alignedTick = startTick; - - // first tick - if (startFraction && majorUnit && !options.timeOpts.round && !options.timeOpts.isoWeekday && helpers.isNullOrUndef(options.min)) { - alignedTick += startFraction - stepValue; - ticks.push(alignedTick); - } else { - ticks.push(startTick); - } - - // generate remaining ticks - var cur = moment(alignedTick); - var realMax = helpers.isNullOrUndef(options.max) ? niceRange.max : options.max; - while (cur.add(stepSize, options.unit).valueOf() < realMax) { - ticks.push(cur.valueOf()); - } - - // last tick - if (helpers.isNullOrUndef(options.max)) { - ticks.push(cur.valueOf()); - } else { - ticks.push(realMax); - } - } - return ticks; -} - -/** - * @namespace Chart.helpers.time; - */ -module.exports = { - /** - * 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 - */ - parseTime: function(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 - */ - determineUnit: function(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; - } - } - - return unit; - }, - - /** - * Determine major unit accordingly to passed unit - * @param unit {String} relative unit - * @return {String} major unit - */ - determineMajorUnit: function(unit) { - var units = Object.keys(interval); - var unitIndex = units.indexOf(unit); - while (unitIndex < units.length) { - var majorUnit = units[++unitIndex]; - // exclude 'week' and 'quarter' units - if (majorUnit !== 'week' && majorUnit !== 'quarter') { - return majorUnit; - } - } - - return null; - }, - - /** - * 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 - */ - determineStepSize: function(min, max, unit, maxTicks) { - // Using our unit, figure 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; - var range = max - min; - - 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(range / (unitSizeInMilliSeconds * multiplier)); - } - } else { - while (sizeInUnits > maxTicks && maxTicks > 0) { - ++multiplier; - sizeInUnits = Math.ceil(range / (unitSizeInMilliSeconds * multiplier)); - } - } - - return multiplier; - }, - - /** - * @function generateTicks - * @param options {ITimeGeneratorOptions} the options for generation - * @param dataRange {IRange} the data range - * @return {Number[]} ticks - */ - generateTicks: function(options, dataRange) { - var niceMin; - var niceMax; - var isoWeekday = options.timeOpts.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 generateTicksNiceRange(options, dataRange, { - min: niceMin, - max: niceMax - }); - } -}; diff --git a/src/helpers/index.js b/src/helpers/index.js index 632b772076a..60c199cad55 100644 --- a/src/helpers/index.js +++ b/src/helpers/index.js @@ -4,4 +4,3 @@ module.exports = require('./helpers.core'); module.exports.easing = require('./helpers.easing'); module.exports.canvas = require('./helpers.canvas'); module.exports.options = require('./helpers.options'); -module.exports.time = require('./helpers.time'); diff --git a/src/scales/scale.time.js b/src/scales/scale.time.js index 2ffb6f9d2fb..94bd7a82c7f 100644 --- a/src/scales/scale.time.js +++ b/src/scales/scale.time.js @@ -7,6 +7,59 @@ moment = typeof moment === 'function' ? moment : window.moment; var defaults = require('../core/core.defaults'); var helpers = require('../helpers/index'); +// Integer constants are from the ES6 spec. +var MIN_INTEGER = Number.MIN_SAFE_INTEGER || -9007199254740991; +var MAX_INTEGER = Number.MAX_SAFE_INTEGER || 9007199254740991; + +var INTERVALS = { + millisecond: { + major: true, + size: 1, + steps: [1, 2, 5, 10, 20, 50, 100, 250, 500] + }, + second: { + major: true, + size: 1000, + steps: [1, 2, 5, 10, 30] + }, + minute: { + major: true, + size: 60000, + steps: [1, 2, 5, 10, 30] + }, + hour: { + major: true, + size: 3600000, + steps: [1, 2, 3, 6, 12] + }, + day: { + major: true, + size: 86400000, + steps: [1, 2, 5] + }, + week: { + major: false, + size: 604800000, + steps: [1, 2, 3, 4] + }, + month: { + major: true, + size: 2.628e9, + steps: [1, 2, 3] + }, + quarter: { + major: false, + size: 7.884e9, + steps: [1, 2, 3, 4] + }, + year: { + major: true, + size: 3.154e10 + } +}; + +var UNITS = Object.keys(INTERVALS); + function sorter(a, b) { return a - b; } @@ -106,32 +159,155 @@ function interpolate(table, skey, sval, tkey) { return prev[tkey] + offset; } +/** + * Convert the given value to a moment object using the given time options. + * @see http://momentjs.com/docs/#/parsing/ + */ +function momentify(value, options) { + var parser = options.parser; + var format = options.parser || options.format; + + if (typeof parser === 'function') { + return parser(value); + } + + if (typeof value === 'string' && typeof format === 'string') { + return moment(value, format); + } + + if (!(value instanceof moment)) { + value = moment(value); + } + + if (value.isValid()) { + return value; + } + + // Labels are in an incompatible moment format and no `parser` has been provided. + // The user might still use the deprecated `format` option to convert his inputs. + if (typeof format === 'function') { + return format(value); + } + + return value; +} + function parse(input, scale) { if (helpers.isNullOrUndef(input)) { return null; } - var round = scale.options.time.round; - var value = scale.getRightValue(input); - var time = value.isValid ? value : helpers.time.parseTime(scale, value); - if (!time || !time.isValid()) { + var options = scale.options.time; + var value = momentify(scale.getRightValue(input), options); + if (!value.isValid()) { return null; } - if (round) { - time.startOf(round); + if (options.round) { + value.startOf(options.round); } - return time.valueOf(); + return value.valueOf(); } -module.exports = function(Chart) { +/** + * Returns the number of unit to skip to be able to display up to `capacity` number of ticks + * in `unit` for the given `min` / `max` range and respecting the interval steps constraints. + */ +function determineStepSize(min, max, unit, capacity) { + var range = max - min; + var interval = INTERVALS[unit]; + var milliseconds = interval.size; + var steps = interval.steps; + var i, ilen, factor; + + if (!steps) { + return Math.ceil(range / ((capacity || 1) * milliseconds)); + } + + for (i = 0, ilen = steps.length; i < ilen; ++i) { + factor = steps[i]; + if (Math.ceil(range / (milliseconds * factor)) <= capacity) { + break; + } + } + + return factor; +} - var timeHelpers = helpers.time; +function determineUnit(minUnit, min, max, capacity) { + var ilen = UNITS.length; + var i, interval, factor; - // Integer constants are from the ES6 spec. - var MIN_INTEGER = Number.MIN_SAFE_INTEGER || -9007199254740991; - var MAX_INTEGER = Number.MAX_SAFE_INTEGER || 9007199254740991; + for (i = UNITS.indexOf(minUnit); i < ilen - 1; ++i) { + interval = INTERVALS[UNITS[i]]; + factor = interval.steps ? interval.steps[interval.steps.length - 1] : MAX_INTEGER; + + if (Math.ceil((max - min) / (factor * interval.size)) <= capacity) { + return UNITS[i]; + } + } + + return UNITS[ilen - 1]; +} + +function determineMajorUnit(unit) { + for (var i = UNITS.indexOf(unit) + 1, ilen = UNITS.length; i < ilen; ++i) { + if (INTERVALS[UNITS[i]].major) { + return UNITS[i]; + } + } +} + +/** + * Generates timestamps between min and max, rounded to the `minor` unit, aligned on + * the `major` unit, spaced with `stepSize` and using the given scale time `options`. + * Important: this method can return ticks outside the min and max range, it's the + * responsibility of the calling code to clamp values if needed. + */ +function generate(min, max, minor, major, stepSize, options) { + var weekday = minor === 'week' ? options.isoWeekday : false; + var interval = INTERVALS[minor]; + var first = moment(min); + var last = moment(max); + var ticks = []; + var time; + + // For 'week' unit, handle the first day of week option + if (weekday) { + first = first.isoWeekday(weekday); + last = last.isoWeekday(weekday); + } + + // Align first/last ticks on unit + first = first.startOf(weekday ? 'day' : minor); + last = last.startOf(weekday ? 'day' : minor); + + // Make sure that the last tick include max + if (last < max) { + last.add(1, minor); + } + + time = moment(first); + + if (major && !weekday && !options.round) { + // Align the first tick on the previous `minor` unit aligned on the `major` unit: + // we first aligned time on the previous `major` unit then add the number of full + // stepSize there is between first and the previous major time. + time.startOf(major); + time.add(~~((first - time) / (interval.size * stepSize)) * stepSize, minor); + } + + for (; time < last; time.add(stepSize, minor)) { + ticks.push(+time); + } + + ticks.push(+time); + + return ticks; +} + +module.exports = function(Chart) { var defaultConfig = { position: 'bottom', @@ -165,6 +341,10 @@ module.exports = function(Chart) { } }; + Chart.Ticks.generators.time = function(opts, range) { + return generate(range.min, range.max, opts.unit, opts.majorUnit, opts.stepSize, opts.timeOpts); + }; + var TimeScale = Chart.Scale.extend({ initialize: function() { if (!moment) { @@ -176,6 +356,18 @@ module.exports = function(Chart) { Chart.Scale.prototype.initialize.call(this); }, + update: function() { + var me = this; + var options = me.options; + + // DEPRECATIONS: output a message only one time per update + if (options.time && options.time.format) { + console.warn('options.time.format is deprecated and replaced by options.time.parser.'); + } + + return Chart.Scale.prototype.update.apply(me, arguments); + }, + /** * Allows data to be referenced via 't' attribute */ @@ -242,8 +434,6 @@ module.exports = function(Chart) { 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: [] }; }, @@ -257,38 +447,31 @@ module.exports = function(Chart) { 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 unit = timeOpts.unit || determineUnit(timeOpts.minUnit, min, max, capacity); + var majorUnit = determineMajorUnit(unit); + var timestamps = []; 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); - } - } - } else { + if (ticksOpts.source === 'auto') { stepSize = helpers.valueOrDefault(timeOpts.stepSize, timeOpts.unitStepSize) - || timeHelpers.determineStepSize(min, max, unit, capacity); - - ticks = timeHelpers.generateTicks({ - maxTicks: capacity, - min: parse(timeOpts.min, me), - max: parse(timeOpts.max, me), - stepSize: stepSize, - majorUnit: majorUnit, - unit: unit, - timeOpts: timeOpts - }, { - min: min, - max: max - }); - - // Recompute min/max, the ticks generation might have changed them (BUG?) - min = ticks.length ? ticks[0] : min; - max = ticks.length ? ticks[ticks.length - 1] : max; + || determineStepSize(min, max, unit, capacity); + + timestamps = generate(min, max, unit, majorUnit, stepSize, timeOpts); + + // Expand min/max to the generated ticks + min = helpers.isNullOrUndef(timeOpts.min) && timestamps.length ? timestamps[0] : min; + max = helpers.isNullOrUndef(timeOpts.max) && timestamps.length ? timestamps[timestamps.length - 1] : max; + } else { + timestamps = model.labels; + } + + // Remove ticks outside the min/max range + for (i = 0, ilen = timestamps.length; i < ilen; ++i) { + timestamp = timestamps[i]; + if (timestamp >= min && timestamp <= max) { + ticks.push(timestamp); + } } me.ticks = ticks; @@ -313,7 +496,7 @@ module.exports = function(Chart) { label = me.getRightValue(value); } if (timeOpts.tooltipFormat) { - label = timeHelpers.parseTime(me, label).format(timeOpts.tooltipFormat); + label = momentify(label, timeOpts).format(timeOpts.tooltipFormat); } return label; @@ -430,7 +613,7 @@ module.exports = function(Chart) { var tickLabelWidth = me.getLabelWidth(exampleLabel); var innerWidth = me.isHorizontal() ? me.width : me.height; - return innerWidth / tickLabelWidth; + return Math.floor(innerWidth / tickLabelWidth); } }); diff --git a/test/specs/scale.time.tests.js b/test/specs/scale.time.tests.js index 590921101c6..87621d6b3ee 100755 --- a/test/specs/scale.time.tests.js +++ b/test/specs/scale.time.tests.js @@ -372,7 +372,7 @@ describe('Time scale tests', function() { config.time.min = '2014-12-29T04:00:00'; var scale = createScale(mockData, config); - expect(scale.ticks[0].value).toEqual('Dec 29'); + expect(scale.ticks[0].value).toEqual('Dec 31'); }); it('should use the max option', function() {