Move and rewrite time helpers (#4549)

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.
This commit is contained in:
Simon Brunel 2017-07-23 17:41:12 +02:00 committed by Evert Timberg
parent e7445a5f00
commit 48d6face28
5 changed files with 228 additions and 280 deletions

View File

@ -139,9 +139,7 @@ module.exports = {
ticks.push(lastTick);
return ticks;
},
time: helpers.time.generateTicks
}
},
/**

View File

@ -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
});
}
};

View File

@ -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');

View File

@ -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,33 +159,156 @@ 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();
}
/**
* 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;
}
function determineUnit(minUnit, min, max, capacity) {
var ilen = UNITS.length;
var i, interval, factor;
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 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',
@ -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);
|| 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
});
timestamps = generate(min, max, unit, majorUnit, stepSize, timeOpts);
// 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;
// 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);
}
});

View File

@ -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() {