///
import { lowerBound } from '../helpers/algorithms';
import { ensureDefined } from '../helpers/assertions';
import { Mutable } from '../helpers/mutable';
import { SeriesDataItemTypeMap } from './data-consumer';
import { getSeriesPlotRowCreator, isSeriesPlotRow, WhitespacePlotRow } from './get-series-plot-row-creator';
import { IHorzScaleBehavior, InternalHorzScaleItem, InternalHorzScaleItemKey } from './ihorz-scale-behavior';
import { Series, SeriesUpdateInfo } from './series';
import { SeriesPlotRow } from './series-data';
import { SeriesType } from './series-options';
import {
TickMarkWeightValue,
TimePointIndex,
TimeScalePoint,
} from './time-data';
export type TimedData = Pick[SeriesType], 'time'>;
export interface TimeScaleChanges {
/**
* An index of the first changed time scale point by any type of change (time, weight, etc)
*/
firstChangedPointIndex?: TimePointIndex;
/**
* An array of the new time scale points
*/
points?: readonly TimeScalePoint[];
/**
* In terms of time scale "base index" means the latest time scale point with data (there might be whitespaces)
*/
baseIndex: TimePointIndex | null;
}
export interface SeriesChanges {
/**
* Data to be merged into series' plot list
*/
data: readonly SeriesPlotRow[];
/**
* Additional info about this change
*/
info?: SeriesUpdateInfo;
}
export interface DataUpdateResponse {
/**
* Contains updates for all _changed_ series (if series data doesn't changed then it will not be here)
*/
series: Map, SeriesChanges>;
/**
* Contains optional time scale points
*/
timeScale: TimeScaleChanges;
}
interface TimePointData {
index: TimePointIndex;
timePoint: InternalHorzScaleItem;
// 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, Mutable | WhitespacePlotRow>>;
}
export interface InternalTimeScalePoint extends Mutable {
pointData: TimePointData;
}
function createEmptyTimePointData(timePoint: InternalHorzScaleItem): TimePointData {
return { index: 0 as TimePointIndex, mapping: new Map(), timePoint };
}
interface SeriesRowsFirstAndLastTime {
firstTime: InternalHorzScaleItemKey;
lastTime: InternalHorzScaleItemKey;
}
function seriesRowsFirstAndLastTime(seriesRows: SeriesPlotRow[] | undefined, bh: IHorzScaleBehavior): SeriesRowsFirstAndLastTime | undefined {
if (seriesRows === undefined || seriesRows.length === 0) {
return undefined;
}
return {
firstTime: bh.key(seriesRows[0].time),
lastTime: bh.key(seriesRows[seriesRows.length - 1].time),
};
}
function seriesUpdateInfo(seriesRows: SeriesPlotRow[] | undefined, prevSeriesRows: SeriesPlotRow[] | undefined, bh: IHorzScaleBehavior): SeriesUpdateInfo | undefined {
const firstAndLastTime = seriesRowsFirstAndLastTime(seriesRows, bh);
const prevFirstAndLastTime = seriesRowsFirstAndLastTime(prevSeriesRows, bh);
if (firstAndLastTime !== undefined && prevFirstAndLastTime !== undefined) {
return {
historicalUpdate: false,
lastBarUpdatedOrNewBarsAddedToTheRight:
firstAndLastTime.lastTime >= prevFirstAndLastTime.lastTime &&
firstAndLastTime.firstTime >= prevFirstAndLastTime.firstTime,
};
}
return undefined;
}
function timeScalePointTime(mergedPointData: Map, SeriesPlotRow | WhitespacePlotRow>): HorzScaleItem {
let result: HorzScaleItem | undefined;
mergedPointData.forEach((v: SeriesPlotRow | WhitespacePlotRow) => {
if (result === undefined) {
result = v.originalTime as HorzScaleItem;
}
});
return ensureDefined(result);
}
function saveOriginalTime(data: SeriesDataItemWithOriginalTime): void {
if (data.originalTime === undefined) {
data.originalTime = data.time;
}
}
type SeriesDataItemWithOriginalTime = SeriesDataItemTypeMap[TSeriesType] & {
originalTime: HorzScaleItem;
};
export class DataLayer {
// note that _pointDataByTimePoint and _seriesRowsBySeries shares THE SAME objects in their values between each other
// it's just different kind of maps to make usages/perf better
private _pointDataByTimePoint: Map = new Map();
private _seriesRowsBySeries: Map, SeriesPlotRow[]> = new Map();
private _seriesLastTimePoint: Map, InternalHorzScaleItem> = new Map();
// this is kind of "dest" values (in opposite to "source" ones) - we don't need to modify it manually, the only by calling _updateTimeScalePoints or updateSeriesData methods
private _sortedTimePoints: readonly InternalTimeScalePoint[] = [];
private readonly _horzScaleBehavior: IHorzScaleBehavior;
public constructor(horzScaleBehavior: IHorzScaleBehavior) {
this._horzScaleBehavior = horzScaleBehavior;
}
public destroy(): void {
this._pointDataByTimePoint.clear();
this._seriesRowsBySeries.clear();
this._seriesLastTimePoint.clear();
this._sortedTimePoints = [];
}
public setSeriesData(series: Series, data: SeriesDataItemTypeMap[TSeriesType][]): DataUpdateResponse {
let needCleanupPoints = this._pointDataByTimePoint.size !== 0;
let isTimeScaleAffected = false;
// save previous series rows data before it's replaced inside this._setRowsToSeries
const prevSeriesRows = this._seriesRowsBySeries.get(series);
if (prevSeriesRows !== undefined) {
if (this._seriesRowsBySeries.size === 1) {
needCleanupPoints = false;
isTimeScaleAffected = true;
// perf optimization - if there is only 1 series, then we can just clear and fill everything from scratch
this._pointDataByTimePoint.clear();
} else {
// perf optimization - actually we have to use this._pointDataByTimePoint for going through here
// but as soon as this._sortedTimePoints is just a different form of _pointDataByTimePoint we can use it as well
for (const point of this._sortedTimePoints) {
if (point.pointData.mapping.delete(series)) {
isTimeScaleAffected = true;
}
}
}
}
let seriesRows: (SeriesPlotRow | WhitespacePlotRow)[] = [];
if (data.length !== 0) {
const originalTimes = data.map((d: SeriesDataItemTypeMap[TSeriesType]) => d.time);
const timeConverter = this._horzScaleBehavior.createConverterToInternalObj(data);
const createPlotRow = getSeriesPlotRowCreator(series.seriesType());
const dataToPlotRow = series.customSeriesPlotValuesBuilder();
const customWhitespaceChecker = series.customSeriesWhitespaceCheck();
seriesRows = data.map((item: SeriesDataItemTypeMap[TSeriesType], index: number) => {
const time = timeConverter(item.time);
const horzItemKey = this._horzScaleBehavior.key(time);
let timePointData = this._pointDataByTimePoint.get(horzItemKey);
if (timePointData === undefined) {
// the indexes will be sync later
timePointData = createEmptyTimePointData(time);
this._pointDataByTimePoint.set(horzItemKey, timePointData);
isTimeScaleAffected = true;
}
const row = createPlotRow(time, timePointData.index, item, originalTimes[index], dataToPlotRow, customWhitespaceChecker);
timePointData.mapping.set(series, row);
return row;
});
}
if (needCleanupPoints) {
// we deleted the old data from mapping and added the new ones
// so there might be empty points now, let's remove them first
this._cleanupPointsData();
}
this._setRowsToSeries(series, seriesRows);
let firstChangedPointIndex = -1;
if (isTimeScaleAffected) {
// then generate the time scale points
// timeWeight will be updates in _updateTimeScalePoints later
const newTimeScalePoints: InternalTimeScalePoint[] = [];
this._pointDataByTimePoint.forEach((pointData: TimePointData) => {
newTimeScalePoints.push({
timeWeight: 0 as TickMarkWeightValue,
time: pointData.timePoint,
pointData,
originalTime: timeScalePointTime(pointData.mapping),
});
});
newTimeScalePoints.sort((t1: InternalTimeScalePoint, t2: InternalTimeScalePoint) => this._horzScaleBehavior.key(t1.time) - this._horzScaleBehavior.key(t2.time));
firstChangedPointIndex = this._replaceTimeScalePoints(newTimeScalePoints);
}
return this._getUpdateResponse(
series,
firstChangedPointIndex,
seriesUpdateInfo(this._seriesRowsBySeries.get(series), prevSeriesRows, this._horzScaleBehavior)
);
}
public removeSeries(series: Series): DataUpdateResponse {
return this.setSeriesData(series, []);
}
// eslint-disable-next-line complexity
public updateSeriesData(series: Series, data: SeriesDataItemTypeMap[TSeriesType], historicalUpdate: boolean): DataUpdateResponse {
// check if conflation is enabled and this is a historical update
if (historicalUpdate && series.isConflationEnabled()) {
throw new Error('Historical updates are not supported when conflation is enabled. Conflation requires data to be processed in order.');
}
const extendedData = data as SeriesDataItemWithOriginalTime;
saveOriginalTime(extendedData);
// convertStringToBusinessDay(data);
this._horzScaleBehavior.preprocessData(data);
const timeConverter = this._horzScaleBehavior.createConverterToInternalObj([data]);
const time = timeConverter(data.time);
const lastSeriesTime = this._seriesLastTimePoint.get(series);
if (!historicalUpdate && lastSeriesTime !== undefined && this._horzScaleBehavior.key(time) < this._horzScaleBehavior.key(lastSeriesTime)) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
throw new Error(`Cannot update oldest data, last time=${lastSeriesTime}, new time=${time}`);
}
let pointDataAtTime = this._pointDataByTimePoint.get(this._horzScaleBehavior.key(time));
if (historicalUpdate && pointDataAtTime === undefined) {
throw new Error('Cannot update non-existing data point when historicalUpdate is true');
}
// if no point data found for the new data item
// that means that we need to update scale
const affectsTimeScale = pointDataAtTime === undefined;
if (pointDataAtTime === undefined) {
// the indexes will be sync later
pointDataAtTime = createEmptyTimePointData(time);
this._pointDataByTimePoint.set(this._horzScaleBehavior.key(time), pointDataAtTime);
}
const createPlotRow = getSeriesPlotRowCreator(series.seriesType());
const dataToPlotRow = series.customSeriesPlotValuesBuilder();
const customWhitespaceChecker = series.customSeriesWhitespaceCheck();
const plotRow = createPlotRow(time, pointDataAtTime.index, data, extendedData.originalTime, dataToPlotRow, customWhitespaceChecker);
// For efficient conflation updates, check if this is just updating the last bar
const isLastBarUpdate = !historicalUpdate &&
!affectsTimeScale &&
lastSeriesTime !== undefined &&
this._horzScaleBehavior.key(time) === this._horzScaleBehavior.key(lastSeriesTime);
pointDataAtTime.mapping.set(series, plotRow);
if (historicalUpdate) {
this._updateHistoricalSeriesRow(series, plotRow, pointDataAtTime.index);
} else if (isLastBarUpdate && series.isConflationEnabled() && isSeriesPlotRow(plotRow)) {
series.updateLastConflatedChunk(plotRow as SeriesPlotRow);
this._updateLastSeriesRow(series, plotRow);
} else {
this._updateLastSeriesRow(series, plotRow);
}
const info: SeriesUpdateInfo = {
lastBarUpdatedOrNewBarsAddedToTheRight: isSeriesPlotRow(plotRow),
historicalUpdate,
};
// 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) {
return this._getUpdateResponse(series, -1, info);
}
const newPoint: InternalTimeScalePoint = {
timeWeight: 0 as TickMarkWeightValue,
time: pointDataAtTime.timePoint,
pointData: pointDataAtTime,
originalTime: timeScalePointTime(pointDataAtTime.mapping),
};
const insertIndex = lowerBound(this._sortedTimePoints, this._horzScaleBehavior.key(newPoint.time), (a: InternalTimeScalePoint, b: number) => this._horzScaleBehavior.key(a.time) < b);
// yes, I know that this array is readonly and this change is intended to make it performative
// we marked _sortedTimePoints array as readonly to avoid modifying this array anywhere else
// but this place is exceptional case due performance reasons, sorry
(this._sortedTimePoints as InternalTimeScalePoint[]).splice(insertIndex, 0, newPoint);
for (let index = insertIndex; index < this._sortedTimePoints.length; ++index) {
assignIndexToPointData(this._sortedTimePoints[index].pointData, index as TimePointIndex);
}
this._horzScaleBehavior.fillWeightsForPoints(this._sortedTimePoints, insertIndex);
return this._getUpdateResponse(series, insertIndex, info);
}
public popSeriesData(series: Series, count: number): [SeriesPlotRow[], DataUpdateResponse] {
const seriesData = this._seriesRowsBySeries.get(series);
if (seriesData === undefined || count <= 0) {
return [[], this._emptyUpdateResponse()];
}
count = Math.min(count, seriesData.length);
const poppedData: SeriesPlotRow[] = seriesData.splice(-count).reverse();
if (seriesData.length === 0) {
this._seriesLastTimePoint.delete(series);
} else {
this._seriesLastTimePoint.set(series, seriesData[seriesData.length - 1].time);
}
for (const data of poppedData) {
const pointData = this._pointDataByTimePoint.get(this._horzScaleBehavior.key(data.time));
if (!pointData) {
continue;
}
pointData.mapping.delete(series);
if (pointData.mapping.size !== 0) {
continue;
}
// No remaining data at this timepoint therefore we need to remove the internal
// timescale point and update the indices.
this._pointDataByTimePoint.delete(this._horzScaleBehavior.key(pointData.timePoint));
(this._sortedTimePoints as InternalTimeScalePoint[]).splice(pointData.index, 1);
for (let index = pointData.index; index < this._sortedTimePoints.length; ++index) {
assignIndexToPointData(this._sortedTimePoints[index].pointData, index);
}
}
const info: SeriesUpdateInfo = {
historicalUpdate: false,
lastBarUpdatedOrNewBarsAddedToTheRight: false,
};
return [poppedData, this._getUpdateResponse(series, this._sortedTimePoints.length - 1, info)];
}
private _updateLastSeriesRow(series: Series, plotRow: SeriesPlotRow | WhitespacePlotRow): void {
let seriesData = this._seriesRowsBySeries.get(series);
if (seriesData === undefined) {
seriesData = [];
this._seriesRowsBySeries.set(series, seriesData);
}
const lastSeriesRow = seriesData.length !== 0 ? seriesData[seriesData.length - 1] : null;
if (lastSeriesRow === null || this._horzScaleBehavior.key(plotRow.time) > this._horzScaleBehavior.key(lastSeriesRow.time)) {
if (isSeriesPlotRow(plotRow)) {
seriesData.push(plotRow);
}
} else {
if (isSeriesPlotRow(plotRow)) {
seriesData[seriesData.length - 1] = plotRow;
} else {
seriesData.splice(-1, 1);
}
}
this._seriesLastTimePoint.set(series, plotRow.time);
}
private _updateHistoricalSeriesRow(series: Series, plotRow: SeriesPlotRow | WhitespacePlotRow, pointDataIndex: number): void {
const seriesData = this._seriesRowsBySeries.get(series);
if (seriesData === undefined) {
return;
}
// binary search for actual index in array.
const index = lowerBound(seriesData, pointDataIndex, (row: SeriesPlotRow, currentIndex: number): boolean =>
row.index < currentIndex
);
if (isSeriesPlotRow(plotRow)) {
seriesData[index] = plotRow;
} else {
seriesData.splice(index, 1);
}
}
private _setRowsToSeries(series: Series, seriesRows: (SeriesPlotRow | WhitespacePlotRow)[]): void {
if (seriesRows.length !== 0) {
this._seriesRowsBySeries.set(series, seriesRows.filter(isSeriesPlotRow));
this._seriesLastTimePoint.set(series, seriesRows[seriesRows.length - 1].time);
} else {
this._seriesRowsBySeries.delete(series);
this._seriesLastTimePoint.delete(series);
}
}
private _cleanupPointsData(): void {
// let's treat all current points as "potentially removed"
// we could create an array with actually potentially removed points
// but most likely this array will be similar to _sortedTimePoints so let's avoid using additional memory
// note that we can use _sortedTimePoints here since a point might be removed only it was here previously
for (const point of this._sortedTimePoints) {
if (point.pointData.mapping.size === 0) {
this._pointDataByTimePoint.delete(this._horzScaleBehavior.key(point.time));
}
}
}
/**
* Sets new time scale and make indexes valid for all series
*
* @returns The index of the first changed point or `-1` if there is no change.
*/
private _replaceTimeScalePoints(newTimePoints: InternalTimeScalePoint[]): number {
let firstChangedPointIndex = -1;
// search the first different point and "syncing" time weight by the way
for (let index = 0; index < this._sortedTimePoints.length && index < newTimePoints.length; ++index) {
const oldPoint = this._sortedTimePoints[index];
const newPoint = newTimePoints[index];
if (this._horzScaleBehavior.key(oldPoint.time) !== this._horzScaleBehavior.key(newPoint.time)) {
firstChangedPointIndex = index;
break;
}
// re-assign point's time weight for points if time is the same (and all prior times was the same)
newPoint.timeWeight = oldPoint.timeWeight;
assignIndexToPointData(newPoint.pointData, index as TimePointIndex);
}
if (firstChangedPointIndex === -1 && this._sortedTimePoints.length !== newTimePoints.length) {
// the common part of the prev and the new points are the same
// so the first changed point is the next after the common part
firstChangedPointIndex = Math.min(this._sortedTimePoints.length, newTimePoints.length);
}
if (firstChangedPointIndex === -1) {
// Times and length are unchanged, but the point objects may be new
// (the single-series setData path rebuilds the map from scratch).
this._sortedTimePoints = newTimePoints;
return -1;
}
// if time scale points are changed that means that we need to make full update to all series (with clearing points)
// but first we need to synchronize indexes and re-fill time weights
for (let index = firstChangedPointIndex; index < newTimePoints.length; ++index) {
assignIndexToPointData(newTimePoints[index].pointData, index as TimePointIndex);
}
// re-fill time weights for point after the first changed one
this._horzScaleBehavior.fillWeightsForPoints(newTimePoints, firstChangedPointIndex);
this._sortedTimePoints = newTimePoints;
return firstChangedPointIndex;
}
private _getBaseIndex(): TimePointIndex | null {
if (this._seriesRowsBySeries.size === 0) {
// if we have no data then 'reset' the base index to null
return null;
}
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;
}
private _getUpdateResponse(updatedSeries: Series, firstChangedPointIndex: number, info?: SeriesUpdateInfo): DataUpdateResponse {
const dataUpdateResponse: DataUpdateResponse = this._emptyUpdateResponse();
if (firstChangedPointIndex !== -1) {
// 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,
info: s === updatedSeries ? info : undefined,
}
);
});
// if the series data was set to [] it will have already been removed from _seriesRowBySeries
// meaning the forEach above won't add the series to the data update response
// so we handle that case here
if (!this._seriesRowsBySeries.has(updatedSeries)) {
dataUpdateResponse.series.set(updatedSeries, { data: [], info });
}
dataUpdateResponse.timeScale.points = this._sortedTimePoints;
dataUpdateResponse.timeScale.firstChangedPointIndex = firstChangedPointIndex as TimePointIndex;
} else {
const seriesData = this._seriesRowsBySeries.get(updatedSeries);
// if no seriesData found that means that we just removed the series
dataUpdateResponse.series.set(updatedSeries, { data: seriesData || [], info });
}
return dataUpdateResponse;
}
private _emptyUpdateResponse(): DataUpdateResponse {
return {
series: new Map(),
timeScale: {
baseIndex: this._getBaseIndex(),
},
};
}
}
function assignIndexToPointData(pointData: TimePointData, index: TimePointIndex): void {
// first, nevertheless update index of point data ("make it valid")
pointData.index = index;
// and then we need to sync indexes for all series
pointData.mapping.forEach((seriesRow: Mutable | WhitespacePlotRow>) => {
seriesRow.index = index;
});
}