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


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));
}