Skip to content

Commit

Permalink
Merge pull request #876 from tradingview/fix781-tick-marks-gen
Browse files Browse the repository at this point in the history
Documentation about time zones support
  • Loading branch information
timocov authored Nov 3, 2021
2 parents 5d82a8a + 1530cbb commit 7104e9a
Show file tree
Hide file tree
Showing 8 changed files with 212 additions and 75 deletions.
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,4 @@ That's it! Your first chart is ready and we can now proceed.
- [Series basics](./series-basics.md)
- [Customization](./customization.md)
- [Time Scale](./time-scale.md)
- [Time zones support](./time-zones.md)
111 changes: 111 additions & 0 deletions docs/time-zones.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Working with time zones

This doc describes what do you need to do if you want to add time zone support to your chart.

## Background

By default, `lightweight-charts` doesn't support time zones of any kind, just because JavaScript doesn't have an API to do that.
Things that the library uses internally includes an API to:

- Format a date
- Get a date and/or time parts of a date object (year, month, day, hours, etc)

Out of the box we could rely on 2 APIs:

- [Date](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date)
- [Intl](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl)

And even if to format a date we could (and we do) use `Date` object with its `toLocaleString` method (and we could even pass a `timeZone` field as an option),
but how about date/time field?

All to solve this it seems that the only solution we have is `Date`'s getters, e.g. `getHours`. Here we could use 2 APIs:

- UTC-based methods like `getUTCHours` to get the date/time in UTC
- Client-based methods like `getHours` to get the date/time in _a local (for the client)_ time zone

As you can see we just unable to get date/time parts in desired time zone without using custom libraries (like `date-fns`) out of the box.

Because of this we decided not to handle time zones in the library. The library treats all dates and times as UTC internally.

But don't worry - it's easy to add time-zone support in your own code!

## How to add time zone support to your chart

**TL;DR** - time for every bar should be "corrected" by a time zone offset.

The only way to do this is to change a time in your data.

As soon as the library relies on UTC-based methods, you could change a time of your data item so in UTC it could be as it is in desired time zone.

Let's consider an example.

Lets say you have a bar with time `2021-01-01T10:00:00.000Z` (a string representation is just for better readability).
And you want to display your chart in `Europe/Moscow` time zone.

According to tz database, for `Europe/Moscow` time zone a time offset at this time is `UTC+03:00`, i.e. +3 hours (pay attention that you cannot use the same offset all the time, because of DST and many other things!).

By this means, the time for `Europe/Moscow` is `2021-01-01 13:00:00.000` (so basically you want to display this time over the UTC one).

To display your chart in the `Europe/Moscow` time zone you would need to adjust the time of your data by +3 hours. So `2021-01-01T10:00:00.000Z` would become `2021-01-01T13:00:00.000Z`.

Note that due a time zone offset the date could be changed as well (not only time part).

This looks tricky, but hopefully you need to implement it once and then just forget this ever happened 😀

### `Date` solution

One of possible solutions (and looks like the most simplest one) is to use approach from [this answer on StackOverflow](https://stackoverflow.com/a/54127122/3893439):

```js
// you could use this function to convert all your times to required time zone
function timeToTz(originalTime, timeZone) {
const zonedDate = new Date(new Date(originalTime * 1000).toLocaleString('en-US', { timeZone }));
return zonedDate.getTime() / 1000;
}
```

#### Note about converting to a "local" time zone

If you don't need to work with time zones in general, but only needs to support a client time zone (i.e. local), you could use the following trick:

```js
function timeToLocal(originalTime) {
const d = new Date(originalTime * 1000);
return Date.UTC(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds()) / 1000;
}
```

### `date-fns-tz` solution

You could also achieve the result by using [`date-fns-tz`](https://github.com/marnusw/date-fns-tz) library in the following way:

```js
import { utcToZonedTime } from 'date-fns-tz';

function timeToTz(originalTime, timeZone) {
const zonedDate = utcToZonedTime(new Date(originalTime * 1000), timeZone);
return zonedDate.getTime() / 1000;
}
```

### `tzdata` solution

If you have lots of data items and the performance of other solutions doesn't fit your requirements you could try to implement more complex solution by using raw [`tzdata`](https://www.npmjs.com/package/tzdata).

The better performance could be achieved with this approach because:

- you don't need to parse dates every time you want to get an offset so you could use [lowerbound algorithm](https://en.wikipedia.org/wiki/Upper_and_lower_bounds) (which is `O(log N)`) to find an offset of very first data point quickly
- after you found an offset, you go through all data items and check whether an offset should be changed or not to the next one (based on a time of the next time shift)

## Why we didn't implement it in the library

- `Date` solution is quite slow (in our tests it took more than 20 seconds for 100k points)
- Albeit `date-fns-tz` solution is a bit faster that the solution with `Date` but it is still very slow (~17-18 seconds for 100k points) and additionally it requires to add another set of dependencies to the package
- `tzdata` solution requires to increase the size of the library by [more than 31kB min.gz](https://bundlephobia.com/package/tzdata) (which is almost the size of the whole library!)

Keep in mind that time zones feature is not an issue for everybody so this is up to you to decide whether you want/need to support it or not and so far we don't want to sacrifice performance/package size for everybody by this feature.

## Note about converting business days

If you're using a business day for your time (either [object](./time.md#business-day-object) or [string](./time.md#business-day-string) representation), for example because of DWM nature of your data,
most likely you **shouldn't** convert that time to a zoned one, because this time represents a day.
63 changes: 32 additions & 31 deletions src/api/time-scale-point-weight-generator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { TimeScalePoint, UTCTimestamp } from '../model/time-data';
import { TickMarkWeight, TimeScalePoint } from '../model/time-data';

function hours(count: number): number {
return count * 60 * 60 * 1000;
Expand All @@ -14,54 +14,55 @@ function seconds(count: number): number {

interface WeightDivisor {
divisor: number;
weight: number;
weight: TickMarkWeight;
}

const intradayWeightDivisors: WeightDivisor[] = [
// TODO: divisor=1 means 1ms and it's strange that weight for 1ms > weight for 1s
{ divisor: 1, weight: 20 },
{ divisor: seconds(1), weight: 19 },
{ divisor: minutes(1), weight: 20 },
{ divisor: minutes(5), weight: 21 },
{ divisor: minutes(30), weight: 22 },
{ divisor: hours(1), weight: 30 },
{ divisor: hours(3), weight: 31 },
{ divisor: hours(6), weight: 32 },
{ divisor: hours(12), weight: 33 },
{ divisor: seconds(1), weight: TickMarkWeight.Second },
{ divisor: minutes(1), weight: TickMarkWeight.Minute1 },
{ divisor: minutes(5), weight: TickMarkWeight.Minute5 },
{ divisor: minutes(30), weight: TickMarkWeight.Minute30 },
{ divisor: hours(1), weight: TickMarkWeight.Hour1 },
{ divisor: hours(3), weight: TickMarkWeight.Hour3 },
{ divisor: hours(6), weight: TickMarkWeight.Hour6 },
{ divisor: hours(12), weight: TickMarkWeight.Hour12 },
];

function weightByTime(currentDate: Date, prevDate: Date | null): number {
if (prevDate !== null) {
if (currentDate.getUTCFullYear() !== prevDate.getUTCFullYear()) {
return 70;
} else if (currentDate.getUTCMonth() !== prevDate.getUTCMonth()) {
return 60;
} else if (currentDate.getUTCDate() !== prevDate.getUTCDate()) {
return 50;
}
function weightByTime(currentDate: Date, prevDate: Date): TickMarkWeight {
if (currentDate.getUTCFullYear() !== prevDate.getUTCFullYear()) {
return TickMarkWeight.Year;
} else if (currentDate.getUTCMonth() !== prevDate.getUTCMonth()) {
return TickMarkWeight.Month;
} else if (currentDate.getUTCDate() !== prevDate.getUTCDate()) {
return TickMarkWeight.Day;
}

for (let i = intradayWeightDivisors.length - 1; i >= 0; --i) {
if (Math.floor(prevDate.getTime() / intradayWeightDivisors[i].divisor) !== Math.floor(currentDate.getTime() / intradayWeightDivisors[i].divisor)) {
return intradayWeightDivisors[i].weight;
}
for (let i = intradayWeightDivisors.length - 1; i >= 0; --i) {
if (Math.floor(prevDate.getTime() / intradayWeightDivisors[i].divisor) !== Math.floor(currentDate.getTime() / intradayWeightDivisors[i].divisor)) {
return intradayWeightDivisors[i].weight;
}
}

return 20;
return TickMarkWeight.LessThanSecond;
}

export function fillWeightsForPoints(sortedTimePoints: readonly Mutable<TimeScalePoint>[], startIndex: number = 0): void {
let prevTime: UTCTimestamp | null = (startIndex === 0 || sortedTimePoints.length === 0)
? null
: sortedTimePoints[startIndex - 1].time.timestamp;
let prevDate: Date | null = prevTime !== null ? new Date(prevTime * 1000) : null;
if (sortedTimePoints.length === 0) {
return;
}

let prevTime = startIndex === 0 ? null : sortedTimePoints[startIndex - 1].time.timestamp;
let prevDate = prevTime !== null ? new Date(prevTime * 1000) : null;

let totalTimeDiff = 0;

for (let index = startIndex; index < sortedTimePoints.length; ++index) {
const currentPoint = sortedTimePoints[index];
const currentDate = new Date(currentPoint.time.timestamp * 1000);
currentPoint.timeWeight = weightByTime(currentDate, prevDate);

if (prevDate !== null) {
currentPoint.timeWeight = weightByTime(currentDate, prevDate);
}

totalTimeDiff += currentPoint.time.timestamp - (prevTime || currentPoint.time.timestamp);

Expand Down
7 changes: 4 additions & 3 deletions src/gui/time-axis-widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { IDataSource } from '../model/idata-source';
import { InvalidationLevel } from '../model/invalidate-mask';
import { LayoutOptionsInternal } from '../model/layout-options';
import { TextWidthCache } from '../model/text-width-cache';
import { TickMarkWeight } from '../model/time-data';
import { TimeMark } from '../model/time-scale';
import { TimeAxisViewRendererOptions } from '../renderers/itime-axis-view-renderer';

Expand Down Expand Up @@ -303,9 +304,9 @@ export class TimeAxisWidget implements MouseEventHandlers, IDestroyable {
let maxWeight = tickMarks.reduce(markWithGreaterWeight, tickMarks[0]).weight;

// special case: it looks strange if 15:00 is bold but 14:00 is not
// so if maxWeight > 30 and < 40 reduce it to 30
if (maxWeight > 30 && maxWeight < 40) {
maxWeight = 30;
// so if maxWeight > TickMarkWeight.Hour1 and < TickMarkWeight.Day reduce it to TickMarkWeight.Hour1
if (maxWeight > TickMarkWeight.Hour1 && maxWeight < TickMarkWeight.Day) {
maxWeight = TickMarkWeight.Hour1;
}

ctx.save();
Expand Down
4 changes: 2 additions & 2 deletions src/model/localization-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { BusinessDay, UTCTimestamp } from './time-data';
export type TimeFormatterFn = (time: BusinessDay | UTCTimestamp) => string;

/**
* Represents options for formattings dates, times, and prices according to a locale.
* Represents options for formatting dates, times, and prices according to a locale.
*/
export interface LocalizationOptions {
/**
Expand All @@ -19,7 +19,7 @@ export interface LocalizationOptions {
locale: string;

/**
* Override fomatting of the price scale crosshair label. Can be used for cases that can't be covered with built-in price formats.
* Override formatting of the price scale crosshair label. Can be used for cases that can't be covered with built-in price formats.
*
* See also {@link PriceFormatCustom}.
*/
Expand Down
8 changes: 4 additions & 4 deletions src/model/tick-marks.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { lowerbound } from '../helpers/algorithms';
import { ensureDefined } from '../helpers/assertions';

import { TimePoint, TimePointIndex, TimeScalePoint } from './time-data';
import { TickMarkWeight, TimePoint, TimePointIndex, TimeScalePoint } from './time-data';

export interface TickMark {
index: TimePointIndex;
time: TimePoint;
weight: number;
weight: TickMarkWeight;
}

interface MarksCache {
Expand All @@ -15,7 +15,7 @@ interface MarksCache {
}

export class TickMarks {
private _marksByWeight: Map<number, TickMark[]> = new Map();
private _marksByWeight: Map<TickMarkWeight, TickMark[]> = new Map();
private _cache: MarksCache | null = null;

public setTimeScalePoints(newPoints: readonly TimeScalePoint[], firstChangedPointIndex: number): void {
Expand Down Expand Up @@ -57,7 +57,7 @@ export class TickMarks {
return;
}

const weightsToClear: number[] = [];
const weightsToClear: TickMarkWeight[] = [];

this._marksByWeight.forEach((marks: TickMark[], timeWeight: number) => {
if (sinceIndex <= marks[0].index) {
Expand Down
24 changes: 23 additions & 1 deletion src/model/time-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,30 @@ export interface TimePoint {
businessDay?: BusinessDay;
}

/**
* Describes a weight of tick mark, i.e. a part of a time that changed since previous time.
* Note that you can use any timezone to calculate this value, it is unnecessary to use UTC.
*
* @example Between 2020-01-01 and 2020-01-02 there is a day of difference, i.e. for 2020-01-02 weight would be a day.
* @example Between 2020-01-01 and 2020-02-02 there is a month of difference, i.e. for 2020-02-02 weight would be a month.
*/
export const enum TickMarkWeight {
LessThanSecond = 0,
Second = 10,
Minute1 = 20,
Minute5 = 21,
Minute30 = 22,
Hour1 = 30,
Hour3 = 31,
Hour6 = 32,
Hour12 = 33,
Day = 50,
Month = 60,
Year = 70,
}

export interface TimeScalePoint {
readonly timeWeight: number;
readonly timeWeight: TickMarkWeight;
readonly time: TimePoint;
}

Expand Down
Loading

0 comments on commit 7104e9a

Please sign in to comment.