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


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 } from '@src/types';
import { IndicatorSnapshot } from '@src/types/snapshot';
import { createFallbackColor, normalizeColor, normalizeSymbol } from '@src/utils';

export interface CompareInstrument {
  symbol: string;
  symbolName: string;
}

interface CompareEntry {
  key: string;
  symbol: string;
  symbolName: string;
  mode: CompareMode;
  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 hadPercentageComparison = 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,
    instrument: CompareInstrument,
    mode: CompareMode,
    paneId?: number,
  ): Promise<void> {
    const symbol = normalizeSymbol(instrument.symbol);

    if (!symbol) {
      return;
    }

    const symbolName = instrument.symbolName.trim() || symbol;

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

    const key = makeKey(symbol, mode);

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

    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,
        symbol,
        symbolName,
        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,
        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,
      symbol,
      symbolName,
      mode,
      symbol$,
      entity,
    });

    this.commitEntriesChange();

    await this.dataSource.isReady(symbol);
  }

  public removeSymbolMode(symbolRaw: string, mode: CompareMode): void {
    const symbol = normalizeSymbol(symbolRaw);

    if (!symbol) {
      return;
    }

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

  public removeSymbol(symbolRaw: string): void {
    const symbol = normalizeSymbol(symbolRaw);

    if (!symbol) {
      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.symbol !== symbol) {
        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(
      ({ symbol, symbolName, entity, mode }) => ({
        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 (const indicator of initialIndicators) {
        if (indicator.indicatorType !== undefined) {
          continue;
        }

        if (!indicator.config?.label) {
          continue;
        }

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

        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,
          {
            symbol,
            symbolName: indicator.config.label,
          },
          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.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({
        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,
    hasPercentageComparison: boolean,
  ): void {
    if (this.restoringInitialIndicators) {
      this.hadPercentageComparison = hasPercentageComparison;
      return;
    }

    if (this.hadPercentageComparison === hasPercentageComparison) {
      return;
    }

    this.hadPercentageComparison = hasPercentageComparison;

    if (hasPercentageComparison) {
      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(symbol: string, mode: CompareMode): string {
  return `${symbol}|${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);
}

const getDefaultCompareIndicatorConfig = (
  seriesType: SeriesType,
  symbol: string,
  symbolName: string,
  usedColors: string[],
): IndicatorConfig => {
  const reservedColors = new Set(usedColors.map(normalizeColor));

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