Repository: tradingview/lightweight-charts (ts)

Issue:
Time scale marks customization
Currently there is no support to customise the time scale with even at least formatting.

I have two problems:

- My trade data is now showing random intervals of 12:32 and 13:34 rather than just 12:30 and 13:30 
- There doesn't seem to be a way to localise the timescale and convert unix timestamp to local times

Any chance we can get this soon?

Pull request description:
Added ability to customize time scale tick marks formatter
**Type of PR:** enhancement

**PR checklist:**

- [x] Addresses an existing issue: fixes #226
- [x] Includes tests
- [x] Documentation update

Patch:
diff --git a/codechecks.yml b/codechecks.yml
index 54feacb47..2f7f4c103 100644
--- a/codechecks.yml
+++ b/codechecks.yml
@@ -3,9 +3,9 @@ checks:
     options:
       files:
         - path: "./dist/lightweight-charts.esm.production.js"
-          maxSize: 43.1KB
+          maxSize: 44.5KB
         - path: "./dist/lightweight-charts.standalone.production.js"
-          maxSize: 43.7KB
+          maxSize: 45KB
 
   - name: typecov
     options:
diff --git a/docs/constants.md b/docs/constants.md
index af8cba7ec..56a81632a 100644
--- a/docs/constants.md
+++ b/docs/constants.md
@@ -68,3 +68,15 @@ It has the following values:
 
 - `PriceLineSource.LastBar` - use the last bar data.
 - `PriceLineSource.LastVisible` - use the last visible data of the chart viewport.
+
+## TickMarkType
+
+`TickMarkType` enum is used to specify a type of a tick mark on the time axis.
+
+It has the following values:
+
+- `TickMarkType.Year` - the tick mark represents start of the year (e.g. it's the first tick mark in a year).
+- `TickMarkType.Month` - the tick mark represents start of the month (e.g. it's the first tick mark in a month).
+- `TickMarkType.DayOfMonth` - the tick mark represents a day of the month.
+- `TickMarkType.Time` - the tick mark represents a time without seconds.
+- `TickMarkType.TimeWithSeconds` - the tick mark represents a full time format.
diff --git a/docs/time-scale.md b/docs/time-scale.md
index dc89c9dde..23174b23a 100644
--- a/docs/time-scale.md
+++ b/docs/time-scale.md
@@ -16,6 +16,24 @@ Time scale (or time axis) is a horizontal scale at the bottom of the chart that
 |`visible`|`boolean`|`true`|If true, the time scale is shown on a chart|
 |`timeVisible`|`boolean`|`false`|If true, the time is shown on the time scale and in the vertical crosshair label|
 |`secondsVisible`|`boolean`|`true`|If true, seconds are shown on the label of the crosshair vertical line in `hh:mm:ss` format on intraday intervals|
+|`tickMarkFormatter`|`(TimePoint, TickMarkType, locale) => string`|Default tick marks formatter|Allows to override the tick marks formatter (see below)|
+
+### Tick marks formatter
+
+Tick marks formatter can be used to customize tick marks labels on the time axis.
+
+To customize it, you need to provide the `tickMarkFormatter` option. It's a function with the following declaration:
+
+```typescript
+export type TickMarkFormatter = (timePoint: TimePoint, tickMarkType: TickMarkType, locale: string) => string;
+```
+
+Where `timePoint` is [Time](./time.md) object, `type` is [TickMarkType](./constants.md#TickMarkType) enum and `locale` is the currently applied locale of the string type.
+
+This function should return `timePoint` as a string formatted according to `tickMarkType` type (year, month, etc) and `locale`.
+
+Note that the returned string should be the shortest possible value and should have no more than 8 characters.
+Otherwise, the tick marks will overlap each other.
 
 ### Example of time scale customization
 
@@ -32,6 +50,10 @@ chart.applyOptions({
         visible: true,
         timeVisible: true,
         secondsVisible: false,
+        tickMarkFormatter: function(timePoint, tickMarkType, locale) {
+            console.log(timePoint, tickMarkType, locale);
+            return String(new Date(timePoint.timestamp).getUTCFullYear());
+        },
     },
 });
 ```
diff --git a/src/api/options/time-scale-options-defaults.ts b/src/api/options/time-scale-options-defaults.ts
index 230753abd..2d35891b6 100644
--- a/src/api/options/time-scale-options-defaults.ts
+++ b/src/api/options/time-scale-options-defaults.ts
@@ -1,3 +1,4 @@
+import { defaultTickMarkFormatter } from '../../model/default-tick-mark-formatter';
 import { TimeScaleOptions } from '../../model/time-scale';
 
 export const timeScaleOptionsDefaults: TimeScaleOptions = {
@@ -11,4 +12,5 @@ export const timeScaleOptionsDefaults: TimeScaleOptions = {
 	visible: true,
 	timeVisible: false,
 	secondsVisible: true,
+	tickMarkFormatter: defaultTickMarkFormatter,
 };
diff --git a/src/index.ts b/src/index.ts
index 41c7d6583..1d3ef21c8 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -7,6 +7,7 @@ export { CrosshairMode } from './model/crosshair';
 export { PriceScaleMode } from './model/price-scale';
 export { PriceLineSource } from './model/series-options';
 export { UTCTimestamp } from './model/time-data';
+export { TickMarkType } from './model/time-scale';
 
 export {
 	BarData,
diff --git a/src/model/default-tick-mark-formatter.ts b/src/model/default-tick-mark-formatter.ts
new file mode 100644
index 000000000..f85e21dcd
--- /dev/null
+++ b/src/model/default-tick-mark-formatter.ts
@@ -0,0 +1,56 @@
+import { ensureNever } from '../helpers/assertions';
+
+import { TimePoint } from './time-data';
+import { TickMarkType } from './time-scale';
+
+export function defaultTickMarkFormatter(timePoint: TimePoint, tickMarkType: TickMarkType, locale: string): string {
+	const formatOptions: Intl.DateTimeFormatOptions = {};
+
+	switch (tickMarkType) {
+		case TickMarkType.Year:
+			formatOptions.year = 'numeric';
+			break;
+
+		case TickMarkType.Month:
+			formatOptions.month = 'short';
+			break;
+
+		case TickMarkType.DayOfMonth:
+			formatOptions.day = 'numeric';
+			break;
+
+		case TickMarkType.Time:
+			formatOptions.hour12 = false;
+			formatOptions.hour = '2-digit';
+			formatOptions.minute = '2-digit';
+			break;
+
+		case TickMarkType.TimeWithSeconds:
+			formatOptions.hour12 = false;
+			formatOptions.hour = '2-digit';
+			formatOptions.minute = '2-digit';
+			formatOptions.second = '2-digit';
+			break;
+
+		default:
+			ensureNever(tickMarkType);
+	}
+
+	const date = timePoint.businessDay === undefined
+		? new Date(timePoint.timestamp * 1000)
+		: new Date(Date.UTC(timePoint.businessDay.year, timePoint.businessDay.month - 1, timePoint.businessDay.day));
+
+	// from given date we should use only as UTC date or timestamp
+	// but to format as locale date we can convert UTC date to local date
+	const localDateFromUtc = new Date(
+		date.getUTCFullYear(),
+		date.getUTCMonth(),
+		date.getUTCDate(),
+		date.getUTCHours(),
+		date.getUTCMinutes(),
+		date.getUTCSeconds(),
+		date.getUTCMilliseconds()
+	);
+
+	return localDateFromUtc.toLocaleString(locale, formatOptions);
+}
diff --git a/src/model/formatted-labels-cache.ts b/src/model/formatted-labels-cache.ts
index 0076a1e88..4020d50fb 100644
--- a/src/model/formatted-labels-cache.ts
+++ b/src/model/formatted-labels-cache.ts
@@ -1,11 +1,13 @@
 import { ensureDefined } from '../helpers/assertions';
 
+import { TimePoint } from './time-data';
+
 interface CachedTick {
 	string: string;
 	tick: number;
 }
 
-export type FormatFunction = (value: Date) => string;
+export type FormatFunction = (timePoint: TimePoint) => string;
 
 export class FormattedLabelsCache {
 	private readonly _format: FormatFunction;
@@ -21,8 +23,12 @@ export class FormattedLabelsCache {
 		this._maxSize = size;
 	}
 
-	public format(value: Date): string {
-		const tick = this._cache.get(value.valueOf());
+	public format(value: TimePoint): string {
+		const cacheKey = value.businessDay === undefined
+			? new Date(value.timestamp * 1000).getTime()
+			: new Date(Date.UTC(value.businessDay.year, value.businessDay.month - 1, value.businessDay.day)).getTime();
+
+		const tick = this._cache.get(cacheKey);
 		if (tick !== undefined) {
 			return tick.string;
 		}
@@ -36,8 +42,8 @@ export class FormattedLabelsCache {
 		}
 
 		const str = this._format(value);
-		this._cache.set(value.valueOf(), { string: str, tick: this._usageTick });
-		this._tick2Labels.set(this._usageTick, value.valueOf());
+		this._cache.set(cacheKey, { string: str, tick: this._usageTick });
+		this._tick2Labels.set(this._usageTick, cacheKey);
 		this._actualSize++;
 		this._usageTick++;
 		return str;
diff --git a/src/model/time-scale.ts b/src/model/time-scale.ts
index 0cd0956ea..f39935269 100644
--- a/src/model/time-scale.ts
+++ b/src/model/time-scale.ts
@@ -44,6 +44,16 @@ export interface TimeMark {
 	major: boolean;
 }
 
+export const enum TickMarkType {
+	Year,
+	Month,
+	DayOfMonth,
+	Time,
+	TimeWithSeconds,
+}
+
+export type TickMarkFormatter = (timePoint: TimePoint, tickMarkType: TickMarkType, locale: string) => string;
+
 export interface TimeScaleOptions {
 	rightOffset: number;
 	barSpacing: number;
@@ -55,6 +65,7 @@ export interface TimeScaleOptions {
 	visible: boolean;
 	timeVisible: boolean;
 	secondsVisible: boolean;
+	tickMarkFormatter: TickMarkFormatter;
 }
 
 export class TimeScale {
@@ -646,58 +657,35 @@ export class TimeScale {
 	private _formatLabel(time: TimePoint, span: number): string {
 		let formatter = this._formattedBySpan.get(span);
 		if (formatter === undefined) {
-			formatter = new FormattedLabelsCache((date: Date) => {
-				return this._formatLabelImpl(date, span);
+			formatter = new FormattedLabelsCache((timePoint: TimePoint) => {
+				return this._formatLabelImpl(timePoint, span);
 			});
 
 			this._formattedBySpan.set(span, formatter);
 		}
 
-		if (time.businessDay === undefined) {
-			return formatter.format(new Date(time.timestamp * 1000));
-		} else {
-			return formatter.format(new Date(Date.UTC(time.businessDay.year, time.businessDay.month - 1, time.businessDay.day)));
-		}
+		return formatter.format(time);
 	}
 
-	private _formatLabelImpl(d: Date, span: number): string {
-		const formatOptions: Intl.DateTimeFormatOptions = {};
+	private _formatLabelImpl(timePoint: TimePoint, span: number): string {
+		let tickMarkType: TickMarkType;
 
 		const timeVisible = this._options.timeVisible;
 		if (span < MarkSpanBorder.Minute && timeVisible) {
-			formatOptions.hour12 = false;
-			formatOptions.hour = '2-digit';
-			formatOptions.minute = '2-digit';
-			if (this._options.secondsVisible) {
-				formatOptions.second = '2-digit';
-			}
+			tickMarkType = this._options.secondsVisible ? TickMarkType.TimeWithSeconds : TickMarkType.Time;
 		} else if (span < MarkSpanBorder.Day && timeVisible) {
-			formatOptions.hour12 = false;
-			formatOptions.hour = '2-digit';
-			formatOptions.minute = '2-digit';
+			tickMarkType = TickMarkType.Time;
 		} else if (span < MarkSpanBorder.Week) {
-			formatOptions.day = 'numeric';
+			tickMarkType = TickMarkType.DayOfMonth;
 		} else if (span < MarkSpanBorder.Month) {
-			formatOptions.day = 'numeric';
+			tickMarkType = TickMarkType.DayOfMonth;
 		} else if (span < MarkSpanBorder.Year) {
-			formatOptions.month = 'short';
+			tickMarkType = TickMarkType.Month;
 		} else {
-			formatOptions.year = 'numeric';
-		}
-
-		// from given date we should use only as UTC date or timestamp
-		// but to format as locale date we can convert UTC date to local date
-		const localDateFromUtc = new Date(
-			d.getUTCFullYear(),
-			d.getUTCMonth(),
-			d.getUTCDate(),
-			d.getUTCHours(),
-			d.getUTCMinutes(),
-			d.getUTCSeconds(),
-			d.getUTCMilliseconds()
-		);
+			tickMarkType = TickMarkType.Year;
+		}
 
-		return localDateFromUtc.toLocaleString(this._localizationOptions.locale, formatOptions);
+		return this._options.tickMarkFormatter(timePoint, tickMarkType, this._localizationOptions.locale);
 	}
 
 	private _setVisibleBars(visibleBars: BarsRange | null): void {


Test patch:
diff --git a/tests/e2e/graphics/test-cases/initial-options/tick-marks-formatter.js b/tests/e2e/graphics/test-cases/initial-options/tick-marks-formatter.js
new file mode 100644
index 000000000..c577a8b6e
--- /dev/null
+++ b/tests/e2e/graphics/test-cases/initial-options/tick-marks-formatter.js
@@ -0,0 +1,53 @@
+function dataItem(date, val) {
+	return {
+		value: val,
+		time: new Date(date).getTime() / 1000,
+	};
+}
+
+function getData() {
+	return [
+		dataItem('2019-12-31', 10),
+		dataItem('2020-01-01', 20), // year
+		dataItem('2020-02-01', 30), // month
+		dataItem('2020-02-03', 10), // day
+		dataItem('2020-02-03T01:00:00.000Z', 20), // hour
+		dataItem('2020-02-03T01:01:00.000Z', 67), // time
+		dataItem('2020-02-03T01:01:01.000Z', 99), // seconds
+	];
+}
+
+// eslint-disable-next-line no-unused-vars
+function runTestCase(container) {
+	var chart = LightweightCharts.createChart(container, {
+		timeScale: {
+			timeVisible: true,
+			secondsVisible: true,
+			tickMarkFormatter: function(timePoint, tickMarkType, locale) {
+				var date = new Date(timePoint.timestamp * 1000);
+				switch (tickMarkType) {
+					case LightweightCharts.TickMarkType.Year:
+						return 'Y' + date.getUTCFullYear();
+
+					case LightweightCharts.TickMarkType.Month:
+						return 'M' + (date.getUTCMonth() + 1);
+
+					case LightweightCharts.TickMarkType.DayOfMonth:
+						return 'D' + date.getUTCDate();
+
+					case LightweightCharts.TickMarkType.Time:
+						return 'T' + date.getUTCHours() + ':' + date.getUTCMinutes();
+
+					case LightweightCharts.TickMarkType.TimeWithSeconds:
+						return 'S' + date.getUTCHours() + ':' + date.getUTCMinutes() + ':' + date.getUTCSeconds();
+				}
+
+				throw new Error('unhandled tick mark type ' + tickMarkType);
+			},
+		},
+	});
+
+	var firstSeries = chart.addLineSeries();
+	firstSeries.setData(getData());
+	chart.timeScale().fitContent();
+}
diff --git a/tests/unittests/default-tick-mark-formatter.spec.ts b/tests/unittests/default-tick-mark-formatter.spec.ts
new file mode 100644
index 000000000..7abaf8079
--- /dev/null
+++ b/tests/unittests/default-tick-mark-formatter.spec.ts
@@ -0,0 +1,43 @@
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+
+import { defaultTickMarkFormatter } from '../../src/model/default-tick-mark-formatter';
+import { TimePoint, UTCTimestamp } from '../../src/model/time-data';
+import { TickMarkType } from '../../src/model/time-scale';
+
+function time(dateTimeStr: string): TimePoint {
+	return {
+		timestamp: new Date(dateTimeStr).getTime() / 1000 as UTCTimestamp,
+	};
+}
+
+describe('defaultTickMarkFormatter', () => {
+	it('correct format year', () => {
+		expect(defaultTickMarkFormatter(time('2019-01-01'), TickMarkType.Year, 'en')).to.be.equal('2019');
+		expect(defaultTickMarkFormatter(time('2020-01-01'), TickMarkType.Year, 'en')).to.be.equal('2020');
+	});
+
+	it('correct format month', () => {
+		expect(defaultTickMarkFormatter(time('2019-01-01'), TickMarkType.Month, 'en')).to.be.equal('Jan');
+		expect(defaultTickMarkFormatter(time('2019-12-01'), TickMarkType.Month, 'en')).to.be.equal('Dec');
+
+		// doesn't work in CI :(
+		// expect(defaultTickMarkFormatter(time('2019-01-01'), TickMarkType.Month, 'ru')).to.be.equal('янв.');
+	});
+
+	it('correct format day of month', () => {
+		expect(defaultTickMarkFormatter(time('2019-01-01'), TickMarkType.DayOfMonth, 'en')).to.be.equal('1');
+		expect(defaultTickMarkFormatter(time('2019-01-31'), TickMarkType.DayOfMonth, 'en')).to.be.equal('31');
+	});
+
+	it('correct format time without seconds', () => {
+		expect(defaultTickMarkFormatter(time('2019-01-01T00:10:00.000Z'), TickMarkType.Time, 'en')).to.be.equal('00:10');
+		expect(defaultTickMarkFormatter(time('2019-01-01T17:59:00.000Z'), TickMarkType.Time, 'en')).to.be.equal('17:59');
+		expect(defaultTickMarkFormatter(time('2019-01-01T18:59:59.000Z'), TickMarkType.Time, 'en')).to.be.equal('18:59');
+	});
+
+	it('correct format time with seconds', () => {
+		expect(defaultTickMarkFormatter(time('2019-01-01T00:10:10.000Z'), TickMarkType.TimeWithSeconds, 'en')).to.be.equal('00:10:10');
+		expect(defaultTickMarkFormatter(time('2019-01-01T17:59:44.000Z'), TickMarkType.TimeWithSeconds, 'en')).to.be.equal('17:59:44');
+	});
+});