Repository: tradingview/lightweight-charts (ts)

Issue:
`to` date of getVisibleRange contains partially visible data item and it's impossible to hover it
**Lightweight Charts Version:** 3.2.0

**Steps/code to reproduce:**

1. Open https://jsfiddle.net/uwzkty5p/
1. Hover at rightmost data point

**Actual behavior:**

Crosshair shows it's 19 Nov 2020, but timeScale.getVisibleRange().to returns 20 Nov 2020.

**Expected behavior:**

It should be possible to hover the rightmost visible point, or visible range should not include "invisible" (or partially visible) points


Pull request:
Fixed inverse of indexToCoordinate and coordinateToIndex methods of TimeScale
**Type of PR:** bugfix

**PR checklist:**

- [x] Addresses an existing issue: fixes #624
- [x] Includes tests
- [ ] Documentation update

Patch:
diff --git a/src/model/time-scale.ts b/src/model/time-scale.ts
index cd010a567..9ad9d277d 100644
--- a/src/model/time-scale.ts
+++ b/src/model/time-scale.ts
@@ -224,11 +224,9 @@ export class TimeScale {
 	}
 
 	public logicalRangeForTimeRange(range: TimePointsRange): LogicalRange {
-		const timeScale = this._model.timeScale();
-
 		return {
-			from: ensureNotNull(timeScale.timeToIndex(range.from, true)) as number as Logical,
-			to: ensureNotNull(timeScale.timeToIndex(range.to, true)) as number as Logical,
+			from: ensureNotNull(this.timeToIndex(range.from, true)) as number as Logical,
+			to: ensureNotNull(this.timeToIndex(range.to, true)) as number as Logical,
 		};
 	}
 
@@ -288,7 +286,7 @@ export class TimeScale {
 
 		const baseIndex = this.baseIndex();
 		const deltaFromRight = baseIndex + this._rightOffset - index;
-		const coordinate = this._width - (deltaFromRight + 0.5) * this._barSpacing;
+		const coordinate = this._width - (deltaFromRight + 0.5) * this._barSpacing - 1;
 		return coordinate as Coordinate;
 	}
 
@@ -300,7 +298,7 @@ export class TimeScale {
 		for (let i = indexFrom; i < indexTo; i++) {
 			const index = points[i].time;
 			const deltaFromRight = baseIndex + this._rightOffset - index;
-			const coordinate = this._width - (deltaFromRight + 0.5) * this._barSpacing;
+			const coordinate = this._width - (deltaFromRight + 0.5) * this._barSpacing - 1;
 			points[i].x = coordinate as Coordinate;
 		}
 	}
@@ -605,7 +603,7 @@ export class TimeScale {
 	}
 
 	private _rightOffsetForCoordinate(x: Coordinate): number {
-		return (this._width + 1 - x) / this._barSpacing;
+		return (this._width - 1 - x) / this._barSpacing;
 	}
 
 	private _coordinateToFloatIndex(x: Coordinate): number {


Test patch:
diff --git a/tests/unittests/time-scale.spec.ts b/tests/unittests/time-scale.spec.ts
new file mode 100644
index 000000000..53c47ed84
--- /dev/null
+++ b/tests/unittests/time-scale.spec.ts
@@ -0,0 +1,68 @@
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+
+import { timeScaleOptionsDefaults } from '../../src/api/options/time-scale-options-defaults';
+import { ChartModel } from '../../src/model/chart-model';
+import { Coordinate } from '../../src/model/coordinate';
+import { LocalizationOptions } from '../../src/model/localization-options';
+import {
+	TimePointIndex,
+	TimeScalePoint,
+	UTCTimestamp,
+} from '../../src/model/time-data';
+import { TimeScale } from '../../src/model/time-scale';
+
+function chartModelMock(): ChartModel {
+	// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+	return {
+		recalculateAllPanes: () => {},
+		lightUpdate: () => {},
+	} as ChartModel;
+}
+
+function tsUpdate(to: number): Parameters<TimeScale['update']> {
+	const points: TimeScalePoint[] = [];
+
+	const startIndex = 0;
+	for (let i = startIndex; i <= to; ++i) {
+		points.push({ time: { timestamp: i as UTCTimestamp }, timeWeight: 20 });
+	}
+
+	return [points];
+}
+
+describe('TimeScale', () => {
+	// eslint-disable-next-line @typescript-eslint/no-explicit-any
+	const fakeLocalizationOptions: LocalizationOptions = {} as any;
+
+	it('indexToCoordinate and coordinateToIndex inverse', () => {
+		const lastIndex = 499 as TimePointIndex;
+
+		const ts = new TimeScale(chartModelMock(), { ...timeScaleOptionsDefaults, barSpacing: 1, rightOffset: 0 }, fakeLocalizationOptions);
+		ts.setWidth(500);
+		ts.update(...tsUpdate(lastIndex));
+		ts.setBaseIndex(lastIndex);
+
+		expect(ts.indexToCoordinate(ts.coordinateToIndex(499.5 as Coordinate))).to.be.equal(499.5 as Coordinate);
+	});
+
+	it('all *ToCoordinate functions should return same coordinate for the same index', () => {
+		const lastIndex = 499 as TimePointIndex;
+
+		const ts = new TimeScale(chartModelMock(), { ...timeScaleOptionsDefaults, barSpacing: 1, rightOffset: 0 }, fakeLocalizationOptions);
+		ts.setWidth(500);
+		ts.update(...tsUpdate(lastIndex));
+		ts.setBaseIndex(lastIndex);
+
+		const index = 1 as TimePointIndex;
+		const expectedValue = 0.5 as Coordinate;
+
+		expect(ts.indexToCoordinate(index)).to.be.equal(expectedValue, 'indexToCoordinate');
+
+		{
+			const indexes = [{ time: index, x: 0 as Coordinate }];
+			ts.indexesToCoordinates(indexes);
+			expect(indexes[0].x).to.be.equal(expectedValue, 'indexesToCoordinates');
+		}
+	});
+});