Загрузка данных


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 FutureTimeScaleExtenderParams {
  chart: IChartApi;
  mainSeries$: Observable<SeriesStrategies | null>;
  timeframe$: Observable<unknown>;
  getTimeframe: () => unknown;
}

type TimeStepUnit = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month';

interface TimeStep {
  unit: TimeStepUnit;
  amount: number;
  key: string;
}

const MIN_FUTURE_BARS_COUNT = 200;
const FUTURE_BARS_BUFFER = 200;
const MAX_FUTURE_BARS_COUNT = 5000;

export class FutureTimeScaleExtender {
  private readonly chart: IChartApi;
  private readonly getTimeframe: () => unknown;
  private readonly subscriptions = new Subscription();

  private readonly whitespaceSeries: ISeriesApi<'Line'>;

  private mainSeries: SeriesStrategies | null = null;
  private visibleLogicalRange: LogicalRange | null = null;

  private lastDataKey = '';

  constructor({ chart, mainSeries$, timeframe$, getTimeframe }: FutureTimeScaleExtenderParams) {
    this.chart = chart;
    this.getTimeframe = getTimeframe;

    this.whitespaceSeries = 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.lastDataKey = '';
        this.update();
      }),
    );
  }

  public destroy(): void {
    this.mainSeries?.unsubscribeDataChanged(this.handleDataChanged);
    this.chart.timeScale().unsubscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);

    this.subscriptions.unsubscribe();

    try {
      this.chart.removeSeries(this.whitespaceSeries);
    } catch {
      // Series can already be removed together with chart.
    }
  }

  private setMainSeries(series: SeriesStrategies | null): void {
    if (this.mainSeries === series) {
      return;
    }

    this.mainSeries?.unsubscribeDataChanged(this.handleDataChanged);

    this.mainSeries = series;
    this.lastDataKey = '';

    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.mainSeries) {
      this.setFutureData([]);
      return;
    }

    const data = this.mainSeries.data();

    if (!data.length) {
      this.setFutureData([]);
      return;
    }

    const lastBar = data[data.length - 1];

    if (!lastBar || typeof lastBar.time !== 'number') {
      this.setFutureData([]);
      return;
    }

    const futureBarsCount = this.getFutureBarsCount(data.length);

    if (futureBarsCount <= 0) {
      this.setFutureData([]);
      return;
    }

    const step = getTimeStep(this.getTimeframe());
    const dataKey = `${lastBar.time}:${step.key}:${futureBarsCount}`;

    if (this.lastDataKey === dataKey) {
      return;
    }

    this.lastDataKey = dataKey;

    this.setFutureData(createFutureWhitespace(lastBar.time, step, futureBarsCount));
  }

  private getFutureBarsCount(realBarsCount: number): number {
    const range = this.visibleLogicalRange;

    if (!range) {
      return 0;
    }

    const lastRealLogicalIndex = realBarsCount - 1;
    const visibleFutureBars = Math.ceil(range.to - lastRealLogicalIndex);

    if (visibleFutureBars <= 0) {
      return 0;
    }

    return clampNumber(
      Math.max(visibleFutureBars + FUTURE_BARS_BUFFER, MIN_FUTURE_BARS_COUNT),
      0,
      MAX_FUTURE_BARS_COUNT,
    );
  }

  private setFutureData(data: WhitespaceData<Time>[]): void {
    if (!data.length) {
      this.lastDataKey = '';
    }

    this.whitespaceSeries.setData(data);
  }
}

function createFutureWhitespace(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 addTime(time: number, step: TimeStep): number {
  const date = new Date(time * 1000);

  switch (step.unit) {
    case 'second':
      date.setUTCSeconds(date.getUTCSeconds() + step.amount);
      break;

    case 'minute':
      date.setUTCMinutes(date.getUTCMinutes() + step.amount);
      break;

    case 'hour':
      date.setUTCHours(date.getUTCHours() + step.amount);
      break;

    case 'day':
      date.setUTCDate(date.getUTCDate() + step.amount);
      break;

    case 'week':
      date.setUTCDate(date.getUTCDate() + step.amount * 7);
      break;

    case 'month':
      date.setUTCMonth(date.getUTCMonth() + step.amount);
      break;

    default:
      date.setUTCMinutes(date.getUTCMinutes() + 1);
      break;
  }

  return Math.floor(date.getTime() / 1000);
}

function getTimeStep(value: unknown): TimeStep {
  const rawValue = String(value ?? '').trim();

  if (!rawValue) {
    return createStep('minute', 1, 'default');
  }

  const normalizedValue = rawValue.toLowerCase();

  const platformFormat = normalizedValue.match(/^(m|h|d|w|mn)(\d+)$/);

  if (platformFormat) {
    const [, unit, amountRaw] = platformFormat;
    const amount = Number(amountRaw);

    if (Number.isFinite(amount) && amount > 0) {
      if (unit === 'm') {
        return createStep('minute', amount, rawValue);
      }

      if (unit === 'h') {
        return createStep('hour', amount, rawValue);
      }

      if (unit === 'd') {
        return createStep('day', amount, rawValue);
      }

      if (unit === 'w') {
        return createStep('week', amount, rawValue);
      }

      if (unit === 'mn') {
        return createStep('month', amount, rawValue);
      }
    }
  }

  const suffixFormat = rawValue.match(/^(\d+)(s|m|h|d|w|M)$/);

  if (suffixFormat) {
    const [, amountRaw, unit] = suffixFormat;
    const amount = Number(amountRaw);

    if (Number.isFinite(amount) && amount > 0) {
      if (unit === 's') {
        return createStep('second', amount, rawValue);
      }

      if (unit === 'm') {
        return createStep('minute', amount, rawValue);
      }

      if (unit === 'h') {
        return createStep('hour', amount, rawValue);
      }

      if (unit === 'd') {
        return createStep('day', amount, rawValue);
      }

      if (unit === 'w') {
        return createStep('week', amount, rawValue);
      }

      if (unit === 'M') {
        return createStep('month', amount, rawValue);
      }
    }
  }

  if (/^\d+$/.test(rawValue)) {
    return createStep('minute', Number(rawValue), rawValue);
  }

  return createStep('minute', 1, rawValue);
}

function createStep(unit: TimeStepUnit, amount: number, key: string): TimeStep {
  return {
    unit,
    amount,
    key,
  };
}

function clampNumber(value: number, min: number, max: number): number {
  return Math.max(min, Math.min(value, max));
}