chart.js
Version:
Simple HTML5 charts using the canvas element.
757 lines (634 loc) • 21.2 kB
JavaScript
/* global window: false */
'use strict';
var moment = require('moment');
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: {
common: true,
size: 1,
steps: [1, 2, 5, 10, 20, 50, 100, 250, 500]
},
second: {
common: true,
size: 1000,
steps: [1, 2, 5, 10, 30]
},
minute: {
common: true,
size: 60000,
steps: [1, 2, 5, 10, 30]
},
hour: {
common: true,
size: 3600000,
steps: [1, 2, 3, 6, 12]
},
day: {
common: true,
size: 86400000,
steps: [1, 2, 5]
},
week: {
common: false,
size: 604800000,
steps: [1, 2, 3, 4]
},
month: {
common: true,
size: 2.628e9,
steps: [1, 2, 3]
},
quarter: {
common: false,
size: 7.884e9,
steps: [1, 2, 3, 4]
},
year: {
common: true,
size: 3.154e10
}
};
var UNITS = Object.keys(INTERVALS);
function sorter(a, b) {
return a - b;
}
function arrayUnique(items) {
var hash = {};
var out = [];
var i, ilen, item;
for (i = 0, ilen = items.length; i < ilen; ++i) {
item = items[i];
if (!hash[item]) {
hash[item] = true;
out.push(item);
}
}
return out;
}
/**
* Returns an array of {time, pos} objects used to interpolate a specific `time` or position
* (`pos`) on the scale, by searching entries before and after the requested value. `pos` is
* a decimal between 0 and 1: 0 being the start of the scale (left or top) and 1 the other
* extremity (left + width or top + height). Note that it would be more optimized to directly
* store pre-computed pixels, but the scale dimensions are not guaranteed at the time we need
* to create the lookup table. The table ALWAYS contains at least two items: min and max.
*
* @param {Number[]} timestamps - timestamps sorted from lowest to highest.
* @param {String} distribution - If 'linear', timestamps will be spread linearly along the min
* and max range, so basically, the table will contains only two items: {min, 0} and {max, 1}.
* If 'series', timestamps will be positioned at the same distance from each other. In this
* case, only timestamps that break the time linearity are registered, meaning that in the
* best case, all timestamps are linear, the table contains only min and max.
*/
function buildLookupTable(timestamps, min, max, distribution) {
if (distribution === 'linear' || !timestamps.length) {
return [
{time: min, pos: 0},
{time: max, pos: 1}
];
}
var table = [];
var items = [min];
var i, ilen, prev, curr, next;
for (i = 0, ilen = timestamps.length; i < ilen; ++i) {
curr = timestamps[i];
if (curr > min && curr < max) {
items.push(curr);
}
}
items.push(max);
for (i = 0, ilen = items.length; i < ilen; ++i) {
next = items[i + 1];
prev = items[i - 1];
curr = items[i];
// only add points that breaks the scale linearity
if (prev === undefined || next === undefined || Math.round((next + prev) / 2) !== curr) {
table.push({time: curr, pos: i / (ilen - 1)});
}
}
return table;
}
// @see adapted from http://www.anujgakhar.com/2014/03/01/binary-search-in-javascript/
function lookup(table, key, value) {
var lo = 0;
var hi = table.length - 1;
var mid, i0, i1;
while (lo >= 0 && lo <= hi) {
mid = (lo + hi) >> 1;
i0 = table[mid - 1] || null;
i1 = table[mid];
if (!i0) {
// given value is outside table (before first item)
return {lo: null, hi: i1};
} else if (i1[key] < value) {
lo = mid + 1;
} else if (i0[key] > value) {
hi = mid - 1;
} else {
return {lo: i0, hi: i1};
}
}
// given value is outside table (after last item)
return {lo: i1, hi: null};
}
/**
* Linearly interpolates the given source `value` using the table items `skey` values and
* returns the associated `tkey` value. For example, interpolate(table, 'time', 42, 'pos')
* returns the position for a timestamp equal to 42. If value is out of bounds, values at
* index [0, 1] or [n - 1, n] are used for the interpolation.
*/
function interpolate(table, skey, sval, tkey) {
var range = lookup(table, skey, sval);
// Note: the lookup table ALWAYS contains at least 2 items (min and max)
var prev = !range.lo ? table[0] : !range.hi ? table[table.length - 2] : range.lo;
var next = !range.lo ? table[1] : !range.hi ? table[table.length - 1] : range.hi;
var span = next[skey] - prev[skey];
var ratio = span ? (sval - prev[skey]) / span : 0;
var offset = (next[tkey] - prev[tkey]) * ratio;
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 options = scale.options.time;
var value = momentify(scale.getRightValue(input), options);
if (!value.isValid()) {
return null;
}
if (options.round) {
value.startOf(options.round);
}
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;
}
/**
* Figures out what unit results in an appropriate number of auto-generated ticks
*/
function determineUnitForAutoTicks(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 (interval.common && Math.ceil((max - min) / (factor * interval.size)) <= capacity) {
return UNITS[i];
}
}
return UNITS[ilen - 1];
}
/**
* Figures out what unit to format a set of ticks with
*/
function determineUnitForFormatting(ticks, minUnit, min, max) {
var duration = moment.duration(moment(max).diff(moment(min)));
var ilen = UNITS.length;
var i, unit;
for (i = ilen - 1; i >= UNITS.indexOf(minUnit); i--) {
unit = UNITS[i];
if (INTERVALS[unit].common && duration.as(unit) >= ticks.length) {
return unit;
}
}
return UNITS[minUnit ? UNITS.indexOf(minUnit) : 0];
}
function determineMajorUnit(unit) {
for (var i = UNITS.indexOf(unit) + 1, ilen = UNITS.length; i < ilen; ++i) {
if (INTERVALS[UNITS[i]].common) {
return UNITS[i];
}
}
}
/**
* Generates a maximum of `capacity` timestamps between min and max, rounded to the
* `minor` unit, aligned on the `major` unit 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, capacity, options) {
var timeOpts = options.time;
var minor = timeOpts.unit || determineUnitForAutoTicks(timeOpts.minUnit, min, max, capacity);
var major = determineMajorUnit(minor);
var stepSize = helpers.valueOrDefault(timeOpts.stepSize, timeOpts.unitStepSize);
var weekday = minor === 'week' ? timeOpts.isoWeekday : false;
var majorTicksEnabled = options.ticks.major.enabled;
var interval = INTERVALS[minor];
var first = moment(min);
var last = moment(max);
var ticks = [];
var time;
if (!stepSize) {
stepSize = determineStepSize(min, max, minor, capacity);
}
// 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 (majorTicksEnabled && major && !weekday && !timeOpts.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;
}
/**
* Returns the right and left offsets from edges in the form of {left, right}.
* Offsets are added when the `offset` option is true.
*/
function computeOffsets(table, ticks, min, max, options) {
var left = 0;
var right = 0;
var upper, lower;
if (options.offset && ticks.length) {
if (!options.time.min) {
upper = ticks.length > 1 ? ticks[1] : max;
lower = ticks[0];
left = (
interpolate(table, 'time', upper, 'pos') -
interpolate(table, 'time', lower, 'pos')
) / 2;
}
if (!options.time.max) {
upper = ticks[ticks.length - 1];
lower = ticks.length > 1 ? ticks[ticks.length - 2] : min;
right = (
interpolate(table, 'time', upper, 'pos') -
interpolate(table, 'time', lower, 'pos')
) / 2;
}
}
return {left: left, right: right};
}
function ticksFromTimestamps(values, majorUnit) {
var ticks = [];
var i, ilen, value, major;
for (i = 0, ilen = values.length; i < ilen; ++i) {
value = values[i];
major = majorUnit ? value === +moment(value).startOf(majorUnit) : false;
ticks.push({
value: value,
major: major
});
}
return ticks;
}
module.exports = function(Chart) {
var defaultConfig = {
position: 'bottom',
/**
* Data distribution along the scale:
* - 'linear': data are spread according to their time (distances can vary),
* - 'series': data are spread at the same distance from each other.
* @see https://github.com/chartjs/Chart.js/pull/4507
* @since 2.7.0
*/
distribution: 'linear',
/**
* Scale boundary strategy (bypassed by min/max time options)
* - `data`: make sure data are fully visible, ticks outside are removed
* - `ticks`: make sure ticks are fully visible, data outside are truncated
* @see https://github.com/chartjs/Chart.js/pull/4556
* @since 2.7.0
*/
bounds: 'data',
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
isoWeekday: false, // override week start day - see http://momentjs.com/docs/#/get-set/iso-weekday/
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 a', // 11:20 AM
hour: 'hA', // 5PM
day: 'MMM D', // Sep 4
week: 'll', // Week 46, or maybe "[W]WW - YYYY" ?
month: 'MMM YYYY', // Sept 2015
quarter: '[Q]Q - YYYY', // Q3
year: 'YYYY' // 2015
},
},
ticks: {
autoSkip: false,
/**
* Ticks generation input values:
* - 'auto': generates "optimal" ticks based on scale size and time options.
* - 'data': generates ticks from data (including labels from data {t|x|y} objects).
* - 'labels': generates ticks from user given `data.labels` values ONLY.
* @see https://github.com/chartjs/Chart.js/pull/4507
* @since 2.7.0
*/
source: 'auto',
major: {
enabled: 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');
}
this.mergeTicksOptions();
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
*/
getRightValue: function(rawValue) {
if (rawValue && rawValue.t !== undefined) {
rawValue = rawValue.t;
}
return Chart.Scale.prototype.getRightValue.call(this, rawValue);
},
determineDataLimits: function() {
var me = this;
var chart = me.chart;
var timeOpts = me.options.time;
var min = MAX_INTEGER;
var max = MIN_INTEGER;
var timestamps = [];
var datasets = [];
var labels = [];
var i, j, ilen, jlen, data, timestamp;
// Convert labels to timestamps
for (i = 0, ilen = chart.data.labels.length; i < ilen; ++i) {
labels.push(parse(chart.data.labels[i], me));
}
// Convert data to timestamps
for (i = 0, ilen = (chart.data.datasets || []).length; i < ilen; ++i) {
if (chart.isDatasetVisible(i)) {
data = chart.data.datasets[i].data;
// Let's consider that all data have the same format.
if (helpers.isObject(data[0])) {
datasets[i] = [];
for (j = 0, jlen = data.length; j < jlen; ++j) {
timestamp = parse(data[j], me);
timestamps.push(timestamp);
datasets[i][j] = timestamp;
}
} else {
timestamps.push.apply(timestamps, labels);
datasets[i] = labels.slice(0);
}
} else {
datasets[i] = [];
}
}
if (labels.length) {
// Sort labels **after** data have been converted
labels = arrayUnique(labels).sort(sorter);
min = Math.min(min, labels[0]);
max = Math.max(max, labels[labels.length - 1]);
}
if (timestamps.length) {
timestamps = arrayUnique(timestamps).sort(sorter);
min = Math.min(min, timestamps[0]);
max = Math.max(max, timestamps[timestamps.length - 1]);
}
min = parse(timeOpts.min, me) || min;
max = parse(timeOpts.max, me) || max;
// In case there is no valid min/max, let's use today limits
min = min === MAX_INTEGER ? +moment().startOf('day') : min;
max = max === MIN_INTEGER ? +moment().endOf('day') + 1 : max;
// Make sure that max is strictly higher than min (required by the lookup table)
me.min = Math.min(min, max);
me.max = Math.max(min + 1, max);
// PRIVATE
me._horizontal = me.isHorizontal();
me._table = [];
me._timestamps = {
data: timestamps,
datasets: datasets,
labels: labels
};
},
buildTicks: function() {
var me = this;
var min = me.min;
var max = me.max;
var options = me.options;
var timeOpts = options.time;
var timestamps = [];
var ticks = [];
var i, ilen, timestamp;
switch (options.ticks.source) {
case 'data':
timestamps = me._timestamps.data;
break;
case 'labels':
timestamps = me._timestamps.labels;
break;
case 'auto':
default:
timestamps = generate(min, max, me.getLabelCapacity(min), options);
}
if (options.bounds === 'ticks' && timestamps.length) {
min = timestamps[0];
max = timestamps[timestamps.length - 1];
}
// Enforce limits with user min/max options
min = parse(timeOpts.min, me) || min;
max = parse(timeOpts.max, me) || max;
// 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.min = min;
me.max = max;
// PRIVATE
me._unit = timeOpts.unit || determineUnitForFormatting(ticks, timeOpts.minUnit, me.min, me.max);
me._majorUnit = determineMajorUnit(me._unit);
me._table = buildLookupTable(me._timestamps.data, min, max, options.distribution);
me._offsets = computeOffsets(me._table, ticks, min, max, options);
return ticksFromTimestamps(ticks, me._majorUnit);
},
getLabelForIndex: function(index, datasetIndex) {
var me = this;
var data = me.chart.data;
var timeOpts = me.options.time;
var label = data.labels && index < data.labels.length ? data.labels[index] : '';
var value = data.datasets[datasetIndex].data[index];
if (helpers.isObject(value)) {
label = me.getRightValue(value);
}
if (timeOpts.tooltipFormat) {
label = momentify(label, timeOpts).format(timeOpts.tooltipFormat);
}
return label;
},
/**
* Function to format an individual tick mark
* @private
*/
tickFormatFunction: function(tick, index, ticks, formatOverride) {
var me = this;
var options = me.options;
var time = tick.valueOf();
var formats = options.time.displayFormats;
var minorFormat = formats[me._unit];
var majorUnit = me._majorUnit;
var majorFormat = formats[majorUnit];
var majorTime = tick.clone().startOf(majorUnit).valueOf();
var majorTickOpts = options.ticks.major;
var major = majorTickOpts.enabled && majorUnit && majorFormat && time === majorTime;
var label = tick.format(formatOverride ? formatOverride : major ? majorFormat : minorFormat);
var tickOpts = major ? majorTickOpts : options.ticks.minor;
var formatter = helpers.valueOrDefault(tickOpts.callback, tickOpts.userCallback);
return formatter ? formatter(label, index, ticks) : label;
},
convertTicksToLabels: function(ticks) {
var labels = [];
var i, ilen;
for (i = 0, ilen = ticks.length; i < ilen; ++i) {
labels.push(this.tickFormatFunction(moment(ticks[i].value), i, ticks));
}
return labels;
},
/**
* @private
*/
getPixelForOffset: function(time) {
var me = this;
var size = me._horizontal ? me.width : me.height;
var start = me._horizontal ? me.left : me.top;
var pos = interpolate(me._table, 'time', time, 'pos');
return start + size * (me._offsets.left + pos) / (me._offsets.left + 1 + me._offsets.right);
},
getPixelForValue: function(value, index, datasetIndex) {
var me = this;
var time = null;
if (index !== undefined && datasetIndex !== undefined) {
time = me._timestamps.datasets[datasetIndex][index];
}
if (time === null) {
time = parse(value, me);
}
if (time !== null) {
return me.getPixelForOffset(time);
}
},
getPixelForTick: function(index) {
var ticks = this.getTicks();
return index >= 0 && index < ticks.length ?
this.getPixelForOffset(ticks[index].value) :
null;
},
getValueForPixel: function(pixel) {
var me = this;
var size = me._horizontal ? me.width : me.height;
var start = me._horizontal ? me.left : me.top;
var pos = (size ? (pixel - start) / size : 0) * (me._offsets.left + 1 + me._offsets.left) - me._offsets.right;
var time = interpolate(me._table, 'pos', pos, 'time');
return moment(time);
},
/**
* Crude approximation of what the label width might be
* @private
*/
getLabelWidth: function(label) {
var me = this;
var ticksOpts = me.options.ticks;
var tickLabelWidth = me.ctx.measureText(label).width;
var angle = helpers.toRadians(ticksOpts.maxRotation);
var cosRotation = Math.cos(angle);
var sinRotation = Math.sin(angle);
var tickFontSize = helpers.valueOrDefault(ticksOpts.fontSize, defaults.global.defaultFontSize);
return (tickLabelWidth * cosRotation) + (tickFontSize * sinRotation);
},
/**
* @private
*/
getLabelCapacity: function(exampleTime) {
var me = this;
var formatOverride = me.options.time.displayFormats.millisecond; // Pick the longest format for guestimation
var exampleLabel = me.tickFormatFunction(moment(exampleTime), 0, [], formatOverride);
var tickLabelWidth = me.getLabelWidth(exampleLabel);
var innerWidth = me.isHorizontal() ? me.width : me.height;
return Math.floor(innerWidth / tickLabelWidth);
}
});
Chart.scaleService.registerScaleType('time', TimeScale, defaultConfig);
};