Загрузка данных
import {
DataChangedHandler,
IChartApi,
ISeriesApi,
LineSeries,
LogicalRange,
Time,
WhitespaceData,
} from 'lightweight-charts';
import { Observable, Subscription } from 'rxjs';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
interface TimeScaleFuturePaddingParams {
chart: IChartApi;
mainSeries$: Observable<SeriesStrategies | null>;
timeframe$: Observable<unknown>;
getTimeframe: () => unknown;
}
type TimeStepUnit = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year';
interface TimeStep {
unit: TimeStepUnit;
amount: number;
key: string;
}
interface LastTimePoint {
time: number;
logicalIndex: number;
}
const DAY_SECONDS = 86400;
const FUTURE_POINTS_BUFFER_BY_UNIT: Record<TimeStepUnit, number> = {
second: 100,
minute: 100,
hour: 72,
day: 30,
week: 12,
month: 6,
year: 2,
};
const MAX_FUTURE_POINTS_BY_UNIT: Record<TimeStepUnit, number> = {
second: 300,
minute: 300,
hour: 240,
day: 120,
week: 52,
month: 36,
year: 5,
};
export class TimeScaleFuturePadding {
private readonly chart: IChartApi;
private readonly getTimeframe: () => unknown;
private readonly subscriptions = new Subscription();
private readonly paddingSeries: ISeriesApi<'Line'>;
private mainSeries: SeriesStrategies | null = null;
private visibleLogicalRange: LogicalRange | null = null;
private lastAppliedKey = '';
private hasFutureData = false;
private isDestroyed = false;
constructor({ chart, mainSeries$, timeframe$, getTimeframe }: TimeScaleFuturePaddingParams) {
this.chart = chart;
this.getTimeframe = getTimeframe;
this.paddingSeries = this.chart.addSeries(LineSeries, {
color: 'rgba(0, 0, 0, 0)',
lineVisible: false,
lastValueVisible: false,
priceLineVisible: false,
crosshairMarkerVisible: false,
priceScaleId: '',
});
this.visibleLogicalRange = this.chart.timeScale().getVisibleLogicalRange();
this.chart.timeScale().subscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);
this.subscriptions.add(
mainSeries$.subscribe((series) => {
this.setMainSeries(series);
}),
);
this.subscriptions.add(
timeframe$.subscribe(() => {
this.lastAppliedKey = '';
this.update();
}),
);
}
public destroy(): void {
if (this.isDestroyed) {
return;
}
this.isDestroyed = true;
this.mainSeries?.unsubscribeDataChanged(this.handleDataChanged);
this.chart.timeScale().unsubscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);
this.subscriptions.unsubscribe();
try {
this.chart.removeSeries(this.paddingSeries);
} catch (error) {
void error;
}
}
private setMainSeries(series: SeriesStrategies | null): void {
if (this.mainSeries === series) {
return;
}
this.mainSeries?.unsubscribeDataChanged(this.handleDataChanged);
this.mainSeries = series;
this.lastAppliedKey = '';
this.mainSeries?.subscribeDataChanged(this.handleDataChanged);
this.update();
}
private handleDataChanged: DataChangedHandler = (): void => {
this.update();
};
private handleVisibleLogicalRangeChange = (range: LogicalRange | null): void => {
this.visibleLogicalRange = range;
this.update();
};
private update(): void {
if (this.isDestroyed) {
return;
}
if (!this.mainSeries) {
this.applyFutureData([]);
return;
}
const seriesData = this.mainSeries.data();
const lastPoint = getLastTimePoint(seriesData);
if (!lastPoint) {
this.applyFutureData([]);
return;
}
const timeStep = getTimeStep(this.getTimeframe(), seriesData);
const futurePointsCount = this.getFuturePointsCount(lastPoint.logicalIndex, timeStep);
if (futurePointsCount <= 0) {
this.applyFutureData([]);
return;
}
const nextKey = `${lastPoint.time}:${timeStep.key}:${futurePointsCount}`;
if (this.lastAppliedKey === nextKey) {
return;
}
this.lastAppliedKey = nextKey;
this.applyFutureData(createFutureWhitespaceData(lastPoint.time, timeStep, futurePointsCount));
}
private getFuturePointsCount(lastRealLogicalIndex: number, timeStep: TimeStep): number {
const range = this.visibleLogicalRange;
if (!range) {
return 0;
}
const visibleFuturePoints = Math.ceil(range.to - lastRealLogicalIndex);
if (visibleFuturePoints <= 0) {
return 0;
}
return clampNumber(
visibleFuturePoints + FUTURE_POINTS_BUFFER_BY_UNIT[timeStep.unit],
1,
MAX_FUTURE_POINTS_BY_UNIT[timeStep.unit],
);
}
private applyFutureData(data: WhitespaceData<Time>[]): void {
if (!data.length) {
if (!this.hasFutureData) {
return;
}
this.hasFutureData = false;
this.lastAppliedKey = '';
this.paddingSeries.setData([]);
return;
}
this.hasFutureData = true;
this.paddingSeries.setData(data);
}
}
function getLastTimePoint(data: readonly { time: Time }[]): LastTimePoint | null {
for (let index = data.length - 1; index >= 0; index -= 1) {
const item = data[index];
if (!item || typeof item.time !== 'number' || !Number.isFinite(item.time)) {
continue;
}
return {
time: item.time,
logicalIndex: index,
};
}
return null;
}
function createFutureWhitespaceData(lastTime: number, step: TimeStep, count: number): WhitespaceData<Time>[] {
const result: WhitespaceData<Time>[] = [];
let currentTime = lastTime;
for (let index = 0; index < count; index += 1) {
currentTime = addTime(currentTime, step);
result.push({
time: currentTime as Time,
});
}
return result;
}
function getTimeStep(value: unknown, data: readonly { time: Time }[]): TimeStep {
const rawValue = String(value ?? '').trim();
if (!rawValue) {
return inferTimeStepFromData(data);
}
if (rawValue.toLowerCase() === 'all') {
return createTimeStep('month', 1, rawValue);
}
if (/^\d+t$/i.test(rawValue)) {
return inferTimeStepFromData(data);
}
const suffixFormat = rawValue.match(/^(\d+)(s|m|h|d|w|M|y|Y)$/);
if (suffixFormat) {
const [, amountRaw, unit] = suffixFormat;
const amount = Number(amountRaw);
if (Number.isFinite(amount) && amount > 0) {
if (unit === 's') {
return createTimeStep('second', amount, rawValue);
}
if (unit === 'm') {
return createTimeStep('minute', amount, rawValue);
}
if (unit === 'h') {
return createTimeStep('hour', amount, rawValue);
}
if (unit === 'd') {
return createTimeStep('day', amount, rawValue);
}
if (unit === 'w') {
return createTimeStep('week', amount, rawValue);
}
if (unit === 'M') {
return createTimeStep('month', amount, rawValue);
}
if (unit === 'y' || unit === 'Y') {
return createTimeStep('year', amount, rawValue);
}
}
}
const prefixFormat = rawValue.toLowerCase().match(/^(s|m|h|d|w|mn|y)(\d+)$/);
if (prefixFormat) {
const [, unit, amountRaw] = prefixFormat;
const amount = Number(amountRaw);
if (Number.isFinite(amount) && amount > 0) {
if (unit === 's') {
return createTimeStep('second', amount, rawValue);
}
if (unit === 'm') {
return createTimeStep('minute', amount, rawValue);
}
if (unit === 'h') {
return createTimeStep('hour', amount, rawValue);
}
if (unit === 'd') {
return createTimeStep('day', amount, rawValue);
}
if (unit === 'w') {
return createTimeStep('week', amount, rawValue);
}
if (unit === 'mn') {
return createTimeStep('month', amount, rawValue);
}
if (unit === 'y') {
return createTimeStep('year', amount, rawValue);
}
}
}
if (/^\d+$/.test(rawValue)) {
return createTimeStep('minute', Number(rawValue), rawValue);
}
return inferTimeStepFromData(data);
}
function inferTimeStepFromData(data: readonly { time: Time }[]): TimeStep {
const times = data
.map((item) => item.time)
.filter((time): time is number => typeof time === 'number' && Number.isFinite(time));
if (times.length < 2) {
return createTimeStep('minute', 1, 'inferred:minute:1');
}
const uniqueTimes = Array.from(new Set(times)).sort((a, b) => a - b);
const latestTimes = uniqueTimes.slice(-100);
const distances: number[] = [];
for (let index = 1; index < latestTimes.length; index += 1) {
const distance = latestTimes[index] - latestTimes[index - 1];
if (distance > 0) {
distances.push(distance);
}
}
if (!distances.length) {
return createTimeStep('minute', 1, 'inferred:minute:1');
}
distances.sort((a, b) => a - b);
const medianDistance = distances[Math.floor(distances.length / 2)];
if (medianDistance < 60) {
const amount = Math.max(1, Math.round(medianDistance));
return createTimeStep('second', amount, `inferred:second:${amount}`);
}
if (medianDistance < 3600) {
const amount = Math.max(1, Math.round(medianDistance / 60));
return createTimeStep('minute', amount, `inferred:minute:${amount}`);
}
if (medianDistance < DAY_SECONDS) {
const amount = Math.max(1, Math.round(medianDistance / 3600));
return createTimeStep('hour', amount, `inferred:hour:${amount}`);
}
if (medianDistance < DAY_SECONDS * 27) {
const amount = Math.max(1, Math.round(medianDistance / DAY_SECONDS));
return createTimeStep('day', amount, `inferred:day:${amount}`);
}
if (medianDistance < DAY_SECONDS * 90) {
const amount = Math.max(1, Math.round(medianDistance / (DAY_SECONDS * 30)));
return createTimeStep('month', amount, `inferred:month:${amount}`);
}
if (medianDistance < DAY_SECONDS * 365) {
const amount = Math.max(1, Math.round(medianDistance / (DAY_SECONDS * 7)));
return createTimeStep('week', amount, `inferred:week:${amount}`);
}
const amount = Math.max(1, Math.round(medianDistance / (DAY_SECONDS * 365)));
return createTimeStep('year', amount, `inferred:year:${amount}`);
}
function createTimeStep(unit: TimeStepUnit, amount: number, key: string): TimeStep {
return {
unit,
amount,
key,
};
}
function addTime(time: number, step: TimeStep): number {
const date = new Date(time * 1000);
if (step.unit === 'second') {
date.setUTCSeconds(date.getUTCSeconds() + step.amount);
return Math.floor(date.getTime() / 1000);
}
if (step.unit === 'minute') {
date.setUTCMinutes(date.getUTCMinutes() + step.amount);
return Math.floor(date.getTime() / 1000);
}
if (step.unit === 'hour') {
date.setUTCHours(date.getUTCHours() + step.amount);
return Math.floor(date.getTime() / 1000);
}
if (step.unit === 'day') {
date.setUTCDate(date.getUTCDate() + step.amount);
return Math.floor(date.getTime() / 1000);
}
if (step.unit === 'week') {
date.setUTCDate(date.getUTCDate() + step.amount * 7);
return Math.floor(date.getTime() / 1000);
}
if (step.unit === 'month') {
return addCalendarMonths(time, step.amount);
}
return addCalendarYears(time, step.amount);
}
function addCalendarMonths(time: number, amount: number): number {
const date = new Date(time * 1000);
const day = date.getUTCDate();
date.setUTCDate(1);
date.setUTCMonth(date.getUTCMonth() + amount);
date.setUTCDate(Math.min(day, getDaysInUtcMonth(date.getUTCFullYear(), date.getUTCMonth())));
return Math.floor(date.getTime() / 1000);
}
function addCalendarYears(time: number, amount: number): number {
const date = new Date(time * 1000);
const day = date.getUTCDate();
date.setUTCDate(1);
date.setUTCFullYear(date.getUTCFullYear() + amount);
date.setUTCDate(Math.min(day, getDaysInUtcMonth(date.getUTCFullYear(), date.getUTCMonth())));
return Math.floor(date.getTime() / 1000);
}
function getDaysInUtcMonth(year: number, monthIndex: number): number {
return new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
}
function clampNumber(value: number, min: number, max: number): number {
return Math.max(min, Math.min(value, max));
}