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


import { IChartApi, PriceScaleMode, SeriesType } from 'lightweight-charts';
import { BehaviorSubject, distinctUntilChanged, map, Observable, of, 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 { CompareSnapshot } from '@src/types/snapshot';
import {
  createFallbackColor,
  getIndicatorColors,
  normalizeColor,
  normalizeSymbol,
  normalizeSymbolInfo,
} from '@src/utils';

type MainScaleCompareMode = CompareMode.Absolute | CompareMode.Percentage;

interface CompareEntry extends CompareItem {
  key: string;
  entity: Indicator;
}

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

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 {
    for (const key of this.entries.keys()) {
      this.removeEntry(key);
    }

    this.commitEntriesChange();
  }

  public async setSymbolMode(
    _seriesType: SeriesType,
    symbolInfoInput: SymbolInfoInput,
    mode: CompareMode,
    paneId?: number,
  ): Promise<void> {
    const symbolInfo = normalizeSymbolInfo(symbolInfoInput);

    if (!symbolInfo) {
      return;
    }

    const { symbolId, symbol, symbolName } = symbolInfo;

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

    const isMainScaleMode = isMainScaleCompareMode(mode);

    if (isMainScaleMode) {
      for (const entry of this.entries.values()) {
        if (isMainScaleCompareMode(entry.mode)) {
          entry.mode = mode;
        }
      }
    }

    const key = makeKey(symbolId, mode);

    if (this.entries.has(key)) {
      if (isMainScaleMode) {
        this.commitEntriesChange();
      }

      return;
    }

    const entity = this.indicatorManager.addEntity<Indicator>((zIndex, moveUp, moveDown) => {
      const existingIndicators = Array.from(this.indicatorManager.getIndicators().value.values());

      const usedColors = [...this.entitiesSubject.value, ...existingIndicators].flatMap((indicator) =>
        getIndicatorColors(indicator.getConfig()),
      );

      const config = getDefaultCompareIndicatorConfig(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$: of(symbolId),
        mainSymbol$: of(symbol),
        dataSource: this.dataSource,
        associatedPane,
        config: {
          ...config,
          series: [
            {
              ...config.series[0],
              actLikeMainSerie: true,
              seriesOptions: {
                ...config.series[0]?.seriesOptions,
                ...(mode === CompareMode.NewScale ? { priceScaleId: Direction.Left } : {}),
              },
            },
          ],
          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,
      entity,
    });

    this.commitEntriesChange();

    await this.dataSource.waitUntilReady(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;
    }

    let removed = false;

    for (const [key, entry] of this.entries) {
      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 setup = async (compareIndicators: CompareSnapshot[]): Promise<void> => {
    this.restoringInitialIndicators = true;

    const mainPane = this.paneManager.getMainPane();
    const mainPaneId = mainPane.getId();
    const mainScaleMode =
      mainPane.getPriceScale(Direction.Right).getMode() === PriceScaleMode.Percentage
        ? CompareMode.Percentage
        : CompareMode.Absolute;

    try {
      for (const compareIndicator of compareIndicators) {
        const { scale, symbolInfo, seriesName, paneId } = compareIndicator;
        const symbolInfoNormalized = normalizeSymbolInfo(symbolInfo);

        if (!symbolInfoNormalized) {
          continue;
        }

        if (mainPaneId !== paneId && scale === Direction.Left) {
          throw new Error('[CompareManager]: несколько шкал на второстеменном пейне не поддерживаются');
        }

        const compareMode =
          scale === Direction.Left ? CompareMode.NewScale : mainPaneId === paneId ? mainScaleMode : CompareMode.NewPane;

        // eslint-disable-next-line no-await-in-loop
        await this.setSymbolMode(seriesName, symbolInfoNormalized, compareMode, 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();

    return true;
  }

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

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

    this.itemsSubject.next(
      entries.map(({ symbolId, symbol, symbolName, mode }) => ({
        symbolId,
        symbol,
        symbolName,
        mode,
      })),
    );

    this.entitiesSubject.next(entries.map(({ entity }) => entity));
  }

  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 {
    let percentageComparisonActive = false;
    let newScaleComparisonActive = false;

    for (const { mode } of this.entries.values()) {
      if (mode === CompareMode.Percentage) {
        percentageComparisonActive = true;
      }

      if (mode === CompareMode.NewScale) {
        newScaleComparisonActive = true;
      }
    }

    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 isMainScaleCompareMode(mode: CompareMode): mode is MainScaleCompareMode {
  return mode === CompareMode.Absolute || mode === CompareMode.Percentage;
}

function makeKey(symbolId: string, mode: CompareMode): string {
  const keyMode = isMainScaleCompareMode(mode) ? 'MAIN' : mode;

  return `${symbolId}|${keyMode}`;
}

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(symbolInfo: SymbolInfo, usedColors: string[]): IndicatorConfig {
  const reservedColors = new Set(usedColors.map(normalizeColor));

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