Files
Chart.js/src/scales/scale.time.js
2020-02-08 18:08:42 -05:00

741 lines
20 KiB
JavaScript

'use strict';
import adapters from '../core/core.adapters';
import defaults from '../core/core.defaults';
import {isFinite, isNullOrUndef, mergeIf, valueOrDefault} from '../helpers/helpers.core';
import {toRadians} from '../helpers/helpers.math';
import Scale from '../core/core.scale';
import {_lookup, _lookupByKey} from '../helpers/helpers.collection';
// Integer constants are from the ES6 spec.
const MAX_INTEGER = Number.MAX_SAFE_INTEGER || 9007199254740991;
const INTERVALS = {
millisecond: {
common: true,
size: 1,
steps: 1000
},
second: {
common: true,
size: 1000,
steps: 60
},
minute: {
common: true,
size: 60000,
steps: 60
},
hour: {
common: true,
size: 3600000,
steps: 24
},
day: {
common: true,
size: 86400000,
steps: 30
},
week: {
common: false,
size: 604800000,
steps: 4
},
month: {
common: true,
size: 2.628e9,
steps: 12
},
quarter: {
common: false,
size: 7.884e9,
steps: 4
},
year: {
common: true,
size: 3.154e10
}
};
const UNITS = Object.keys(INTERVALS);
function sorter(a, b) {
return a - b;
}
function arrayUnique(items) {
const set = new Set();
let i, ilen;
for (i = 0, ilen = items.length; i < ilen; ++i) {
set.add(items[i]);
}
if (set.size === ilen) {
return items;
}
return [...set];
}
function parse(scale, input) {
if (isNullOrUndef(input)) {
return null;
}
const adapter = scale._adapter;
const options = scale.options.time;
const parser = options.parser;
let value = input;
if (typeof parser === 'function') {
value = parser(value);
}
// Only parse if its not a timestamp already
if (!isFinite(value)) {
value = typeof parser === 'string'
? adapter.parse(value, parser)
: adapter.parse(value);
}
if (value === null) {
return value;
}
if (options.round) {
value = scale._adapter.startOf(value, options.round);
}
return +value;
}
function getDataTimestamps(scale) {
const isSeries = scale.options.distribution === 'series';
let timestamps = scale._cache.data || [];
let i, ilen, metas;
if (timestamps.length) {
return timestamps;
}
metas = scale._getMatchingVisibleMetas();
if (isSeries && metas.length) {
return metas[0].controller._getAllParsedValues(scale);
}
for (i = 0, ilen = metas.length; i < ilen; ++i) {
timestamps = timestamps.concat(metas[i].controller._getAllParsedValues(scale));
}
// We can not assume data is in order or unique - not even for single dataset
// It seems to be somewhat faster to do sorting first
return (scale._cache.data = arrayUnique(timestamps.sort(sorter)));
}
function getLabelTimestamps(scale) {
const isSeries = scale.options.distribution === 'series';
const timestamps = scale._cache.labels || [];
let i, ilen, labels;
if (timestamps.length) {
return timestamps;
}
labels = scale._getLabels();
for (i = 0, ilen = labels.length; i < ilen; ++i) {
timestamps.push(parse(scale, labels[i]));
}
// We could assume labels are in order and unique - but let's not
return (scale._cache.labels = isSeries ? timestamps : arrayUnique(timestamps.sort(sorter)));
}
function getAllTimestamps(scale) {
let timestamps = scale._cache.all || [];
let label, data;
if (timestamps.length) {
return timestamps;
}
data = getDataTimestamps(scale);
label = getLabelTimestamps(scale);
if (data.length && label.length) {
// If combining labels and data (data might not contain all labels),
// we need to recheck uniqueness and sort
timestamps = arrayUnique(data.concat(label).sort(sorter));
} else {
timestamps = data.length ? data : label;
}
timestamps = scale._cache.all = timestamps;
return timestamps;
}
/**
* 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}
];
}
const table = [];
const items = [min];
let 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;
}
/**
* 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) {
const {lo, hi} = _lookupByKey(table, skey, sval);
// Note: the lookup table ALWAYS contains at least 2 items (min and max)
const prev = table[lo];
const next = table[hi];
const span = next[skey] - prev[skey];
const ratio = span ? (sval - prev[skey]) / span : 0;
const offset = (next[tkey] - prev[tkey]) * ratio;
return prev[tkey] + offset;
}
/**
* Figures out what unit results in an appropriate number of auto-generated ticks
*/
function determineUnitForAutoTicks(minUnit, min, max, capacity) {
const ilen = UNITS.length;
let i, interval, factor;
for (i = UNITS.indexOf(minUnit); i < ilen - 1; ++i) {
interval = INTERVALS[UNITS[i]];
factor = interval.steps ? interval.steps : 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(scale, numTicks, minUnit, min, max) {
let i, unit;
for (i = UNITS.length - 1; i >= UNITS.indexOf(minUnit); i--) {
unit = UNITS[i];
if (INTERVALS[unit].common && scale._adapter.diff(max, min, unit) >= numTicks - 1) {
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];
}
}
}
function addTick(timestamps, ticks, time) {
if (!timestamps.length) {
return;
}
const {lo, hi} = _lookup(timestamps, time);
const timestamp = timestamps[lo] >= time ? timestamps[lo] : timestamps[hi];
ticks.add(timestamp);
}
/**
* Generates a maximum of `capacity` timestamps between min and max, rounded to the
* `minor` unit 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(scale) {
const adapter = scale._adapter;
const min = scale.min;
const max = scale.max;
const options = scale.options;
const timeOpts = options.time;
const minor = timeOpts.unit || determineUnitForAutoTicks(timeOpts.minUnit, min, max, scale._getLabelCapacity(min));
const stepSize = valueOrDefault(timeOpts.stepSize, 1);
const weekday = minor === 'week' ? timeOpts.isoWeekday : false;
const ticks = new Set();
let first = min;
let time;
// For 'week' unit, handle the first day of week option
if (weekday) {
first = +adapter.startOf(first, 'isoWeek', weekday);
}
// Align first ticks on unit
first = +adapter.startOf(first, weekday ? 'day' : minor);
// Prevent browser from freezing in case user options request millions of milliseconds
if (adapter.diff(max, min, minor) > 100000 * stepSize) {
throw min + ' and ' + max + ' are too far apart with stepSize of ' + stepSize + ' ' + minor;
}
if (scale.options.ticks.source === 'data') {
// need to make sure ticks are in data in this case
const timestamps = getDataTimestamps(scale);
for (time = first; time < max; time = +adapter.add(time, stepSize, minor)) {
addTick(timestamps, ticks, time);
}
if (time === max || options.bounds === 'ticks') {
addTick(timestamps, ticks, time);
}
} else {
for (time = first; time < max; time = +adapter.add(time, stepSize, minor)) {
ticks.add(time);
}
if (time === max || options.bounds === 'ticks') {
ticks.add(time);
}
}
return [...ticks];
}
/**
* Returns the start and end offsets from edges in the form of {start, end}
* where each value is a relative width to the scale and ranges between 0 and 1.
* They add extra margins on the both sides by scaling down the original scale.
* Offsets are added when the `offset` option is true.
*/
function computeOffsets(table, timestamps, min, max, options) {
let start = 0;
let end = 0;
let first, last;
if (options.offset && timestamps.length) {
first = interpolate(table, 'time', timestamps[0], 'pos');
if (timestamps.length === 1) {
start = 1 - first;
} else {
start = (interpolate(table, 'time', timestamps[1], 'pos') - first) / 2;
}
last = interpolate(table, 'time', timestamps[timestamps.length - 1], 'pos');
if (timestamps.length === 1) {
end = last;
} else {
end = (last - interpolate(table, 'time', timestamps[timestamps.length - 2], 'pos')) / 2;
}
}
return {start: start, end: end, factor: 1 / (start + 1 + end)};
}
function setMajorTicks(scale, ticks, map, majorUnit) {
const adapter = scale._adapter;
const first = +adapter.startOf(ticks[0].value, majorUnit);
const last = ticks[ticks.length - 1].value;
let major, index;
for (major = first; major <= last; major = +adapter.add(major, 1, majorUnit)) {
index = map[major];
if (index >= 0) {
ticks[index].major = true;
}
}
return ticks;
}
function ticksFromTimestamps(scale, values, majorUnit) {
const ticks = [];
const map = {};
const ilen = values.length;
let i, value;
for (i = 0; i < ilen; ++i) {
value = values[i];
map[value] = i;
ticks.push({
value: value,
major: false
});
}
// We set the major ticks separately from the above loop because calling startOf for every tick
// is expensive when there is a large number of ticks
return (ilen === 0 || !majorUnit) ? ticks : setMajorTicks(scale, ticks, map, majorUnit);
}
function getTimestampsForTicks(scale) {
if (scale.options.ticks.source === 'labels') {
return getLabelTimestamps(scale);
}
return generate(scale);
}
function getTimestampsForTable(scale) {
return scale.options.distribution === 'series'
? getAllTimestamps(scale)
: [scale.min, scale.max];
}
function getLabelBounds(scale) {
const arr = getLabelTimestamps(scale);
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
if (arr.length) {
min = arr[0];
max = arr[arr.length - 1];
}
return {min, max};
}
/**
* Return subset of `timestamps` between `min` and `max`.
* Timestamps are assumend to be in sorted order.
* @param {number[]} timestamps - array of timestamps
* @param {number} min - min value (timestamp)
* @param {number} max - max value (timestamp)
*/
function filterBetween(timestamps, min, max) {
let start = 0;
let end = timestamps.length - 1;
while (start < end && timestamps[start] < min) {
start++;
}
while (end > start && timestamps[end] > max) {
end--;
}
end++; // slice does not include last element
return start > 0 || end < timestamps.length
? timestamps.slice(start, end)
: timestamps;
}
const defaultConfig = {
/**
* 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',
adapters: {},
time: {
parser: false, // false == a pattern string from https://momentjs.com/docs/#/parsing/string-format/ or a custom callback that converts its argument to a moment
unit: false, // false == automatic or override with week, month, year, etc.
round: false, // none, or override with week, month, year, etc.
isoWeekday: false, // override week start day - see https://momentjs.com/docs/#/get-set/iso-weekday/
minUnit: 'millisecond',
displayFormats: {}
},
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
}
}
};
class TimeScale extends Scale {
constructor(props) {
super(props);
const options = this.options;
const time = options.time || (options.time = {});
const adapter = this._adapter = new adapters._date(options.adapters.date);
this._cache = {};
/** @type {string | undefined} */
this._unit = undefined;
/** @type {string | undefined} */
this._majorUnit = undefined;
/** @type {object | undefined} */
this._offsets = undefined;
/** @type {object[] | undefined} */
this._table = undefined;
// Backward compatibility: before introducing adapter, `displayFormats` was
// supposed to contain *all* unit/string pairs but this can't be resolved
// when loading the scale (adapters are loaded afterward), so let's populate
// missing formats on update
mergeIf(time.displayFormats, adapter.formats());
}
_parse(raw, index) { // eslint-disable-line no-unused-vars
if (raw === undefined) {
return NaN;
}
return parse(this, raw);
}
_parseObject(obj, axis, index) {
if (obj && obj.t) {
return this._parse(obj.t, index);
}
if (obj[axis] !== undefined) {
return this._parse(obj[axis], index);
}
return null;
}
_invalidateCaches() {
this._cache = {};
}
determineDataLimits() {
const me = this;
const options = me.options;
const adapter = me._adapter;
const unit = options.time.unit || 'day';
let {min, max, minDefined, maxDefined} = me._getUserBounds();
function _applyBounds(bounds) {
if (!minDefined && !isNaN(bounds.min)) {
min = Math.min(min, bounds.min);
}
if (!maxDefined && !isNaN(bounds.max)) {
max = Math.max(max, bounds.max);
}
}
// If we have user provided `min` and `max` labels / data bounds can be ignored
if (!minDefined || !maxDefined) {
// Labels are always considered, when user did not force bounds
_applyBounds(getLabelBounds(me));
// If `bounds` is `'ticks'` and `ticks.source` is `'labels'`,
// data bounds are ignored (and don't need to be determined)
if (options.bounds !== 'ticks' || options.ticks.source !== 'labels') {
_applyBounds(me._getMinMax(false));
}
}
min = isFinite(min) && !isNaN(min) ? min : +adapter.startOf(Date.now(), unit);
max = isFinite(max) && !isNaN(max) ? max : +adapter.endOf(Date.now(), unit) + 1;
// 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);
}
buildTicks() {
const me = this;
const options = me.options;
const timeOpts = options.time;
const tickOpts = options.ticks;
const distribution = options.distribution;
const timestamps = getTimestampsForTicks(me);
if (options.bounds === 'ticks' && timestamps.length) {
me.min = me._userMin || timestamps[0];
me.max = me._userMax || timestamps[timestamps.length - 1];
}
const min = me.min;
const max = me.max;
const ticks = filterBetween(timestamps, min, max);
// PRIVATE
// determineUnitForFormatting relies on the number of ticks so we don't use it when
// autoSkip is enabled because we don't yet know what the final number of ticks will be
me._unit = timeOpts.unit || (tickOpts.autoSkip
? determineUnitForAutoTicks(timeOpts.minUnit, me.min, me.max, me._getLabelCapacity(min))
: determineUnitForFormatting(me, ticks.length, timeOpts.minUnit, me.min, me.max));
me._majorUnit = !tickOpts.major.enabled || me._unit === 'year' ? undefined
: determineMajorUnit(me._unit);
me._table = buildLookupTable(getTimestampsForTable(me), min, max, distribution);
me._offsets = computeOffsets(me._table, getDataTimestamps(me), min, max, options);
if (options.reverse) {
ticks.reverse();
}
return ticksFromTimestamps(me, ticks, me._majorUnit);
}
getLabelForValue(value) {
const me = this;
const adapter = me._adapter;
const timeOpts = me.options.time;
if (timeOpts.tooltipFormat) {
return adapter.format(value, timeOpts.tooltipFormat);
}
return adapter.format(value, timeOpts.displayFormats.datetime);
}
/**
* Function to format an individual tick mark
* @private
*/
_tickFormatFunction(time, index, ticks, format) {
const me = this;
const options = me.options;
const formats = options.time.displayFormats;
const majorUnit = me._majorUnit;
const minorFormat = formats[me._unit];
const majorFormat = formats[majorUnit];
const tick = ticks[index];
const major = majorUnit && majorFormat && tick && tick.major;
const label = me._adapter.format(time, format ? format : major ? majorFormat : minorFormat);
const formatter = options.ticks.callback;
return formatter ? formatter(label, index, ticks) : label;
}
generateTickLabels(ticks) {
let i, ilen, tick;
for (i = 0, ilen = ticks.length; i < ilen; ++i) {
tick = ticks[i];
tick.label = this._tickFormatFunction(tick.value, i, ticks);
}
}
/**
* @param {number} value - Milliseconds since epoch (1 January 1970 00:00:00 UTC)
*/
getPixelForValue(value) {
const me = this;
const offsets = me._offsets;
const pos = interpolate(me._table, 'time', value, 'pos');
return me.getPixelForDecimal((offsets.start + pos) * offsets.factor);
}
getPixelForTick(index) {
const ticks = this.ticks;
if (index < 0 || index > ticks.length - 1) {
return null;
}
return this.getPixelForValue(ticks[index].value);
}
getValueForPixel(pixel) {
const me = this;
const offsets = me._offsets;
const pos = me.getDecimalForPixel(pixel) / offsets.factor - offsets.end;
return interpolate(me._table, 'pos', pos, 'time');
}
/**
* @private
*/
_getLabelSize(label) {
const me = this;
const ticksOpts = me.options.ticks;
const tickLabelWidth = me.ctx.measureText(label).width;
const angle = toRadians(me.isHorizontal() ? ticksOpts.maxRotation : ticksOpts.minRotation);
const cosRotation = Math.cos(angle);
const sinRotation = Math.sin(angle);
const tickFontSize = valueOrDefault(ticksOpts.fontSize, defaults.fontSize);
return {
w: (tickLabelWidth * cosRotation) + (tickFontSize * sinRotation),
h: (tickLabelWidth * sinRotation) + (tickFontSize * cosRotation)
};
}
/**
* @private
*/
_getLabelCapacity(exampleTime) {
const me = this;
const timeOpts = me.options.time;
const displayFormats = timeOpts.displayFormats;
// pick the longest format (milliseconds) for guestimation
const format = displayFormats[timeOpts.unit] || displayFormats.millisecond;
const exampleLabel = me._tickFormatFunction(exampleTime, 0, ticksFromTimestamps(me, [exampleTime], me._majorUnit), format);
const size = me._getLabelSize(exampleLabel);
// subtract 1 - if offset then there's one less label than tick
// if not offset then one half label padding is added to each end leaving room for one less label
const capacity = Math.floor(me.isHorizontal() ? me.width / size.w : me.height / size.h) - 1;
return capacity > 0 ? capacity : 1;
}
}
// INTERNAL: static default options, registered in src/index.js
TimeScale._defaults = defaultConfig;
export default TimeScale;