/*
|
* Licensed to the Apache Software Foundation (ASF) under one
|
* or more contributor license agreements. See the NOTICE file
|
* distributed with this work for additional information
|
* regarding copyright ownership. The ASF licenses this file
|
* to you under the Apache License, Version 2.0 (the
|
* "License"); you may not use this file except in compliance
|
* with the License. You may obtain a copy of the License at
|
*
|
* http://www.apache.org/licenses/LICENSE-2.0
|
*
|
* Unless required by applicable law or agreed to in writing,
|
* software distributed under the License is distributed on an
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
* KIND, either express or implied. See the License for the
|
* specific language governing permissions and limitations
|
* under the License.
|
*/
|
|
/*
|
* A third-party license is embeded for some of the code in this file:
|
* The "scaleLevels" was originally copied from "d3.js" with some
|
* modifications made for this project.
|
* (See more details in the comment on the definition of "scaleLevels" below.)
|
* The use of the source code of this file is also subject to the terms
|
* and consitions of the license of "d3.js" (BSD-3Clause, see
|
* </licenses/LICENSE-d3>).
|
*/
|
|
|
// [About UTC and local time zone]:
|
// In most cases, `number.parseDate` will treat input data string as local time
|
// (except time zone is specified in time string). And `format.formateTime` returns
|
// local time by default. option.useUTC is false by default. This design have
|
// concidered these common case:
|
// (1) Time that is persistent in server is in UTC, but it is needed to be diplayed
|
// in local time by default.
|
// (2) By default, the input data string (e.g., '2011-01-02') should be displayed
|
// as its original time, without any time difference.
|
|
import * as numberUtil from '../util/number';
|
import {
|
ONE_SECOND,
|
ONE_MINUTE,
|
ONE_HOUR,
|
ONE_DAY,
|
ONE_YEAR,
|
format,
|
leveledFormat,
|
PrimaryTimeUnit,
|
TimeUnit,
|
getUnitValue,
|
timeUnits,
|
fullLeveledFormatter,
|
getPrimaryTimeUnit,
|
isPrimaryTimeUnit,
|
getDefaultFormatPrecisionOfInterval,
|
fullYearGetterName,
|
monthSetterName,
|
fullYearSetterName,
|
dateSetterName,
|
hoursGetterName,
|
hoursSetterName,
|
minutesSetterName,
|
secondsSetterName,
|
millisecondsSetterName,
|
monthGetterName,
|
dateGetterName,
|
minutesGetterName,
|
secondsGetterName,
|
millisecondsGetterName
|
} from '../util/time';
|
import * as scaleHelper from './helper';
|
import IntervalScale from './Interval';
|
import Scale from './Scale';
|
import {TimeScaleTick, ScaleTick} from '../util/types';
|
import {TimeAxisLabelFormatterOption} from '../coord/axisCommonTypes';
|
import { warn } from '../util/log';
|
import { LocaleOption } from '../core/locale';
|
import Model from '../model/Model';
|
import { filter, map } from 'zrender/src/core/util';
|
|
// FIXME 公用?
|
const bisect = function (
|
a: [string | number, number][],
|
x: number,
|
lo: number,
|
hi: number
|
): number {
|
while (lo < hi) {
|
const mid = lo + hi >>> 1;
|
if (a[mid][1] < x) {
|
lo = mid + 1;
|
}
|
else {
|
hi = mid;
|
}
|
}
|
return lo;
|
};
|
|
type TimeScaleSetting = {
|
locale: Model<LocaleOption>;
|
useUTC: boolean;
|
};
|
|
class TimeScale extends IntervalScale<TimeScaleSetting> {
|
|
static type = 'time';
|
readonly type = 'time';
|
|
_approxInterval: number;
|
|
_minLevelUnit: TimeUnit;
|
|
constructor(settings?: TimeScaleSetting) {
|
super(settings);
|
}
|
|
/**
|
* Get label is mainly for other components like dataZoom, tooltip.
|
*/
|
getLabel(tick: TimeScaleTick): string {
|
const useUTC = this.getSetting('useUTC');
|
return format(
|
tick.value,
|
fullLeveledFormatter[
|
getDefaultFormatPrecisionOfInterval(getPrimaryTimeUnit(this._minLevelUnit))
|
] || fullLeveledFormatter.second,
|
useUTC,
|
this.getSetting('locale')
|
);
|
}
|
|
getFormattedLabel(
|
tick: TimeScaleTick,
|
idx: number,
|
labelFormatter: TimeAxisLabelFormatterOption
|
): string {
|
const isUTC = this.getSetting('useUTC');
|
const lang = this.getSetting('locale');
|
return leveledFormat(tick, idx, labelFormatter, lang, isUTC);
|
}
|
|
/**
|
* @override
|
* @param expandToNicedExtent Whether expand the ticks to niced extent.
|
*/
|
getTicks(expandToNicedExtent?: boolean): TimeScaleTick[] {
|
const interval = this._interval;
|
const extent = this._extent;
|
|
let ticks = [] as TimeScaleTick[];
|
// If interval is 0, return [];
|
if (!interval) {
|
return ticks;
|
}
|
|
ticks.push({
|
value: extent[0],
|
level: 0
|
});
|
|
const useUTC = this.getSetting('useUTC');
|
|
const innerTicks = getIntervalTicks(
|
this._minLevelUnit,
|
this._approxInterval,
|
useUTC,
|
extent
|
);
|
|
ticks = ticks.concat(innerTicks);
|
|
ticks.push({
|
value: extent[1],
|
level: 0
|
});
|
|
return ticks;
|
}
|
|
niceExtent(
|
opt?: {
|
splitNumber?: number,
|
fixMin?: boolean,
|
fixMax?: boolean,
|
minInterval?: number,
|
maxInterval?: number
|
}
|
): void {
|
const extent = this._extent;
|
// If extent start and end are same, expand them
|
if (extent[0] === extent[1]) {
|
// Expand extent
|
extent[0] -= ONE_DAY;
|
extent[1] += ONE_DAY;
|
}
|
// If there are no data and extent are [Infinity, -Infinity]
|
if (extent[1] === -Infinity && extent[0] === Infinity) {
|
const d = new Date();
|
extent[1] = +new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
extent[0] = extent[1] - ONE_DAY;
|
}
|
|
this.niceTicks(opt.splitNumber, opt.minInterval, opt.maxInterval);
|
}
|
|
niceTicks(approxTickNum: number, minInterval: number, maxInterval: number): void {
|
approxTickNum = approxTickNum || 10;
|
|
const extent = this._extent;
|
const span = extent[1] - extent[0];
|
this._approxInterval = span / approxTickNum;
|
|
if (minInterval != null && this._approxInterval < minInterval) {
|
this._approxInterval = minInterval;
|
}
|
if (maxInterval != null && this._approxInterval > maxInterval) {
|
this._approxInterval = maxInterval;
|
}
|
|
const scaleIntervalsLen = scaleIntervals.length;
|
const idx = Math.min(
|
bisect(scaleIntervals, this._approxInterval, 0, scaleIntervalsLen),
|
scaleIntervalsLen - 1
|
);
|
|
// Interval that can be used to calculate ticks
|
this._interval = scaleIntervals[idx][1];
|
// Min level used when picking ticks from top down.
|
// We check one more level to avoid the ticks are to sparse in some case.
|
this._minLevelUnit = scaleIntervals[Math.max(idx - 1, 0)][0];
|
}
|
|
parse(val: number | string | Date): number {
|
// val might be float.
|
return typeof val === 'number' ? val : +numberUtil.parseDate(val);
|
}
|
|
contain(val: number): boolean {
|
return scaleHelper.contain(this.parse(val), this._extent);
|
}
|
|
normalize(val: number): number {
|
return scaleHelper.normalize(this.parse(val), this._extent);
|
}
|
|
scale(val: number): number {
|
return scaleHelper.scale(val, this._extent);
|
}
|
|
}
|
|
|
/**
|
* This implementation was originally copied from "d3.js"
|
* <https://github.com/d3/d3/blob/b516d77fb8566b576088e73410437494717ada26/src/time/scale.js>
|
* with some modifications made for this program.
|
* See the license statement at the head of this file.
|
*/
|
const scaleIntervals: [TimeUnit, number][] = [
|
// Format interval
|
['second', ONE_SECOND], // 1s
|
['minute', ONE_MINUTE], // 1m
|
['hour', ONE_HOUR], // 1h
|
['quarter-day', ONE_HOUR * 6], // 6h
|
['half-day', ONE_HOUR * 12], // 12h
|
['day', ONE_DAY * 1.2], // 1d
|
['half-week', ONE_DAY * 3.5], // 3.5d
|
['week', ONE_DAY * 7], // 7d
|
['month', ONE_DAY * 31], // 1M
|
['quarter', ONE_DAY * 95], // 3M
|
['half-year', ONE_YEAR / 2], // 6M
|
['year', ONE_YEAR] // 1Y
|
];
|
|
function isUnitValueSame(
|
unit: PrimaryTimeUnit,
|
valueA: number,
|
valueB: number,
|
isUTC: boolean
|
): boolean {
|
const dateA = numberUtil.parseDate(valueA) as any;
|
const dateB = numberUtil.parseDate(valueB) as any;
|
|
const isSame = (unit: PrimaryTimeUnit) => {
|
return getUnitValue(dateA, unit, isUTC)
|
=== getUnitValue(dateB, unit, isUTC);
|
};
|
const isSameYear = () => isSame('year');
|
// const isSameHalfYear = () => isSameYear() && isSame('half-year');
|
// const isSameQuater = () => isSameYear() && isSame('quarter');
|
const isSameMonth = () => isSameYear() && isSame('month');
|
const isSameDay = () => isSameMonth() && isSame('day');
|
// const isSameHalfDay = () => isSameDay() && isSame('half-day');
|
const isSameHour = () => isSameDay() && isSame('hour');
|
const isSameMinute = () => isSameHour() && isSame('minute');
|
const isSameSecond = () => isSameMinute() && isSame('second');
|
const isSameMilliSecond = () => isSameSecond() && isSame('millisecond');
|
|
switch (unit) {
|
case 'year':
|
return isSameYear();
|
case 'month':
|
return isSameMonth();
|
case 'day':
|
return isSameDay();
|
case 'hour':
|
return isSameHour();
|
case 'minute':
|
return isSameMinute();
|
case 'second':
|
return isSameSecond();
|
case 'millisecond':
|
return isSameMilliSecond();
|
}
|
}
|
|
// const primaryUnitGetters = {
|
// year: fullYearGetterName(),
|
// month: monthGetterName(),
|
// day: dateGetterName(),
|
// hour: hoursGetterName(),
|
// minute: minutesGetterName(),
|
// second: secondsGetterName(),
|
// millisecond: millisecondsGetterName()
|
// };
|
|
// const primaryUnitUTCGetters = {
|
// year: fullYearGetterName(true),
|
// month: monthGetterName(true),
|
// day: dateGetterName(true),
|
// hour: hoursGetterName(true),
|
// minute: minutesGetterName(true),
|
// second: secondsGetterName(true),
|
// millisecond: millisecondsGetterName(true)
|
// };
|
|
// function moveTick(date: Date, unitName: TimeUnit, step: number, isUTC: boolean) {
|
// step = step || 1;
|
// switch (getPrimaryTimeUnit(unitName)) {
|
// case 'year':
|
// date[fullYearSetterName(isUTC)](date[fullYearGetterName(isUTC)]() + step);
|
// break;
|
// case 'month':
|
// date[monthSetterName(isUTC)](date[monthGetterName(isUTC)]() + step);
|
// break;
|
// case 'day':
|
// date[dateSetterName(isUTC)](date[dateGetterName(isUTC)]() + step);
|
// break;
|
// case 'hour':
|
// date[hoursSetterName(isUTC)](date[hoursGetterName(isUTC)]() + step);
|
// break;
|
// case 'minute':
|
// date[minutesSetterName(isUTC)](date[minutesGetterName(isUTC)]() + step);
|
// break;
|
// case 'second':
|
// date[secondsSetterName(isUTC)](date[secondsGetterName(isUTC)]() + step);
|
// break;
|
// case 'millisecond':
|
// date[millisecondsSetterName(isUTC)](date[millisecondsGetterName(isUTC)]() + step);
|
// break;
|
// }
|
// return date.getTime();
|
// }
|
|
// const DATE_INTERVALS = [[8, 7.5], [4, 3.5], [2, 1.5]];
|
// const MONTH_INTERVALS = [[6, 5.5], [3, 2.5], [2, 1.5]];
|
// const MINUTES_SECONDS_INTERVALS = [[30, 30], [20, 20], [15, 15], [10, 10], [5, 5], [2, 2]];
|
|
function getDateInterval(approxInterval: number, daysInMonth: number) {
|
approxInterval /= ONE_DAY;
|
return approxInterval > 16 ? 16
|
// Math.floor(daysInMonth / 2) + 1 // In this case we only want one tick betwen two month.
|
: approxInterval > 7.5 ? 7 // TODO week 7 or day 8?
|
: approxInterval > 3.5 ? 4
|
: approxInterval > 1.5 ? 2 : 1;
|
}
|
|
function getMonthInterval(approxInterval: number) {
|
const APPROX_ONE_MONTH = 30 * ONE_DAY;
|
approxInterval /= APPROX_ONE_MONTH;
|
return approxInterval > 6 ? 6
|
: approxInterval > 3 ? 3
|
: approxInterval > 2 ? 2 : 1;
|
}
|
|
function getHourInterval(approxInterval: number) {
|
approxInterval /= ONE_HOUR;
|
return approxInterval > 12 ? 12
|
: approxInterval > 6 ? 6
|
: approxInterval > 3.5 ? 4
|
: approxInterval > 2 ? 2 : 1;
|
}
|
|
function getMinutesAndSecondsInterval(approxInterval: number, isMinutes?: boolean) {
|
approxInterval /= isMinutes ? ONE_MINUTE : ONE_SECOND;
|
return approxInterval > 30 ? 30
|
: approxInterval > 20 ? 20
|
: approxInterval > 15 ? 15
|
: approxInterval > 10 ? 10
|
: approxInterval > 5 ? 5
|
: approxInterval > 2 ? 2 : 1;
|
}
|
|
function getMillisecondsInterval(approxInterval: number) {
|
return numberUtil.nice(approxInterval, true);
|
}
|
|
function getFirstTimestampOfUnit(date: Date, unitName: TimeUnit, isUTC: boolean) {
|
const outDate = new Date(date);
|
switch (getPrimaryTimeUnit(unitName)) {
|
case 'year':
|
case 'month':
|
outDate[monthSetterName(isUTC)](0);
|
case 'day':
|
outDate[dateSetterName(isUTC)](1);
|
case 'hour':
|
outDate[hoursSetterName(isUTC)](0);
|
case 'minute':
|
outDate[minutesSetterName(isUTC)](0);
|
case 'second':
|
outDate[secondsSetterName(isUTC)](0);
|
outDate[millisecondsSetterName(isUTC)](0);
|
}
|
return outDate.getTime();
|
}
|
|
function getIntervalTicks(
|
bottomUnitName: TimeUnit,
|
approxInterval: number,
|
isUTC: boolean,
|
extent: number[]
|
): TimeScaleTick[] {
|
const safeLimit = 10000;
|
const unitNames = timeUnits;
|
// const bottomPrimaryUnitName = getPrimaryTimeUnit(bottomUnitName);
|
|
interface InnerTimeTick extends TimeScaleTick {
|
notAdd?: boolean
|
}
|
|
let iter = 0;
|
|
function addTicksInSpan(
|
interval: number,
|
minTimestamp: number, maxTimestamp: number,
|
getMethodName: string,
|
setMethodName: string,
|
isDate: boolean,
|
out: InnerTimeTick[]
|
) {
|
const date = new Date(minTimestamp) as any;
|
let dateTime = minTimestamp;
|
let d = date[getMethodName]();
|
|
// if (isDate) {
|
// d -= 1; // Starts with 0; PENDING
|
// }
|
|
while (dateTime < maxTimestamp && dateTime <= extent[1]) {
|
out.push({
|
value: dateTime
|
});
|
|
d += interval;
|
date[setMethodName](d);
|
dateTime = date.getTime();
|
}
|
|
// This extra tick is for calcuating ticks of next level. Will not been added to the final result
|
out.push({
|
value: dateTime,
|
notAdd: true
|
});
|
}
|
|
function addLevelTicks(
|
unitName: TimeUnit,
|
lastLevelTicks: InnerTimeTick[],
|
levelTicks: InnerTimeTick[]
|
) {
|
const newAddedTicks: ScaleTick[] = [];
|
const isFirstLevel = !lastLevelTicks.length;
|
|
if (isUnitValueSame(getPrimaryTimeUnit(unitName), extent[0], extent[1], isUTC)) {
|
return;
|
}
|
|
if (isFirstLevel) {
|
lastLevelTicks = [{
|
// TODO Optimize. Not include so may ticks.
|
value: getFirstTimestampOfUnit(new Date(extent[0]), unitName, isUTC)
|
}, {
|
value: extent[1]
|
}];
|
}
|
|
for (let i = 0; i < lastLevelTicks.length - 1; i++) {
|
const startTick = lastLevelTicks[i].value;
|
const endTick = lastLevelTicks[i + 1].value;
|
if (startTick === endTick) {
|
continue;
|
}
|
|
let interval: number;
|
let getterName;
|
let setterName;
|
let isDate = false;
|
|
switch (unitName) {
|
case 'year':
|
interval = Math.max(1, Math.round(approxInterval / ONE_DAY / 365));
|
getterName = fullYearGetterName(isUTC);
|
setterName = fullYearSetterName(isUTC);
|
break;
|
case 'half-year':
|
case 'quarter':
|
case 'month':
|
interval = getMonthInterval(approxInterval);
|
getterName = monthGetterName(isUTC);
|
setterName = monthSetterName(isUTC);
|
break;
|
case 'week': // PENDING If week is added. Ignore day.
|
case 'half-week':
|
case 'day':
|
interval = getDateInterval(approxInterval, 31); // Use 32 days and let interval been 16
|
getterName = dateGetterName(isUTC);
|
setterName = dateSetterName(isUTC);
|
isDate = true;
|
break;
|
case 'half-day':
|
case 'quarter-day':
|
case 'hour':
|
interval = getHourInterval(approxInterval);
|
getterName = hoursGetterName(isUTC);
|
setterName = hoursSetterName(isUTC);
|
break;
|
case 'minute':
|
interval = getMinutesAndSecondsInterval(approxInterval, true);
|
getterName = minutesGetterName(isUTC);
|
setterName = minutesSetterName(isUTC);
|
break;
|
case 'second':
|
interval = getMinutesAndSecondsInterval(approxInterval, false);
|
getterName = secondsGetterName(isUTC);
|
setterName = secondsSetterName(isUTC);
|
break;
|
case 'millisecond':
|
interval = getMillisecondsInterval(approxInterval);
|
getterName = millisecondsGetterName(isUTC);
|
setterName = millisecondsSetterName(isUTC);
|
break;
|
}
|
|
addTicksInSpan(
|
interval, startTick, endTick, getterName, setterName, isDate, newAddedTicks
|
);
|
|
if (unitName === 'year' && levelTicks.length > 1 && i === 0) {
|
// Add nearest years to the left extent.
|
levelTicks.unshift({
|
value: levelTicks[0].value - interval
|
});
|
}
|
}
|
|
for (let i = 0; i < newAddedTicks.length; i++) {
|
levelTicks.push(newAddedTicks[i]);
|
}
|
// newAddedTicks.length && console.log(unitName, newAddedTicks);
|
return newAddedTicks;
|
}
|
|
const levelsTicks: InnerTimeTick[][] = [];
|
let currentLevelTicks: InnerTimeTick[] = [];
|
|
let tickCount = 0;
|
let lastLevelTickCount = 0;
|
for (let i = 0; i < unitNames.length && iter++ < safeLimit; ++i) {
|
const primaryTimeUnit = getPrimaryTimeUnit(unitNames[i]);
|
if (!isPrimaryTimeUnit(unitNames[i])) { // TODO
|
continue;
|
}
|
addLevelTicks(unitNames[i], levelsTicks[levelsTicks.length - 1] || [], currentLevelTicks);
|
|
const nextPrimaryTimeUnit: PrimaryTimeUnit = unitNames[i + 1] ? getPrimaryTimeUnit(unitNames[i + 1]) : null;
|
if (primaryTimeUnit !== nextPrimaryTimeUnit) {
|
if (currentLevelTicks.length) {
|
lastLevelTickCount = tickCount;
|
// Remove the duplicate so the tick count can be precisely.
|
currentLevelTicks.sort((a, b) => a.value - b.value);
|
const levelTicksRemoveDuplicated = [];
|
for (let i = 0; i < currentLevelTicks.length; ++i) {
|
const tickValue = currentLevelTicks[i].value;
|
if (i === 0 || currentLevelTicks[i - 1].value !== tickValue) {
|
levelTicksRemoveDuplicated.push(currentLevelTicks[i]);
|
if (tickValue >= extent[0] && tickValue <= extent[1]) {
|
tickCount++;
|
}
|
}
|
}
|
|
const targetTickNum = (extent[1] - extent[0]) / approxInterval;
|
// Added too much in this level and not too less in last level
|
if (tickCount > targetTickNum * 1.5 && lastLevelTickCount > targetTickNum / 1.5) {
|
break;
|
}
|
|
// Only treat primary time unit as one level.
|
levelsTicks.push(levelTicksRemoveDuplicated);
|
|
if (tickCount > targetTickNum || bottomUnitName === unitNames[i]) {
|
break;
|
}
|
|
}
|
// Reset if next unitName is primary
|
currentLevelTicks = [];
|
}
|
|
}
|
|
if (__DEV__) {
|
if (iter >= safeLimit) {
|
warn('Exceed safe limit.');
|
}
|
}
|
|
const levelsTicksInExtent = filter(map(levelsTicks, levelTicks => {
|
return filter(levelTicks, tick => tick.value >= extent[0] && tick.value <= extent[1] && !tick.notAdd);
|
}), levelTicks => levelTicks.length > 0);
|
|
const ticks: TimeScaleTick[] = [];
|
const maxLevel = levelsTicksInExtent.length - 1;
|
for (let i = 0; i < levelsTicksInExtent.length; ++i) {
|
const levelTicks = levelsTicksInExtent[i];
|
for (let k = 0; k < levelTicks.length; ++k) {
|
ticks.push({
|
value: levelTicks[k].value,
|
level: maxLevel - i
|
});
|
}
|
}
|
|
ticks.sort((a, b) => a.value - b.value);
|
// Remove duplicates
|
const result: TimeScaleTick[] = [];
|
for (let i = 0; i < ticks.length; ++i) {
|
if (i === 0 || ticks[i].value !== ticks[i - 1].value) {
|
result.push(ticks[i]);
|
}
|
}
|
|
return result;
|
}
|
|
|
Scale.registerClass(TimeScale);
|
|
export default TimeScale;
|