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


import {
  IChartApi,
  PriceScaleMode,
  SeriesType,
} from 'lightweight-charts';

import { flatten } from 'lodash-es';
import {
  BehaviorSubject,
  distinctUntilChanged,
  map,
  Observable,
  Subscription,
} from 'rxjs';

import { DataSource } from '@core/DataSource';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { IndicatorManager } from '@core/IndicatorManager';
import { PaneManager } from '@core/PaneManager';
import { PriceScale } from '@core/PriceScale';
import { COMPARE_COLOR_PALETTE } from '@src/theme';
import {
  CompareItem,
  CompareMode,
  Direction,
  IndicatorConfig,
  SymbolInfo,
  SymbolInfoInput,
} from '@src/types';
import { IndicatorSnapshot } from '@src/types/snapshot';
import {
  createFallbackColor,
  normalizeColor,
  normalizeSymbol,
  normalizeSymbolInfo,
} from '@src/utils';

interface CompareEntry extends CompareItem {
  key: string;
  symbolId$: BehaviorSubject<string>;
  symbol$: BehaviorSubject<string>;
  entity: Indicator;
}

interface CompareManagerParams {
  chart: IChartApi;
  eventManager: EventManager;
  dataSource: DataSource;
  indicatorManager: IndicatorManager;
  paneManager: PaneManager;
  initialIndicators?: IndicatorSnapshot[];
}

export class CompareManager {
  private readonly chart: IChartApi;
  private readonly eventManager: EventManager;
  private readonly dataSource: DataSource;
  private readonly indicatorManager: IndicatorManager;
  private readonly paneManager: PaneManager;
  private readonly entries = new Map<string, CompareEntry>();
  private readonly itemsSubject = new BehaviorSubject<CompareItem[]>([]);
  private readonly entitiesSubject = new BehaviorSubject<Indicator[]>([]);
  private readonly subscriptions = new Subscription();

  private percentageComparisonActive = false;
  private restoringInitialIndicators = false;

  constructor({
    chart,
    eventManager,
    dataSource,
    indicatorManager,
    paneManager,
    initialIndicators = [],
  }: CompareManagerParams) {
    this.chart = chart;
    this.eventManager = eventManager;
    this.dataSource = dataSource;
    this.indicatorManager = indicatorManager;
    this.paneManager = paneManager;

    this.subscriptions.add(
      this.eventManager.timeframe().subscribe(() => {
        this.applyPolicy();
      }),
    );

    this.setup(initialIndicators);
  }

  public itemsObs(): Observable<CompareItem[]> {
    return this.itemsSubject.asObservable();
  }

  public entities(): Observable<Indicator[]> {
    return this.entitiesSubject.asObservable();
  }

  public clear(): void {
    const keys = Array.from(this.entries.keys());

    for (let index = 0; index < keys.length; index += 1) {
      this.removeEntry(keys[index]);
    }

    this.commitEntriesChange();
  }

  public async setSymbolMode(
    seriesType: SeriesType,
    symbolInfoInput: SymbolInfoInput,
    mode: CompareMode,
    paneId?: number,
  ): Promise<void> {
    const normalizedSymbolId = normalizeSymbol(
      symbolInfoInput.symbolId,
    );

    if (!normalizedSymbolId) {
      return;
    }

    const symbolInfo = normalizeSymbolInfo({
      ...symbolInfoInput,
      symbolId: normalizedSymbolId,
    });

    if (!symbolInfo) {
      return;
    }

    const {
      symbolId,
      symbol,
      symbolName,
    } = symbolInfo;

    if (
      mode === CompareMode.NewScale &&
      this.isNewScaleDisabled() &&
      !this.restoringInitialIndicators
    ) {
      return;
    }

    const key = makeKey(symbolId, mode);

    if (this.entries.has(key)) {
      return;
    }

    const symbolId$ = new BehaviorSubject(symbolId);
    const symbol$ = new BehaviorSubject(symbol);

    const entity = this.indicatorManager.addEntity<Indicator>(
      (zIndex, moveUp, moveDown) => {
        const usedColorsByCompare =
          this.entitiesSubject.value.map(
            // eslint-disable-next-line @typescript-eslint/ban-ts-comment
            // @ts-ignore
            (indicator) =>
              indicator
                .getConfig()
                .series?.[0]
                ?.seriesOptions
                ?.color,
          );

        const existingIndicators = Array.from(
          this.indicatorManager
            .getIndicators()
            .value
            .values(),
        );

        const usedColorsByIndicatorsRaw =
          existingIndicators.map(
            (indicator) =>
              // eslint-disable-next-line @typescript-eslint/ban-ts-comment
              // @ts-ignore
              indicator.config?.series?.map(
                (series) =>
                  series.seriesOptions?.color,
              ),
          );

        const usedColorsByIndicators = flatten(
          usedColorsByIndicatorsRaw,
        ).filter(
          (color) => color !== undefined,
        );

        const usedColors =
          usedColorsByCompare.concat(
            usedColorsByIndicators,
          );

        const config =
          getDefaultCompareIndicatorConfig(
            seriesType,
            symbolInfo,
            usedColors,
          );

        const associatedPane =
          mode === CompareMode.NewPane
            ? paneId !== undefined
              ? (this.paneManager.getPaneById(
                  paneId,
                ) ??
                this.paneManager.addPane())
              : this.paneManager.addPane()
            : this.paneManager.getMainPane();

        return new Indicator({
          id: key,
          lwcChart: this.chart,
          mainSymbolId$: symbolId$,
          mainSymbol$: symbol$,
          dataSource: this.dataSource,
          associatedPane,
          config: {
            ...config,
            series: [
              {
                ...config.series[0],
                seriesOptions: {
                  ...config.series[0]
                    ?.seriesOptions,
                  priceScaleId:
                    mode === CompareMode.NewScale
                      ? Direction.Left
                      : Direction.Right,
                },
              },
            ],
            newPane:
              mode === CompareMode.NewPane,
          },
          zIndex,
          onDelete: () => {
            if (this.removeEntry(key)) {
              this.commitEntriesChange();
            }
          },
          moveUp,
          moveDown,
          paneId: associatedPane.getId(),
        });
      },
    );

    this.entries.set(key, {
      key,
      symbolId,
      symbol,
      symbolName,
      mode,
      symbolId$,
      symbol$,
      entity,
    });

    this.commitEntriesChange();

    await this.dataSource.isReady(symbolId);
  }

  public removeSymbolMode(
    symbolIdRaw: string,
    mode: CompareMode,
  ): void {
    const symbolId = normalizeSymbol(symbolIdRaw);

    if (!symbolId) {
      return;
    }

    if (
      this.removeEntry(
        makeKey(symbolId, mode),
      )
    ) {
      this.commitEntriesChange();
    }
  }

  public removeSymbol(symbolIdRaw: string): void {
    const symbolId = normalizeSymbol(symbolIdRaw);

    if (!symbolId) {
      return;
    }

    const entries = Array.from(
      this.entries.entries(),
    );

    let removed = false;

    for (
      let index = 0;
      index < entries.length;
      index += 1
    ) {
      const [key, entry] = entries[index];

      if (entry.symbolId !== symbolId) {
        continue;
      }

      removed =
        this.removeEntry(key) || removed;
    }

    if (removed) {
      this.commitEntriesChange();
    }
  }

  public isNewScaleDisabled(): boolean {
    return this.itemsSubject.value.length > 0;
  }

  public isNewScaleDisabledObservable(): Observable<boolean> {
    return this.itemsSubject.pipe(
      map((items) => items.length > 0),
      distinctUntilChanged(),
    );
  }

  public getAllEntities() {
    return Array.from(
      this.entries.values(),
    ).map(
      ({
        symbolId,
        symbol,
        symbolName,
        entity,
        mode,
      }) => ({
        symbolId,
        symbol,
        symbolName,
        entity,
        mode,
      }),
    );
  }

  public destroy(): void {
    this.subscriptions.unsubscribe();
    this.clear();
    this.itemsSubject.complete();
    this.entitiesSubject.complete();
  }

  private async setup(
    initialIndicators: IndicatorSnapshot[],
  ): Promise<void> {
    this.restoringInitialIndicators = true;

    try {
      for (
        let index = 0;
        index < initialIndicators.length;
        index += 1
      ) {
        const indicator =
          initialIndicators[index];

        if (
          indicator.indicatorType !== undefined
        ) {
          continue;
        }

        const symbolInfoInput =
          indicator.config?.symbolInfo;

        if (!symbolInfoInput) {
          continue;
        }

        const symbolInfo =
          normalizeSymbolInfo(
            symbolInfoInput,
          );

        if (!symbolInfo) {
          continue;
        }

        const series =
          indicator.config?.series[0];

        if (!series) {
          continue;
        }

        const compareMode =
          series.seriesOptions
            ?.priceScaleId === Direction.Left
            ? CompareMode.NewScale
            : indicator.config?.newPane
              ? CompareMode.NewPane
              : CompareMode.Percentage;

        // eslint-disable-next-line no-await-in-loop
        await this.setSymbolMode(
          series.name,
          symbolInfo,
          compareMode,
          indicator.paneId,
        );
      }
    } finally {
      this.restoringInitialIndicators = false;
    }
  }

  private removeEntry(key: string): boolean {
    const entry = this.entries.get(key);

    if (!entry) {
      return false;
    }

    this.entries.delete(key);
    this.indicatorManager.removeEntity(
      entry.entity,
    );
    entry.entity.destroy();
    entry.symbolId$.complete();
    entry.symbol$.complete();

    return true;
  }

  private commitEntriesChange(): void {
    this.applyPolicy();
    this.publish();
  }

  private publish(): void {
    const values = Array.from(
      this.entries.values(),
    );

    const items: CompareItem[] = [];
    const entities: Indicator[] = [];

    for (
      let index = 0;
      index < values.length;
      index += 1
    ) {
      items.push({
        symbolId: values[index].symbolId,
        symbol: values[index].symbol,
        symbolName:
          values[index].symbolName,
        mode: values[index].mode,
      });

      entities.push(values[index].entity);
    }

    this.itemsSubject.next(items);
    this.entitiesSubject.next(entities);
  }

  private syncPercentageMode(
    priceScale: PriceScale,
    shouldEnablePercentageMode: boolean,
  ): void {
    if (this.restoringInitialIndicators) {
      this.percentageComparisonActive =
        shouldEnablePercentageMode;
      return;
    }

    if (
      this.percentageComparisonActive ===
      shouldEnablePercentageMode
    ) {
      return;
    }

    this.percentageComparisonActive =
      shouldEnablePercentageMode;

    if (shouldEnablePercentageMode) {
      priceScale.setMode(
        PriceScaleMode.Percentage,
      );
      return;
    }

    if (
      priceScale.getMode() ===
      PriceScaleMode.Percentage
    ) {
      priceScale.setMode(
        PriceScaleMode.Normal,
      );
    }
  }

  private applyPolicy(): void {
    const entries = Array.from(
      this.entries.values(),
    );

    let percentageComparisonActive = false;
    let newScaleComparisonActive = false;

    for (
      let index = 0;
      index < entries.length;
      index += 1
    ) {
      if (
        entries[index].mode ===
        CompareMode.Percentage
      ) {
        percentageComparisonActive = true;
      }

      if (
        entries[index].mode ===
        CompareMode.NewScale
      ) {
        newScaleComparisonActive = true;
      }
    }

    for (
      let index = 0;
      index < entries.length;
      index += 1
    ) {
      const entry = entries[index];
      const pane = entry.entity.getPane();

      // [0 - в индикаторах compare сущности может быть только одна серия] [1 - entry]
      const series = Array.from(
        entry.entity
          .getSeriesMap()
          .values(),
      )[0];

      if (!series) {
        continue;
      }

      series.applyOptions({
        priceScaleId:
          pane.isMainPane() &&
          entry.mode ===
            CompareMode.NewScale
            ? Direction.Left
            : Direction.Right,
      });
    }

    this.paneManager.setPriceScaleSideVisible(
      Direction.Left,
      newScaleComparisonActive,
    );

    this.paneManager.setPriceScaleSideVisible(
      Direction.Right,
      true,
    );

    const mainRightPriceScale =
      this.paneManager
        .getMainPane()
        .getPriceScale(Direction.Right);

    this.syncPercentageMode(
      mainRightPriceScale,
      percentageComparisonActive,
    );

    this.paneManager.invalidate();
  }
}

function makeKey(
  symbolId: string,
  mode: CompareMode,
): string {
  return `${symbolId}|${mode}`;
}

function getPaletteColorFromIndex(
  usedColors: Set<string>,
  startIndex: number,
): string {
  for (
    let offset = 0;
    offset < COMPARE_COLOR_PALETTE.length;
    offset += 1
  ) {
    const color =
      COMPARE_COLOR_PALETTE[
        (startIndex + offset) %
          COMPARE_COLOR_PALETTE.length
      ];

    if (
      !usedColors.has(
        normalizeColor(color),
      )
    ) {
      return color;
    }
  }

  return createFallbackColor(
    usedColors.size,
  );
}

function getDefaultCompareIndicatorConfig(
  seriesType: SeriesType,
  symbolInfo: SymbolInfo,
  usedColors: string[],
): IndicatorConfig {
  const reservedColors = new Set(
    usedColors.map(normalizeColor),
  );

  return {
    symbolInfo,
    newPane: true,
    label: symbolInfo.symbolName,
    series: [
      {
        name: seriesType,
        id: `compare-${crypto.randomUUID()}`,
        seriesOptions: {
          visible: true,
          color: getPaletteColorFromIndex(
            reservedColors,
            0,
          ),
        },
      },
    ],
  };
}