Repository: tradingview/lightweight-charts (ts)

Issue:
Omit value in candlestick chart
**Lightweight Charts Version:** 
1.2.0 

**Steps/code to reproduce:**

To leave a value empty for a line or a histogram while you set the data with series.setData you can simply omit the value. 
ie. series.setData({ time: '2019-04-11' });

When you set data by omiting the value (high,low,close,open)  for candlestick series it fails due to value being undefined in the following code:
    function ensureDefined(value) {
        if (value === undefined) {
            throw new Error('Value is undefined');
        }
        return value;
    }

Can I do that differently? Is it even possible to omit a value for candlesticks? Is there a workaround?

Pull request:
Added whitespaces support
**Type of PR:** enhancement

**PR checklist:**

- [x] Addresses an existing issue: fixes #209
- [x] Includes tests
- [x] Documentation update

Patch:
diff --git a/docs/area-series.md b/docs/area-series.md
index bfc3b3c9c..ac5f2da46 100644
--- a/docs/area-series.md
+++ b/docs/area-series.md
@@ -28,7 +28,7 @@ areaSeries.setData([
 
 ## Data format
 
-Each area series item should have the following field:
+Each area series item should be a [whitespace](./whitespace-data.md) item or an object with the following fields:
 
 - `time` ([Time](./time.md)) - item time
 - `value` (`number`) - item value
diff --git a/docs/bar-series.md b/docs/bar-series.md
index 62be60d01..bfb062bd9 100644
--- a/docs/bar-series.md
+++ b/docs/bar-series.md
@@ -35,7 +35,7 @@ barSeries.setData([
 
 ## Data format
 
-Each item of the bar series is [OHLC](./ohlc.md) item.
+Each item of the bar series is either an [OHLC](./ohlc.md) or a [whitespace](./whitespace-data.md) item.
 
 ## Customization
 
diff --git a/docs/candlestick-series.md b/docs/candlestick-series.md
index a0ce91838..1bf334742 100644
--- a/docs/candlestick-series.md
+++ b/docs/candlestick-series.md
@@ -31,7 +31,7 @@ candlestickSeries.setData([
 
 ## Data format
 
-Each item of the candlestick series is an [OHLC](./ohlc.md) item.
+Each item of the candlestick series is either an [OHLC](./ohlc.md) or a [whitespace](./whitespace-data.md) item.
 
 ## Customization
 
diff --git a/docs/histogram-series.md b/docs/histogram-series.md
index f83e50d66..58f4825ca 100644
--- a/docs/histogram-series.md
+++ b/docs/histogram-series.md
@@ -32,7 +32,7 @@ histogramSeries.setData([
 
 ## Data format
 
-Each item of the histogram series should include the following field:
+Each item of the histogram series should be a [whitespace](./whitespace-data.md) item or an object with the following fields:
 
 - `time` ([Time](./time.md)) - item time
 - `value` (`number`) - item value
diff --git a/docs/line-series.md b/docs/line-series.md
index 2a063f558..1f7a63f68 100644
--- a/docs/line-series.md
+++ b/docs/line-series.md
@@ -31,7 +31,7 @@ lineSeries.setData([
 
 ## Data format
 
-Each item of the line series should include the following field:
+Each item of the line series should be a [whitespace](./whitespace-data.md) item or an object with the following fields:
 
 - `time` ([Time](./time.md)) - a time of the item
 - `value` (`number`) - a value of the item
diff --git a/docs/whitespace-data.md b/docs/whitespace-data.md
new file mode 100644
index 000000000..e7824be1b
--- /dev/null
+++ b/docs/whitespace-data.md
@@ -0,0 +1,30 @@
+# Whitespace Data
+
+A whitespace - an empty space on the chart, which extends timescale, but doesn't have a value for the series.
+
+A whitespace item is an object with the only one field:
+
+- `time` ([Time](./time.md)) - whitespace time
+
+Example:
+
+```javascript
+// note it might be any type of series here
+const series = chart.addHistogramSeries();
+
+series.setData([
+    { time: "2018-12-01", value: 32.51 },
+    { time: "2018-12-02", value: 31.11 },
+    { time: "2018-12-03", value: 27.02 },
+    { time: "2018-12-04" }, // whitespace
+    { time: "2018-12-05" }, // whitespace
+    { time: "2018-12-06" }, // whitespace
+    { time: "2018-12-07" }, // whitespace
+    { time: "2018-12-08", value: 23.92 },
+    { time: "2018-12-09", value: 22.68 },
+    { time: "2018-12-10", value: 22.67 },
+    { time: "2018-12-11", value: 27.57 },
+    { time: "2018-12-12", value: 24.11 },
+    { time: "2018-12-13", value: 30.74 },
+]);
+```
diff --git a/src/api/chart-api.ts b/src/api/chart-api.ts
index 25edcc457..0eba5fa64 100644
--- a/src/api/chart-api.ts
+++ b/src/api/chart-api.ts
@@ -334,10 +334,7 @@ export class ChartApi implements IChartApi, IPriceScaleApiProvider, DataUpdatesC
 	private _sendUpdateToChart(update: DataUpdateResponse): void {
 		const model = this._chartWidget.model();
 
-		if (update.timeScale !== undefined) {
-			model.updateTimeScale(update.timeScale.points, update.timeScale.baseIndex);
-		}
-
+		model.updateTimeScale(update.timeScale.baseIndex, update.timeScale.points);
 		update.series.forEach((value: SeriesChanges, series: Series) => series.updateData(value.data, value.fullUpdate));
 
 		model.recalculateAllPanes();
diff --git a/src/api/data-consumer.ts b/src/api/data-consumer.ts
index 26f2008b2..02cfc7b64 100644
--- a/src/api/data-consumer.ts
+++ b/src/api/data-consumer.ts
@@ -14,6 +14,13 @@ export function isUTCTimestamp(time: Time): time is UTCTimestamp {
 	return isNumber(time);
 }
 
+/**
+ * Structure describing whitespace data item (data point without series' data)
+ */
+export interface WhitespaceData {
+	time: Time;
+}
+
 /**
  * Structure describing single data item for series of type Line or Area
  */
@@ -45,12 +52,16 @@ export interface BarData {
 	close: number;
 }
 
+export function isWhitespaceData(data: SeriesDataItemTypeMap[SeriesType]): data is WhitespaceData {
+	return (data as Partial<BarData>).open === undefined && (data as Partial<LineData>).value === undefined;
+}
+
 export interface SeriesDataItemTypeMap {
-	Bar: BarData;
-	Candlestick: BarData;
-	Area: LineData;
-	Line: LineData;
-	Histogram: HistogramData;
+	Bar: BarData | WhitespaceData;
+	Candlestick: BarData | WhitespaceData;
+	Area: LineData | WhitespaceData;
+	Line: LineData | WhitespaceData;
+	Histogram: HistogramData | WhitespaceData;
 }
 
 export interface DataUpdatesConsumer<TSeriesType extends SeriesType> {
diff --git a/src/api/data-layer.ts b/src/api/data-layer.ts
index 9603a27f1..e94680d90 100644
--- a/src/api/data-layer.ts
+++ b/src/api/data-layer.ts
@@ -14,7 +14,7 @@ import {
 	SeriesDataItemTypeMap,
 	Time,
 } from './data-consumer';
-import { getSeriesPlotRowCreator } from './get-series-plot-row-creator';
+import { getSeriesPlotRowCreator, isSeriesPlotRow, WhitespacePlotRow } from './get-series-plot-row-creator';
 import { fillWeightsForPoints } from './time-scale-point-weight-generator';
 
 type TimedData = Pick<SeriesDataItemTypeMap[SeriesType], 'time'>;
@@ -105,7 +105,7 @@ export interface TimeScaleChanges {
 	/**
 	 * An array of the new time scale points
 	 */
-	points: readonly TimeScalePoint[];
+	points?: readonly TimeScalePoint[];
 
 	/**
 	 * In terms of time scale "base index" means the latest time scale point with data (there might be whitespaces)
@@ -134,7 +134,7 @@ export interface DataUpdateResponse {
 	/**
 	 * Contains optional time scale points
 	 */
-	timeScale?: TimeScaleChanges;
+	timeScale: TimeScaleChanges;
 }
 
 interface TimePointData {
@@ -143,7 +143,7 @@ interface TimePointData {
 
 	// actually the type of the value should be related to the series' type (generic type)
 	// here, in data layer all data for us is "mutable" by default, but to the chart we provide "readonly" data, to avoid modifying it
-	mapping: Map<Series, Mutable<SeriesPlotRow>>;
+	mapping: Map<Series, Mutable<SeriesPlotRow | WhitespacePlotRow>>;
 }
 
 function createEmptyTimePointData(timePoint: TimePoint): TimePointData {
@@ -174,7 +174,7 @@ export class DataLayer {
 			this._pointDataByTimePoint.forEach((pointData: TimePointData) => pointData.mapping.delete(series));
 		}
 
-		let seriesRows: SeriesPlotRow[] = [];
+		let seriesRows: (SeriesPlotRow | WhitespacePlotRow)[] = [];
 
 		if (data.length !== 0) {
 			convertStringsToBusinessDays(data);
@@ -237,21 +237,29 @@ export class DataLayer {
 		const plotRow = createPlotRow(time, pointDataAtTime.index, data);
 		pointDataAtTime.mapping.set(series, plotRow);
 
-		this._updateLastSeriesRow(series, plotRow);
+		const seriesChanges = this._updateLastSeriesRow(series, plotRow);
 
 		// if point already exist on the time scale - we don't need to make a full update and just make an incremental one
 		if (!affectsTimeScale) {
 			const seriesUpdate = new Map<Series, SeriesChanges>();
-			const seriesData = ensureDefined(this._seriesRowsBySeries.get(series));
-			seriesUpdate.set(series, { data: [seriesData[seriesData.length - 1]], fullUpdate: false });
-			return { series: seriesUpdate };
+			if (seriesChanges !== null) {
+				seriesUpdate.set(series, seriesChanges);
+			}
+
+			return {
+				series: seriesUpdate,
+				timeScale: {
+					// base index might be updated even if no time scale point is changed
+					baseIndex: this._getBaseIndex(),
+				},
+			};
 		}
 
 		// but if we don't have such point on the time scale - we need to generate "full" update (including time scale update)
 		return this._syncIndexesAndApplyChanges(series);
 	}
 
-	private _updateLastSeriesRow(series: Series, plotRow: SeriesPlotRow): void {
+	private _updateLastSeriesRow(series: Series, plotRow: SeriesPlotRow | WhitespacePlotRow): SeriesChanges | null {
 		let seriesData = this._seriesRowsBySeries.get(series);
 		if (seriesData === undefined) {
 			seriesData = [];
@@ -260,18 +268,44 @@ export class DataLayer {
 
 		const lastSeriesRow = seriesData.length !== 0 ? seriesData[seriesData.length - 1] : null;
 
+		let result: SeriesChanges | null = null;
+
 		if (lastSeriesRow === null || plotRow.time.timestamp > lastSeriesRow.time.timestamp) {
-			seriesData.push(plotRow);
+			if (isSeriesPlotRow(plotRow)) {
+				seriesData.push(plotRow);
+
+				result = {
+					fullUpdate: false,
+					data: [plotRow],
+				};
+			}
 		} else {
-			seriesData[seriesData.length - 1] = plotRow;
+			if (isSeriesPlotRow(plotRow)) {
+				seriesData[seriesData.length - 1] = plotRow;
+
+				result = {
+					fullUpdate: false,
+					data: [plotRow],
+				};
+			} else {
+				seriesData.splice(-1, 1);
+
+				// we just removed point from series - needs generate full update
+				result = {
+					fullUpdate: true,
+					data: seriesData,
+				};
+			}
 		}
 
 		this._seriesLastTimePoint.set(series, plotRow.time);
+
+		return result;
 	}
 
-	private _setRowsToSeries(series: Series, seriesRows: SeriesPlotRow[]): void {
+	private _setRowsToSeries(series: Series, seriesRows: (SeriesPlotRow | WhitespacePlotRow)[]): void {
 		if (seriesRows.length !== 0) {
-			this._seriesRowsBySeries.set(series, seriesRows);
+			this._seriesRowsBySeries.set(series, seriesRows.filter(isSeriesPlotRow));
 			this._seriesLastTimePoint.set(series, seriesRows[seriesRows.length - 1].time);
 		} else {
 			this._seriesRowsBySeries.delete(series);
@@ -333,7 +367,7 @@ export class DataLayer {
 			pointData.index = index as TimePointIndex;
 
 			// and then we need to sync indexes for all series
-			pointData.mapping.forEach((seriesRow: Mutable<SeriesPlotRow>) => {
+			pointData.mapping.forEach((seriesRow: Mutable<SeriesPlotRow | WhitespacePlotRow>) => {
 				seriesRow.index = index as TimePointIndex;
 			});
 		}
@@ -346,6 +380,18 @@ export class DataLayer {
 		return firstChangedPointIndex;
 	}
 
+	private _getBaseIndex(): TimePointIndex {
+		let baseIndex = 0 as TimePointIndex;
+
+		this._seriesRowsBySeries.forEach((data: SeriesPlotRow[]) => {
+			if (data.length !== 0) {
+				baseIndex = Math.max(baseIndex, data[data.length - 1].index) as TimePointIndex;
+			}
+		});
+
+		return baseIndex;
+	}
+
 	/**
 	 * Methods syncs indexes (recalculates them applies them to point/series data) between time scale, point data and series point
 	 * and returns generated update for applied change.
@@ -358,26 +404,22 @@ export class DataLayer {
 
 		const firstChangedPointIndex = this._updateTimeScalePoints(newTimeScalePoints);
 
-		const dataUpdateResponse: DataUpdateResponse = { series: new Map() };
+		const dataUpdateResponse: DataUpdateResponse = {
+			series: new Map(),
+			timeScale: {
+				baseIndex: this._getBaseIndex(),
+			},
+		};
 
 		if (firstChangedPointIndex !== -1) {
-			let baseIndex = 0 as TimePointIndex;
-
 			// time scale is changed, so we need to make "full" update for every series
 			// TODO: it's possible to make perf improvements by checking what series has data after firstChangedPointIndex
 			// but let's skip for now
 			this._seriesRowsBySeries.forEach((data: SeriesPlotRow[], s: Series) => {
 				dataUpdateResponse.series.set(s, { data, fullUpdate: true });
-
-				if (data.length !== 0) {
-					baseIndex = Math.max(baseIndex, data[data.length - 1].index) as TimePointIndex;
-				}
 			});
 
-			dataUpdateResponse.timeScale = {
-				points: this._sortedTimePoints,
-				baseIndex,
-			};
+			dataUpdateResponse.timeScale.points = this._sortedTimePoints;
 		} else {
 			const seriesData = this._seriesRowsBySeries.get(series);
 			// if no seriesData found that means that we just removed the series
diff --git a/src/api/get-series-plot-row-creator.ts b/src/api/get-series-plot-row-creator.ts
index 7cb6d8797..8f5f9bfeb 100644
--- a/src/api/get-series-plot-row-creator.ts
+++ b/src/api/get-series-plot-row-creator.ts
@@ -1,8 +1,9 @@
+import { PlotRow } from '../model/plot-data';
 import { SeriesPlotRow } from '../model/series-data';
 import { SeriesType } from '../model/series-options';
 import { TimePoint, TimePointIndex } from '../model/time-data';
 
-import { BarData, HistogramData, LineData, SeriesDataItemTypeMap } from './data-consumer';
+import { BarData, HistogramData, isWhitespaceData, LineData, SeriesDataItemTypeMap } from './data-consumer';
 
 function getLineBasedSeriesPlotRow(time: TimePoint, index: TimePointIndex, item: LineData | HistogramData): Mutable<SeriesPlotRow<'Line' | 'Histogram'>> {
 	const val = item.value;
@@ -15,8 +16,14 @@ function getLineBasedSeriesPlotRow(time: TimePoint, index: TimePointIndex, item:
 	return res;
 }
 
-function getOHLCBasedSeriesPlotRow(time: TimePoint, index: TimePointIndex, bar: BarData): Mutable<SeriesPlotRow> {
-	return { index, time, value: [bar.open, bar.high, bar.low, bar.close] };
+function getOHLCBasedSeriesPlotRow(time: TimePoint, index: TimePointIndex, item: BarData): Mutable<SeriesPlotRow> {
+	return { index, time, value: [item.open, item.high, item.low, item.close] };
+}
+
+export type WhitespacePlotRow = Omit<PlotRow, 'value'>;
+
+export function isSeriesPlotRow(row: SeriesPlotRow | WhitespacePlotRow): row is SeriesPlotRow {
+	return 'value' in row;
 }
 
 // we want to have compile-time checks that the type of the functions is correct
@@ -24,17 +31,27 @@ function getOHLCBasedSeriesPlotRow(time: TimePoint, index: TimePointIndex, bar:
 // so let's use TimedSeriesItemValueFn for shut up the compiler in seriesItemValueFn
 // we need to be sure (and we're sure actually) that stored data has correct type for it's according series object
 type SeriesItemValueFnMap = {
-	[T in keyof SeriesDataItemTypeMap]: (time: TimePoint, index: TimePointIndex, item: SeriesDataItemTypeMap[T]) => Mutable<SeriesPlotRow>;
+	[T in keyof SeriesDataItemTypeMap]: (time: TimePoint, index: TimePointIndex, item: SeriesDataItemTypeMap[T]) => Mutable<SeriesPlotRow | WhitespacePlotRow>;
 };
 
-export type TimedSeriesItemValueFn = (time: TimePoint, index: TimePointIndex, item: SeriesDataItemTypeMap[SeriesType]) => Mutable<SeriesPlotRow>;
+export type TimedSeriesItemValueFn = (time: TimePoint, index: TimePointIndex, item: SeriesDataItemTypeMap[SeriesType]) => Mutable<SeriesPlotRow | WhitespacePlotRow>;
+
+function wrapWhitespaceData(createPlotRowFn: (typeof getLineBasedSeriesPlotRow) | (typeof getOHLCBasedSeriesPlotRow)): TimedSeriesItemValueFn {
+	return (time: TimePoint, index: TimePointIndex, bar: SeriesDataItemTypeMap[SeriesType]) => {
+		if (isWhitespaceData(bar)) {
+			return { time, index };
+		}
+
+		return createPlotRowFn(time, index, bar);
+	};
+}
 
 const seriesPlotRowFnMap: SeriesItemValueFnMap = {
-	Candlestick: getOHLCBasedSeriesPlotRow,
-	Bar: getOHLCBasedSeriesPlotRow,
-	Area: getLineBasedSeriesPlotRow,
-	Histogram: getLineBasedSeriesPlotRow,
-	Line: getLineBasedSeriesPlotRow,
+	Candlestick: wrapWhitespaceData(getOHLCBasedSeriesPlotRow),
+	Bar: wrapWhitespaceData(getOHLCBasedSeriesPlotRow),
+	Area: wrapWhitespaceData(getLineBasedSeriesPlotRow),
+	Histogram: wrapWhitespaceData(getLineBasedSeriesPlotRow),
+	Line: wrapWhitespaceData(getLineBasedSeriesPlotRow),
 };
 
 export function getSeriesPlotRowCreator(seriesType: SeriesType): TimedSeriesItemValueFn {
diff --git a/src/model/chart-model.ts b/src/model/chart-model.ts
index 980b67ad0..b152d6e92 100644
--- a/src/model/chart-model.ts
+++ b/src/model/chart-model.ts
@@ -470,8 +470,10 @@ export class ChartModel implements IDestroyable {
 		}
 	}
 
-	public updateTimeScale(newPoints: readonly TimeScalePoint[], newBaseIndex: TimePointIndex): void {
-		this._timeScale.update(newPoints);
+	public updateTimeScale(newBaseIndex: TimePointIndex, newPoints?: readonly TimeScalePoint[]): void {
+		if (newPoints !== undefined) {
+			this._timeScale.update(newPoints);
+		}
 
 		const currentBaseIndex = this._timeScale.baseIndex();
 		const visibleBars = this._timeScale.visibleStrictRange();


Test patch:
diff --git a/tests/e2e/graphics/test-cases/series/area-with-whitespaces.js b/tests/e2e/graphics/test-cases/series/area-with-whitespaces.js
new file mode 100644
index 000000000..17dabbc52
--- /dev/null
+++ b/tests/e2e/graphics/test-cases/series/area-with-whitespaces.js
@@ -0,0 +1,25 @@
+function generateData() {
+	const res = [];
+	const time = new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0));
+	for (let i = 0; i < 500; ++i) {
+		const item = {
+			time: time.getTime() / 1000,
+		};
+
+		if (i % 3 !== 0) {
+			item.value = i;
+		}
+
+		res.push(item);
+		time.setUTCDate(time.getUTCDate() + 1);
+	}
+	return res;
+}
+
+function runTestCase(container) {
+	const chart = LightweightCharts.createChart(container);
+
+	const mainSeries = chart.addAreaSeries();
+
+	mainSeries.setData(generateData());
+}
diff --git a/tests/e2e/graphics/test-cases/series/bars-with-whitespaces.js b/tests/e2e/graphics/test-cases/series/bars-with-whitespaces.js
new file mode 100644
index 000000000..acd095dd6
--- /dev/null
+++ b/tests/e2e/graphics/test-cases/series/bars-with-whitespaces.js
@@ -0,0 +1,33 @@
+function generateBar(i, target) {
+	const step = (i % 20) / 1000;
+	const base = i / 5;
+	target.open = base * (1 - step);
+	target.high = base * (1 + 2 * step);
+	target.low = base * (1 - 2 * step);
+	target.close = base * (1 + step);
+}
+
+function generateData() {
+	const res = [];
+	const time = new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0));
+	for (let i = 0; i < 500; ++i) {
+		const item = {
+			time: time.getTime() / 1000,
+		};
+		time.setUTCDate(time.getUTCDate() + 1);
+
+		if (i % 3 !== 0) {
+			generateBar(i, item);
+		}
+		res.push(item);
+	}
+	return res;
+}
+
+function runTestCase(container) {
+	const chart = LightweightCharts.createChart(container);
+
+	const mainSeries = chart.addBarSeries();
+
+	mainSeries.setData(generateData());
+}
diff --git a/tests/e2e/graphics/test-cases/series/candlestics-with-whitespaces.js b/tests/e2e/graphics/test-cases/series/candlestics-with-whitespaces.js
new file mode 100644
index 000000000..f737a6db1
--- /dev/null
+++ b/tests/e2e/graphics/test-cases/series/candlestics-with-whitespaces.js
@@ -0,0 +1,33 @@
+function generateCandle(i, target) {
+	const step = (i % 20) / 5000;
+	const base = i / 5;
+	target.open = base * (1 - step);
+	target.high = base * (1 + 2 * step);
+	target.low = base * (1 - 2 * step);
+	target.close = base * (1 + step);
+}
+
+function generateData() {
+	const res = [];
+	const time = new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0));
+	for (let i = 0; i < 500; ++i) {
+		const item = {
+			time: time.getTime() / 1000,
+		};
+		time.setUTCDate(time.getUTCDate() + 1);
+
+		if (i % 3 !== 0) {
+			generateCandle(i, item);
+		}
+		res.push(item);
+	}
+	return res;
+}
+
+function runTestCase(container) {
+	const chart = LightweightCharts.createChart(container);
+
+	const mainSeries = chart.addCandlestickSeries();
+
+	mainSeries.setData(generateData());
+}
diff --git a/tests/e2e/graphics/test-cases/series/histogram-with-whitespaces.js b/tests/e2e/graphics/test-cases/series/histogram-with-whitespaces.js
new file mode 100644
index 000000000..e17d001b3
--- /dev/null
+++ b/tests/e2e/graphics/test-cases/series/histogram-with-whitespaces.js
@@ -0,0 +1,25 @@
+function generateData() {
+	const res = [];
+	const time = new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0));
+	for (let i = 0; i < 500; ++i) {
+		const item = {
+			time: time.getTime() / 1000,
+		};
+
+		if (i % 3 !== 0) {
+			item.value = i;
+		}
+
+		res.push(item);
+		time.setUTCDate(time.getUTCDate() + 1);
+	}
+	return res;
+}
+
+function runTestCase(container) {
+	const chart = LightweightCharts.createChart(container);
+
+	const mainSeries = chart.addHistogramSeries();
+
+	mainSeries.setData(generateData());
+}
diff --git a/tests/e2e/graphics/test-cases/series/line-with-whitespaces.js b/tests/e2e/graphics/test-cases/series/line-with-whitespaces.js
new file mode 100644
index 000000000..fe08019fa
--- /dev/null
+++ b/tests/e2e/graphics/test-cases/series/line-with-whitespaces.js
@@ -0,0 +1,25 @@
+function generateData() {
+	const res = [];
+	const time = new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0));
+	for (let i = 0; i < 500; ++i) {
+		const item = {
+			time: time.getTime() / 1000,
+		};
+
+		if (i % 3 !== 0) {
+			item.value = i;
+		}
+
+		res.push(item);
+		time.setUTCDate(time.getUTCDate() + 1);
+	}
+	return res;
+}
+
+function runTestCase(container) {
+	const chart = LightweightCharts.createChart(container);
+
+	const mainSeries = chart.addLineSeries();
+
+	mainSeries.setData(generateData());
+}
diff --git a/tests/e2e/graphics/test-cases/series/whitespace-updates.js b/tests/e2e/graphics/test-cases/series/whitespace-updates.js
new file mode 100644
index 000000000..9b7b4099d
--- /dev/null
+++ b/tests/e2e/graphics/test-cases/series/whitespace-updates.js
@@ -0,0 +1,17 @@
+function runTestCase(container) {
+	const chart = LightweightCharts.createChart(container);
+
+	const mainSeries = chart.addCandlestickSeries();
+
+	mainSeries.setData([
+		{ time: '2019-01-01', open: 10, high: 25, low: 5, close: 20 },
+		{ time: '2019-01-02', open: 10, high: 25, low: 5, close: 20 },
+		{ time: '2019-01-03', open: 10, high: 25, low: 5, close: 20 },
+		{ time: '2019-01-04', open: 10, high: 25, low: 5, close: 20 },
+		{ time: '2019-01-06', open: 10, high: 25, low: 5, close: 20 },
+	]);
+
+	mainSeries.update({ time: '2019-01-06' }); // replace the latest bar with whitespace
+	mainSeries.update({ time: '2019-01-07' }); // add new whitespace
+	mainSeries.update({ time: '2019-01-07', open: 10, high: 25, low: 5, close: 20 }); // replace the last whitespace with bar
+}
diff --git a/tests/unittests/data-layer.spec.ts b/tests/unittests/data-layer.spec.ts
index dbf4fd4c1..fb136d793 100644
--- a/tests/unittests/data-layer.spec.ts
+++ b/tests/unittests/data-layer.spec.ts
@@ -1,7 +1,7 @@
 import { expect } from 'chai';
 import { describe, it } from 'mocha';
 
-import { BarData, HistogramData, LineData, Time } from '../../src/api/data-consumer';
+import { BarData, HistogramData, LineData, Time, WhitespaceData } from '../../src/api/data-consumer';
 import { convertTime, DataLayer, SeriesChanges, stringToBusinessDay } from '../../src/api/data-layer';
 import { ensureDefined } from '../../src/helpers/assertions';
 import { PlotRowValueIndex } from '../../src/model/plot-data';
@@ -26,6 +26,10 @@ function dataItemAt(time: Time): BarData & LineData & HistogramData {
 	return { time, value: 0, open: 0, high: 0, low: 0, close: 0 };
 }
 
+function whitespaceItemAt(time: Time): WhitespaceData {
+	return { time };
+}
+
 describe('DataLayer', () => {
 	it('should be able to append new series with updating time scale', () => {
 		const dataLayer = new DataLayer();
@@ -35,8 +39,8 @@ describe('DataLayer', () => {
 		const series2 = createSeriesMock();
 
 		const updateResult1 = dataLayer.setSeriesData(series1, [dataItemAt(1000 as UTCTimestamp), dataItemAt(3000 as UTCTimestamp)]);
-		expect(updateResult1.timeScale?.baseIndex).to.be.equal(1 as TimePointIndex);
-		expect(updateResult1.timeScale?.points).to.have.deep.members([
+		expect(updateResult1.timeScale.baseIndex).to.be.equal(1 as TimePointIndex);
+		expect(updateResult1.timeScale.points).to.have.deep.members([
 			{ time: { timestamp: 1000 }, timeWeight: 70 },
 			{ time: { timestamp: 3000 }, timeWeight: 22 },
 		]);
@@ -54,8 +58,8 @@ describe('DataLayer', () => {
 		});
 
 		const updateResult2 = dataLayer.setSeriesData(series2, [dataItemAt(2000 as UTCTimestamp), dataItemAt(4000 as UTCTimestamp)]);
-		expect(updateResult2.timeScale?.baseIndex).to.be.equal(3 as TimePointIndex);
-		expect(updateResult2.timeScale?.points).to.have.deep.members([
+		expect(updateResult2.timeScale.baseIndex).to.be.equal(3 as TimePointIndex);
+		expect(updateResult2.timeScale.points).to.have.deep.members([
 			{ time: { timestamp: 1000 }, timeWeight: 70 },
 			{ time: { timestamp: 2000 }, timeWeight: 22 },
 			{ time: { timestamp: 3000 }, timeWeight: 21 },
@@ -90,7 +94,8 @@ describe('DataLayer', () => {
 
 		dataLayer.setSeriesData(series1, [dataItemAt(1000 as UTCTimestamp), dataItemAt(3000 as UTCTimestamp)]);
 		const updateResult = dataLayer.setSeriesData(series2, [dataItemAt(1000 as UTCTimestamp), dataItemAt(3000 as UTCTimestamp)]);
-		expect(updateResult.timeScale).to.be.equal(undefined);
+		expect(updateResult.timeScale.baseIndex).to.be.equal(1 as TimePointIndex);
+		expect(updateResult.timeScale.points).to.be.equal(undefined);
 		expect(updateResult.series.size).to.be.equal(1);
 
 		const series2Updates = updateResult.series.get(series2);
@@ -119,9 +124,9 @@ describe('DataLayer', () => {
 
 		const updateResult = dataLayer.removeSeries(series3);
 
-		expect(updateResult.timeScale?.baseIndex).to.be.equal(3 as TimePointIndex);
-		expect(updateResult.timeScale?.points.length).to.be.equal(4);
-		expect(updateResult.timeScale?.points).to.have.deep.members([
+		expect(updateResult.timeScale.baseIndex).to.be.equal(3 as TimePointIndex);
+		expect(updateResult.timeScale.points?.length).to.be.equal(4);
+		expect(updateResult.timeScale.points).to.have.deep.members([
 			{ time: { timestamp: 2000 }, timeWeight: 70 },
 			{ time: { timestamp: 3000 }, timeWeight: 21 },
 			{ time: { timestamp: 5000 }, timeWeight: 30 },
@@ -160,7 +165,8 @@ describe('DataLayer', () => {
 
 		const updateResult = dataLayer.removeSeries(series3);
 
-		expect(updateResult.timeScale).to.be.equal(undefined);
+		expect(updateResult.timeScale.points).to.be.equal(undefined);
+		expect(updateResult.timeScale.baseIndex).to.be.equal(3 as TimePointIndex);
 		expect(updateResult.series.size).to.be.equal(1);
 
 		const series3Updates = updateResult.series.get(series3);
@@ -181,8 +187,8 @@ describe('DataLayer', () => {
 
 		// add a new point
 		const updateResult1 = dataLayer.updateSeriesData(series1, dataItemAt(5000 as UTCTimestamp));
-		expect(updateResult1.timeScale?.baseIndex).to.be.equal(4 as TimePointIndex);
-		expect(updateResult1.timeScale?.points).to.have.deep.members([
+		expect(updateResult1.timeScale.baseIndex).to.be.equal(4 as TimePointIndex);
+		expect(updateResult1.timeScale.points).to.have.deep.members([
 			{ time: { timestamp: 1000 }, timeWeight: 70 },
 			{ time: { timestamp: 2000 }, timeWeight: 22 },
 			{ time: { timestamp: 3000 }, timeWeight: 21 },
@@ -202,8 +208,8 @@ describe('DataLayer', () => {
 
 		// add one more point
 		const updateResult2 = dataLayer.updateSeriesData(series2, dataItemAt(6000 as UTCTimestamp));
-		expect(updateResult2.timeScale?.baseIndex).to.be.equal(5 as TimePointIndex);
-		expect(updateResult2.timeScale?.points).to.have.deep.members([
+		expect(updateResult2.timeScale.baseIndex).to.be.equal(5 as TimePointIndex);
+		expect(updateResult2.timeScale.points).to.have.deep.members([
 			{ time: { timestamp: 1000 }, timeWeight: 70 },
 			{ time: { timestamp: 2000 }, timeWeight: 22 },
 			{ time: { timestamp: 3000 }, timeWeight: 21 },
@@ -234,7 +240,8 @@ describe('DataLayer', () => {
 
 		// change the last point of the first series
 		const updateResult1 = dataLayer.updateSeriesData(series1, dataItemAt(4000 as UTCTimestamp));
-		expect(updateResult1.timeScale).to.be.equal(undefined);
+		expect(updateResult1.timeScale.baseIndex).to.be.equal(2 as TimePointIndex);
+		expect(updateResult1.timeScale.points).to.be.equal(undefined);
 		expect(updateResult1.series.size).to.be.equal(1);
 		updateResult1.series.forEach((updatePacket: SeriesChanges, series: Series) => {
 			expect(series).to.be.equal(series1);
@@ -246,7 +253,8 @@ describe('DataLayer', () => {
 
 		// change the last point of the second series
 		const updateResult2 = dataLayer.updateSeriesData(series2, dataItemAt(4000 as UTCTimestamp));
-		expect(updateResult2.timeScale).to.be.equal(undefined);
+		expect(updateResult2.timeScale.baseIndex).to.be.equal(2 as TimePointIndex);
+		expect(updateResult2.timeScale.points).to.be.equal(undefined);
 		expect(updateResult2.series.size).to.be.equal(1);
 		updateResult2.series.forEach((updatePacket: SeriesChanges, series: Series) => {
 			expect(series).to.be.equal(series2);
@@ -269,8 +277,8 @@ describe('DataLayer', () => {
 
 		// add a new point in the end of one series but not in the end of all points
 		const updateResult = dataLayer.updateSeriesData(series2, dataItemAt(4000 as UTCTimestamp));
-		expect(updateResult.timeScale?.baseIndex).to.be.equal(4 as TimePointIndex);
-		expect(updateResult.timeScale?.points).to.have.deep.members([
+		expect(updateResult.timeScale.baseIndex).to.be.equal(4 as TimePointIndex);
+		expect(updateResult.timeScale.points).to.have.deep.members([
 			{ time: { timestamp: 2000 }, timeWeight: 22 },
 			{ time: { timestamp: 3000 }, timeWeight: 21 },
 			{ time: { timestamp: 4000 }, timeWeight: 30 },
@@ -309,7 +317,7 @@ describe('DataLayer', () => {
 		const date2: BusinessDay = { day: 2, month: 10, year: 2019 };
 		const updateResult1 = dataLayer.setSeriesData(series1, [dataItemAt(date1), dataItemAt(date2)]);
 
-		expect(updateResult1.timeScale?.baseIndex).to.be.equal(1 as TimePointIndex);
+		expect(updateResult1.timeScale.baseIndex).to.be.equal(1 as TimePointIndex);
 
 		const timePoint1: TimePoint = {
 			businessDay: {
@@ -328,7 +336,7 @@ describe('DataLayer', () => {
 			timestamp: 1569974400 as UTCTimestamp,
 		};
 
-		expect(updateResult1.timeScale?.points).to.have.deep.members([
+		expect(updateResult1.timeScale.points).to.have.deep.members([
 			{ time: timePoint1, timeWeight: 60 },
 			{ time: timePoint2, timeWeight: 50 },
 		]);
@@ -427,8 +435,8 @@ describe('DataLayer', () => {
 			dataLayer.setSeriesData(series2, generateData(4, 1));
 
 			const updateResult = dataLayer.removeSeries(series1);
-			expect(updateResult.timeScale).not.to.be.equal(undefined);
-			expect(updateResult.timeScale?.baseIndex).to.be.equal(barsCount - 1);
+			expect(updateResult.timeScale.points).not.to.be.equal(undefined);
+			expect(updateResult.timeScale.baseIndex).to.be.equal(barsCount - 1);
 
 			expect(updateResult.series.size).to.be.equal(1);
 
@@ -448,8 +456,8 @@ describe('DataLayer', () => {
 			dataLayer.setSeriesData(series2, generateData(4, 1));
 
 			const updateResult = dataLayer.removeSeries(series2);
-			expect(updateResult.timeScale).not.to.be.equal(undefined);
-			expect(updateResult.timeScale?.baseIndex).to.be.equal(barsCount - 1);
+			expect(updateResult.timeScale.points).not.to.be.equal(undefined);
+			expect(updateResult.timeScale.baseIndex).to.be.equal(barsCount - 1);
 
 			expect(updateResult.series.size).to.be.equal(1);
 
@@ -459,4 +467,233 @@ describe('DataLayer', () => {
 			expect(series1Update?.data.length).to.be.equal(barsCount);
 		});
 	});
+
+	describe('whitespaces', () => {
+		it('should allow to set whitespaces to series', () => {
+			const dataLayer = new DataLayer();
+
+			const series = createSeriesMock();
+
+			const updateResult = dataLayer.setSeriesData(series, [
+				dataItemAt(1000 as UTCTimestamp),
+				whitespaceItemAt(2000 as UTCTimestamp),
+				whitespaceItemAt(3000 as UTCTimestamp),
+				dataItemAt(4000 as UTCTimestamp),
+			]);
+
+			expect(updateResult.timeScale.baseIndex).to.be.equal(3 as TimePointIndex);
+			expect(updateResult.timeScale.points).to.have.deep.members([
+				{ time: { timestamp: 1000 }, timeWeight: 21 },
+				{ time: { timestamp: 2000 }, timeWeight: 22 },
+				{ time: { timestamp: 3000 }, timeWeight: 21 },
+				{ time: { timestamp: 4000 }, timeWeight: 30 },
+			]);
+
+			expect(updateResult.series.size).to.be.equal(1);
+
+			const seriesUpdate = updateResult.series.get(series) as SeriesChanges;
+			expect(seriesUpdate).not.to.be.equal(undefined);
+			expect(seriesUpdate.fullUpdate).to.be.equal(true);
+			expect(seriesUpdate.data.length).to.be.equal(2);
+
+			expect(seriesUpdate.data[0].index).to.be.equal(0 as TimePointIndex);
+			expect(seriesUpdate.data[0].time.timestamp).to.be.equal(1000 as UTCTimestamp);
+
+			expect(seriesUpdate.data[1].index).to.be.equal(3 as TimePointIndex);
+			expect(seriesUpdate.data[1].time.timestamp).to.be.equal(4000 as UTCTimestamp);
+		});
+
+		it('should allow to append whitespace via update', () => {
+			const dataLayer = new DataLayer();
+
+			const series = createSeriesMock();
+
+			dataLayer.setSeriesData(series, [
+				dataItemAt(1000 as UTCTimestamp),
+				dataItemAt(4000 as UTCTimestamp),
+			]);
+
+			const updateResult = dataLayer.updateSeriesData(series, whitespaceItemAt(5000 as UTCTimestamp));
+
+			expect(updateResult.timeScale.baseIndex).to.be.equal(1 as TimePointIndex);
+			expect(updateResult.timeScale.points).to.have.deep.members([
+				{ time: { timestamp: 1000 }, timeWeight: 70 },
+				{ time: { timestamp: 4000 }, timeWeight: 30 },
+				{ time: { timestamp: 5000 }, timeWeight: 21 },
+			]);
+
+			expect(updateResult.series.size).to.be.equal(1);
+
+			const seriesUpdate = updateResult.series.get(series) as SeriesChanges;
+			expect(seriesUpdate).not.to.be.equal(undefined);
+			expect(seriesUpdate.fullUpdate).to.be.equal(true);
+			expect(seriesUpdate.data.length).to.be.equal(2);
+
+			expect(seriesUpdate.data[0].index).to.be.equal(0 as TimePointIndex);
+			expect(seriesUpdate.data[0].time.timestamp).to.be.equal(1000 as UTCTimestamp);
+
+			expect(seriesUpdate.data[1].index).to.be.equal(1 as TimePointIndex);
+			expect(seriesUpdate.data[1].time.timestamp).to.be.equal(4000 as UTCTimestamp);
+		});
+
+		it('should allow to replace whitespace with bar', () => {
+			const dataLayer = new DataLayer();
+
+			const series = createSeriesMock();
+
+			dataLayer.setSeriesData(series, [
+				dataItemAt(1000 as UTCTimestamp),
+				dataItemAt(4000 as UTCTimestamp),
+				whitespaceItemAt(5000 as UTCTimestamp),
+			]);
+
+			const updateResult = dataLayer.updateSeriesData(series, dataItemAt(5000 as UTCTimestamp));
+
+			expect(updateResult.timeScale.baseIndex).to.be.equal(2 as TimePointIndex);
+			expect(updateResult.timeScale.points).to.be.equal(undefined);
+
+			expect(updateResult.series.size).to.be.equal(1);
+
+			const seriesUpdate = updateResult.series.get(series) as SeriesChanges;
+			expect(seriesUpdate).not.to.be.equal(undefined);
+			expect(seriesUpdate.fullUpdate).to.be.equal(false);
+			expect(seriesUpdate.data.length).to.be.equal(1);
+
+			expect(seriesUpdate.data[0].index).to.be.equal(2 as TimePointIndex);
+			expect(seriesUpdate.data[0].time.timestamp).to.be.equal(5000 as UTCTimestamp);
+		});
+
+		it('should allow to replace bar with whitespace', () => {
+			const dataLayer = new DataLayer();
+
+			const series = createSeriesMock();
+
+			dataLayer.setSeriesData(series, [
+				dataItemAt(1000 as UTCTimestamp),
+				dataItemAt(4000 as UTCTimestamp),
+				dataItemAt(5000 as UTCTimestamp),
+			]);
+
+			const updateResult = dataLayer.updateSeriesData(series, whitespaceItemAt(5000 as UTCTimestamp));
+
+			expect(updateResult.timeScale.baseIndex).to.be.equal(1 as TimePointIndex);
+			expect(updateResult.timeScale.points).to.be.equal(undefined);
+
+			expect(updateResult.series.size).to.be.equal(1);
+
+			const seriesUpdate = updateResult.series.get(series) as SeriesChanges;
+			expect(seriesUpdate).not.to.be.equal(undefined);
+			expect(seriesUpdate.fullUpdate).to.be.equal(true);
+			expect(seriesUpdate.data.length).to.be.equal(2);
+
+			expect(seriesUpdate.data[0].index).to.be.equal(0 as TimePointIndex);
+			expect(seriesUpdate.data[0].time.timestamp).to.be.equal(1000 as UTCTimestamp);
+
+			expect(seriesUpdate.data[1].index).to.be.equal(1 as TimePointIndex);
+			expect(seriesUpdate.data[1].time.timestamp).to.be.equal(4000 as UTCTimestamp);
+		});
+
+		it('should generate full update if whitespace extends timescale', () => {
+			const dataLayer = new DataLayer();
+
+			const series1 = createSeriesMock();
+			const series2 = createSeriesMock();
+
+			dataLayer.setSeriesData(series1, [
+				dataItemAt(1000 as UTCTimestamp),
+				dataItemAt(5000 as UTCTimestamp),
+			]);
+
+			dataLayer.setSeriesData(series2, [
+				dataItemAt(2000 as UTCTimestamp),
+				dataItemAt(3000 as UTCTimestamp),
+			]);
+
+			const updateResult = dataLayer.updateSeriesData(series2, whitespaceItemAt(4000 as UTCTimestamp));
+
+			expect(updateResult.timeScale.baseIndex).to.be.equal(4 as TimePointIndex);
+			expect(updateResult.timeScale.points).to.have.deep.members([
+				{ time: { timestamp: 1000 }, timeWeight: 70 },
+				{ time: { timestamp: 2000 }, timeWeight: 22 },
+				{ time: { timestamp: 3000 }, timeWeight: 21 },
+				{ time: { timestamp: 4000 }, timeWeight: 30 },
+				{ time: { timestamp: 5000 }, timeWeight: 21 },
+			]);
+
+			expect(updateResult.series.size).to.be.equal(2);
+
+			updateResult.series.forEach((seriesUpdate: SeriesChanges, series: Series) => {
+				expect(seriesUpdate.fullUpdate).to.be.equal(true);
+				expect(seriesUpdate.data.length).to.be.equal(2);
+
+				if (series === series1) {
+					expect(seriesUpdate.data[0].index).to.be.equal(0 as TimePointIndex);
+					expect(seriesUpdate.data[0].time.timestamp).to.be.equal(1000 as UTCTimestamp);
+
+					expect(seriesUpdate.data[1].index).to.be.equal(4 as TimePointIndex);
+					expect(seriesUpdate.data[1].time.timestamp).to.be.equal(5000 as UTCTimestamp);
+				} else {
+					expect(seriesUpdate.data[0].index).to.be.equal(1 as TimePointIndex);
+					expect(seriesUpdate.data[0].time.timestamp).to.be.equal(2000 as UTCTimestamp);
+
+					expect(seriesUpdate.data[1].index).to.be.equal(2 as TimePointIndex);
+					expect(seriesUpdate.data[1].time.timestamp).to.be.equal(3000 as UTCTimestamp);
+				}
+			});
+		});
+
+		it('should remove old whitespaces while setting new data', () => {
+			const dataLayer = new DataLayer();
+
+			const series1 = createSeriesMock();
+			const series2 = createSeriesMock();
+
+			dataLayer.setSeriesData(series1, [
+				dataItemAt(1000 as UTCTimestamp),
+				dataItemAt(5000 as UTCTimestamp),
+			]);
+
+			dataLayer.setSeriesData(series2, [
+				dataItemAt(2000 as UTCTimestamp),
+				dataItemAt(3000 as UTCTimestamp),
+				whitespaceItemAt(4000 as UTCTimestamp),
+			]);
+
+			const updateResult = dataLayer.setSeriesData(series2, [
+				dataItemAt(2000 as UTCTimestamp),
+				dataItemAt(3000 as UTCTimestamp),
+				whitespaceItemAt(6000 as UTCTimestamp),
+			]);
+
+			expect(updateResult.timeScale.baseIndex).to.be.equal(3 as TimePointIndex);
+			expect(updateResult.timeScale.points).to.have.deep.members([
+				{ time: { timestamp: 1000 }, timeWeight: 70 },
+				{ time: { timestamp: 2000 }, timeWeight: 22 },
+				{ time: { timestamp: 3000 }, timeWeight: 21 },
+				{ time: { timestamp: 5000 }, timeWeight: 30 },
+				{ time: { timestamp: 6000 }, timeWeight: 22 },
+			]);
+
+			expect(updateResult.series.size).to.be.equal(2);
+
+			updateResult.series.forEach((seriesUpdate: SeriesChanges, series: Series) => {
+				expect(seriesUpdate.fullUpdate).to.be.equal(true);
+				expect(seriesUpdate.data.length).to.be.equal(2);
+
+				if (series === series1) {
+					expect(seriesUpdate.data[0].index).to.be.equal(0 as TimePointIndex);
+					expect(seriesUpdate.data[0].time.timestamp).to.be.equal(1000 as UTCTimestamp);
+
+					expect(seriesUpdate.data[1].index).to.be.equal(3 as TimePointIndex);
+					expect(seriesUpdate.data[1].time.timestamp).to.be.equal(5000 as UTCTimestamp);
+				} else {
+					expect(seriesUpdate.data[0].index).to.be.equal(1 as TimePointIndex);
+					expect(seriesUpdate.data[0].time.timestamp).to.be.equal(2000 as UTCTimestamp);
+
+					expect(seriesUpdate.data[1].index).to.be.equal(2 as TimePointIndex);
+					expect(seriesUpdate.data[1].time.timestamp).to.be.equal(3000 as UTCTimestamp);
+				}
+			});
+		});
+	});
 });