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


import { IChartApi } from 'lightweight-charts';
import { BehaviorSubject, map, Observable } from 'rxjs';
import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { PaneManager } from '@core/PaneManager';
import { IndicatorsIds } from '@src/constants';
import { DOMObject } from '@src/core/DOMObject';
import { indicatorsMap as indicatorsConfigMap } from '@src/core/Indicators';
import { ChartTypeOptions, IndicatorConfig } from '@src/types';
import { IndicatorSnapshot } from '@src/types/snapshot';
import { applyNextIndicatorColors, getIndicatorColors } from '@src/utils';

interface IndicatorManagerParams {
  eventManager: EventManager;
  dataSource: DataSource;
  lwcChart: IChartApi;
  paneManager: PaneManager;
  DOM: DOMModel;
  initialIndicators?: IndicatorSnapshot[];
  chartOptions?: ChartTypeOptions;
}

export class IndicatorManager {
  private readonly eventManager: EventManager;
  private readonly lwcChart: IChartApi;
  private readonly chartOptions?: ChartTypeOptions;
  private readonly indicatorsMap$ = new BehaviorSubject<Map<string, Indicator>>(new Map());
  private readonly DOM: DOMModel;
  private readonly dataSource: DataSource;
  private readonly paneManager: PaneManager;

  constructor({
    eventManager,
    dataSource,
    lwcChart,
    DOM,
    chartOptions,
    initialIndicators,
    paneManager,
  }: IndicatorManagerParams) {
    this.eventManager = eventManager;
    this.lwcChart = lwcChart;
    this.chartOptions = chartOptions;
    this.DOM = DOM;
    this.dataSource = dataSource;
    this.paneManager = paneManager;

    initialIndicators?.forEach((indicator) => {
      this.addIndicator(indicator);
    });
  }

  public addEntity<T extends Indicator>(
    factory: (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) => T,
  ): T {
    return this.DOM.setEntity(factory);
  }

  public addIndicator(snap: Partial<IndicatorSnapshot>): void {
    if (!snap.indicatorType) {
      console.error('[IndicatorManager] Не был получен тип индиктора');
      return;
    }

    const indicatorsMap = new Map(this.indicatorsMap$.value);
    const id = snap.id ?? `${snap.indicatorType}-${crypto.randomUUID()}`;
    const config = getConfigByIndicatorType({
      indicatorType: snap.indicatorType,
      existedIndicators: this.indicatorsMap$.value,
    });
    const associatedPane =
      snap.paneId !== undefined
        ? (this.paneManager.getPaneById(snap.paneId) ?? this.paneManager.addPane())
        : config.newPane
          ? this.paneManager.addPane()
          : this.paneManager.getMainPane();

    const indicatorToSet = this.addEntity<Indicator>((zIndex, moveUp, moveDown) => {
      return new Indicator({
        id,
        paneId: associatedPane.getId(),
        zIndex,
        onDelete: this.deleteIndicator,
        moveUp,
        moveDown,
        mainSymbolId$: this.eventManager.symbolId(),
        mainSymbol$: this.eventManager.symbol(),
        lwcChart: this.lwcChart,
        dataSource: this.dataSource,
        associatedPane,
        config,
        settings: snap.settings,
        type: snap.indicatorType,
        chartOptions: this.chartOptions,
      });
    });

    indicatorsMap.set(id, indicatorToSet);
    this.indicatorsMap$.next(indicatorsMap);
  }

  public getIndicators(): ReadonlyMap<string, Indicator> {
    return this.indicatorsMap$.value;
  }

  public removeEntity(entity: DOMObject): void {
    this.DOM.removeEntity(entity);
  }

  public deleteIndicator = (id: string): void => {
    const entity = this.indicatorsMap$.value.get(id);
    if (!entity) {
      return;
    }

    const indicatorsMap = new Map(this.indicatorsMap$.value);
    indicatorsMap.delete(id);
    this.indicatorsMap$.next(indicatorsMap);

    this.removeEntity(entity);
    entity.destroy();
  };

  public entities(): Observable<Indicator[]> {
    return this.indicatorsMap$.pipe(map((indicators) => Array.from(indicators.values())));
  }
}

function getConfigByIndicatorType({
  indicatorType,
  existedIndicators,
}: {
  indicatorType: IndicatorsIds;
  existedIndicators: ReadonlyMap<string, Indicator>;
}): IndicatorConfig {
  const configWithAppliedSettings = indicatorsConfigMap()[indicatorType] as IndicatorConfig;
  const usedColors = getUsedIndicatorColorsByType({ indicatorType, existedIndicators });
  return applyNextIndicatorColors(configWithAppliedSettings, usedColors);
}

function getUsedIndicatorColorsByType({
  indicatorType,
  existedIndicators,
}: {
  indicatorType: IndicatorSnapshot['indicatorType'];
  existedIndicators: ReadonlyMap<string, Indicator>;
}): string[] {
  const colors: string[] = [];

  existedIndicators.forEach((indicator) => {
    if (indicator.getIndicatorType() !== indicatorType) {
      return;
    }

    colors.push(...getIndicatorColors(indicator.getConfig()));
  });

  return colors;
}
















import { IChartApi, PriceScaleMode, SeriesType } from 'lightweight-charts';
import { flatten } from 'lodash-es';
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, 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 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().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(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', // todo: change with enum
        id: `compare-${crypto.randomUUID()}`,
        seriesOptions: {
          visible: true,
          color: getPaletteColorFromIndex(reservedColors, 0),
        },
      },
    ],
  };
}

















import { IChartApi, IPaneApi, PriceScaleMode, Time } from 'lightweight-charts';
import { BehaviorSubject, Subscription } from 'rxjs';
import { ChartTooltip } from '@components/ChartTooltip';
import { LegendComponent } from '@components/Legend';
import { ChartMouseEvents } from '@core/ChartMouseEvents';
import { ContainerManager } from '@core/ContainerManager';
import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { DrawingsManager, DrawingsManagerSnapshot } from '@core/DrawingsManager';
import { EventManager } from '@core/EventManager';
import { Hotkeys } from '@core/Hotkeys';
import { Indicator } from '@core/Indicator';
import { Legend } from '@core/Legend';
import { PriceScale, PriceScaleControls } from '@core/PriceScale';
import { ReactRenderer } from '@core/ReactRenderer';
import { TooltipService } from '@core/Tooltip';
import { UIRenderer } from '@core/UIRenderer';
import { EntitySettingsModal } from '@src/components/EntitySettingsModal';
import { DrawingsNames, indicatorLabelById, MAIN_PANE_INDEX } from '@src/constants';
import { ModalRenderer } from '@src/core/ModalRenderer';
import { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { t } from '@src/translations';
import { ActiveDrawingTool, Direction, OHLCConfig, TooltipConfig } from '@src/types';
import {
  CompareSnapshot,
  DOMObjectSnapshot,
  IndicatorSnapshot,
  ISerializable,
  PaneSnapshot,
  PriceScaleSide,
  PriceScaleSnapshot,
} from '@src/types/snapshot';
import { ensureDefined } from '@src/utils';

export interface PaneParams {
  id: number;
  lwcChart: IChartApi;
  eventManager: EventManager;
  DOM: DOMModel;
  isMainPane: boolean;
  ohlcConfig: OHLCConfig;
  dataSource: DataSource | null; // todo: deal with dataSource. На каких то пейнах он нужен, на каких то нет
  basedOn?: Pane; // Pane на котором находится главная серия, или серия, по которой строятся серии на текущем пейне
  subscribeChartEvent: ChartMouseEvents['subscribe'];
  tooltipConfig: TooltipConfig;
  onDelete: () => void;
  chartContainer: HTMLElement;
  modalRenderer: ModalRenderer;
  initialPriceScales?: PriceScaleSnapshot[];
  onPriceScaleStateChange: () => void;
  leftPriceScaleVisible: boolean;
  rightPriceScaleVisible: boolean;
  hotkeys: Hotkeys;
  addDrawingManager: (manager: DrawingsManager, paneId: number) => void;
  removeDrawingManager: (paneId: number) => void;
  setActiveTool: (name: ActiveDrawingTool) => void;
  getActiveTool: () => ActiveDrawingTool;
  getIsEndlessMode: () => boolean;
  continueDrawing: (name: DrawingsNames) => void;
}

// todo: Pane, ему должна принадлежать mainSerie, а также IndicatorManager и drawingsManager, mouseEvents. Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
// todo: Учитывать, что есть линейка, которая рисуется одна для всех пейнов
// todo: в CompareManage, при создании нового пейна для сравнения - инициализируем новый dataSource, принадлежащий только конкретному пейну. Убираем возможность добавлять индикаторы на такие пейны
// todo: на каждый символ свой DataSource (учитывать что есть MainPane и "главный" DataSource, который инициализиурется во время старта moexChart)
// todo: сделать два разных представления для compare, в зависимости от отображения на главном пейне или на второстепенном
export class Pane implements ISerializable<PaneSnapshot> {
  private readonly id: number;
  private readonly isMain: boolean;
  private mainSeries = new BehaviorSubject<SeriesStrategies | null>(null); // Main Series. Exists in a single copy
  // todo: Отвратительный нейминг. Нужно оставить главной только эту серию и переименовать её localMainSeries => mainSerie
  // Если вдруг понадобится главная серия главного пейна, то брать из КоллекцииПейнов(-сейчас PaneManager)
  // serie to attach drawings
  private localMainSeries = new BehaviorSubject<SeriesStrategies | null>(null);
  private readonly indicatorsMap = new BehaviorSubject<Map<string, Indicator>>(new Map());
  private readonly lwcPane: IPaneApi<Time>;
  private readonly lwcChart: IChartApi;
  private readonly eventManager: EventManager;
  private readonly drawingsManager: DrawingsManager;
  private readonly modalRenderer: ModalRenderer;
  private readonly leftPriceScale: PriceScale;
  private readonly rightPriceScale: PriceScale;
  private readonly priceScaleControls: PriceScaleControls;
  private readonly subscribeChartEvent: ChartMouseEvents['subscribe'];
  private readonly onDelete: () => void;
  private readonly onPriceScaleStateChange: () => void;
  private readonly subscriptions = new Subscription();
  private legend!: Legend;
  private tooltip: TooltipService | undefined;
  private legendContainer!: HTMLElement;
  private paneOverlayContainer!: HTMLElement;
  private indicatorEmptyStateElement!: HTMLDivElement;
  private legendRenderer!: UIRenderer;
  private tooltipRenderer: UIRenderer | undefined;
  private mainSeriesDataSubscription = new Subscription();
  private paneContainerSyncFrameId: number | null = null;

  constructor({
    lwcChart,
    eventManager,
    dataSource,
    DOM,
    isMainPane,
    ohlcConfig,
    id,
    basedOn,
    subscribeChartEvent,
    tooltipConfig,
    onDelete,
    chartContainer,
    modalRenderer,
    initialPriceScales = [],
    onPriceScaleStateChange,
    leftPriceScaleVisible,
    rightPriceScaleVisible,
    hotkeys,
    addDrawingManager,
    removeDrawingManager,
    setActiveTool,
    getActiveTool,
    getIsEndlessMode,
    continueDrawing,
  }: PaneParams) {
    this.onDelete = onDelete;
    this.onPriceScaleStateChange = onPriceScaleStateChange;
    this.eventManager = eventManager;
    this.lwcChart = lwcChart;
    this.modalRenderer = modalRenderer;
    this.subscribeChartEvent = subscribeChartEvent;
    this.isMain = isMainPane;
    this.id = id;

    if (isMainPane) {
      this.lwcPane = this.lwcChart.panes()[MAIN_PANE_INDEX];
    } else {
      this.lwcPane = this.lwcChart.addPane(true);
    }

    this.leftPriceScale = this.createPriceScale(Direction.Left, initialPriceScales, leftPriceScaleVisible);
    this.rightPriceScale = this.createPriceScale(Direction.Right, initialPriceScales, rightPriceScaleVisible);

    // TODO: Перенести PriceScaleControls внутрь PriceScale, чтобы каждая шкала владела собственными контролами, а PriceScaleControls работал только с одной шкалой.
    this.priceScaleControls = new PriceScaleControls({
      leftPriceScale: this.leftPriceScale,
      rightPriceScale: this.rightPriceScale,
      onPriceScaleChange: this.handlePriceScaleStateChange,
    });

    this.initializeLegend({ ohlcConfig });

    this.tooltip = new TooltipService({
      config: tooltipConfig,
      legend: this.legend,
      paneOverlayContainer: this.paneOverlayContainer,
    });
    this.tooltipRenderer = new ReactRenderer(this.paneOverlayContainer);
    this.tooltipRenderer.renderComponent(
      <ChartTooltip
        formatObs={this.eventManager.getChartOptionsModel()}
        timeframeObs={this.eventManager.getTimeframeObs()}
        viewModel={this.tooltip.getTooltipViewModel()}
        tooltipConfig={this.tooltip.getConfig()}
      />,
    );

    if (dataSource) {
      this.initializeMainSerie({ lwcChart, dataSource });
    } else if (basedOn) {
      this.mainSeries = basedOn.getMainSerie();
    } else {
      console.error('[Pane]: There is no any mainSerie for new pane');
    }

    this.subscriptions.add(
      this.mainSeries.subscribe((series) => {
        this.bindMainSeriesData(series);
        if (series) {
          this.rebindIndicators();
        }
      }),
    );
    this.subscriptions.add(this.indicatorsMap.subscribe(() => this.updateIndicatorEmptyState()));

    this.drawingsManager = new DrawingsManager({
      // todo: менеджер дровингов должен быть один на чарт, не на пейн
      pane: this,
      eventManager,
      DOM,
      mainSeries$: this.localMainSeries.asObservable(),
      lwcChart,
      container: chartContainer,
      modalRenderer: this.modalRenderer,
      paneId: this.id,
      hotkeys,
      setActiveTool,
      getActiveTool,
      getIsEndlessMode,
      continueDrawing,
    });

    addDrawingManager(this.drawingsManager, this.id);
    this.subscriptions.add(() => removeDrawingManager(this.id));
    this.subscriptions.add(
      this.drawingsManager.entities().subscribe((drawings) => {
        const hasRuler = drawings.some((drawing) => drawing.getDrawingName() === DrawingsNames.ruler);
        this.legendContainer.style.display = hasRuler ? 'none' : '';
      }),
    );
  }

  public getLocalMainSeries = () => {
    return this.localMainSeries.value;
  };

  public setLocalMainSeries = (next: SeriesStrategies) => {
    if (!this.isMain) {
      this.localMainSeries.next(next);
    }
  };

  public isMainPane = () => {
    return this.isMain;
  };

  public getDrawingsSnapshot(): DrawingsManagerSnapshot {
    return this.drawingsManager.getSnapshot();
  }

  public setDrawingsSnapshot(snapshot: DrawingsManagerSnapshot): void {
    this.drawingsManager.setSnapshot(snapshot);
  }

  public getMainSerie = () => {
    return this.mainSeries;
  };

  public getId = () => {
    return this.id;
  };

  public isReady = async (interval = 50): Promise<void> => {
    return new Promise((resolve) => {
      const check = () => {
        if (this.lwcPane.getHTMLElement() === null) {
          setTimeout(check, interval);
        } else {
          resolve();
        }
      };

      check();
    });
  };

  public getHTMLElement = () => {
    return this.lwcPane.getHTMLElement();
  };

  public paneIndex = () => {
    return this.lwcPane.paneIndex();
  };

  public getPriceScale(side: PriceScaleSide): PriceScale {
    return side === Direction.Left ? this.leftPriceScale : this.rightPriceScale;
  }

  public setIndicator(indicatorId: string, indicator: Indicator): void {
    const indicators = new Map(this.indicatorsMap.value);
    indicators.set(indicatorId, indicator);
    this.indicatorsMap.next(indicators);
    this.priceScaleControls.refresh();
  }

  public removeIndicator(indicatorId: string): void {
    if (!this.indicatorsMap.value.has(indicatorId)) {
      return;
    }

    const indicators = new Map(this.indicatorsMap.value);
    indicators.delete(indicatorId);
    this.indicatorsMap.next(indicators);
    this.priceScaleControls.refresh();

    if (indicators.size === 0 && !this.isMain) {
      this.onDelete();
    }
  }

  public getDrawingManager(): DrawingsManager {
    return this.drawingsManager;
  }

  public schedulePaneContainerSync(): void {
    if (this.paneContainerSyncFrameId !== null) {
      return;
    }

    this.paneContainerSyncFrameId = requestAnimationFrame(() => {
      this.paneContainerSyncFrameId = null;
      this.syncPaneContainers();
    });
  }

  public refreshPriceScaleControls(): void {
    this.priceScaleControls.refresh();
  }

  public resetPriceScalesAutoScale(): void {
    this.leftPriceScale.enableAutoScale();
    this.rightPriceScale.enableAutoScale();
    this.handlePriceScaleStateChange();
  }

  public getSnapshot(): PaneSnapshot {
    const indicators: (DOMObjectSnapshot & (IndicatorSnapshot | CompareSnapshot))[] = [];

    this.indicatorsMap.value.forEach((indicator) => {
      indicators.push(indicator.getSnapshot());
    });

    return {
      isMain: this.isMain,
      id: this.id,
      indicators,
      drawings: this.getDrawingsSnapshot(),
      priceScales: [this.leftPriceScale.getSnapshot(), this.rightPriceScale.getSnapshot()],
    };
  }

  public destroy(): void {
    if (this.paneContainerSyncFrameId !== null) {
      cancelAnimationFrame(this.paneContainerSyncFrameId);
      this.paneContainerSyncFrameId = null;
    }

    this.drawingsManager.destroy();
    this.mainSeriesDataSubscription.unsubscribe();
    this.subscriptions.unsubscribe();
    this.tooltip?.destroy();
    this.legend?.destroy();
    this.legendRenderer.destroy();
    this.tooltipRenderer?.destroy();
    this.priceScaleControls.destroy();
    this.indicatorEmptyStateElement.remove();
    this.legendContainer.remove();
    this.paneOverlayContainer.remove();
    this.indicatorsMap.complete();

    if (this.isMain) {
      this.mainSeries.value?.destroy();
      this.mainSeries.complete();
    } else {
      this.localMainSeries.complete();
    }
  }

  private createPriceScale(
    side: PriceScaleSide,
    initialPriceScales: PriceScaleSnapshot[],
    initialVisible: boolean,
  ): PriceScale {
    const initialMode =
      initialPriceScales.find((priceScaleSnapshot) => priceScaleSnapshot.side === side)?.mode ?? PriceScaleMode.Normal;

    return new PriceScale({
      paneId: this.id,
      side,
      pane: this.lwcPane,
      initialMode,
      initialVisible,
      hasVisibleSeriesData: () => this.hasVisibleSeriesData(side),
    });
  }

  private hasVisibleSeriesData(side: PriceScaleSide): boolean {
    const mainSeries = this.mainSeries.value;
    const defaultPriceScaleSide = this.lwcChart.options().defaultVisiblePriceScaleId as PriceScaleSide;

    if (this.isMain && side === defaultPriceScaleSide && mainSeries?.isVisible() && mainSeries.data().length > 0) {
      return true;
    }

    const indicators = Array.from(this.indicatorsMap.value.values());
    for (let indicatorIndex = 0; indicatorIndex < indicators.length; indicatorIndex += 1) {
      const series = Array.from(indicators[indicatorIndex].getSeriesMap().values());

      for (let seriesIndex = 0; seriesIndex < series.length; seriesIndex += 1) {
        const currentSeries = series[seriesIndex];
        const seriesPriceScaleSide = (currentSeries.options().priceScaleId ?? defaultPriceScaleSide) as PriceScaleSide;

        if (currentSeries.isVisible() && currentSeries.data().length > 0 && seriesPriceScaleSide === side) {
          return true;
        }
      }
    }

    return false;
  }

  private handlePriceScaleStateChange = (): void => {
    this.priceScaleControls.refresh();
    this.onPriceScaleStateChange();
  };

  private initializeLegend({ ohlcConfig }: { ohlcConfig: OHLCConfig }): void {
    const { legendContainer, paneOverlayContainer } = ContainerManager.createPaneContainers();
    this.legendContainer = legendContainer;
    this.paneOverlayContainer = paneOverlayContainer;
    this.legendRenderer = new ReactRenderer(legendContainer);

    this.indicatorEmptyStateElement = document.createElement('div');
    this.indicatorEmptyStateElement.className = 'chart-pane__indicator-empty-state';
    this.indicatorEmptyStateElement.textContent =
      'Для отображения индикатора необходимо добавить основной инструмент';
    this.indicatorEmptyStateElement.hidden = true;

    this.schedulePaneContainerSync();

    this.legend = new Legend({
      config: ohlcConfig,
      indicators: this.indicatorsMap,
      eventManager: this.eventManager,
      subscribeChartEvent: this.subscribeChartEvent,
      mainSeries: this.isMain ? this.mainSeries : null,
      paneId: this.id,
      paneIndex: this.paneIndex,
      openIndicatorSettings: (indicatorId, indicator) => {
        let settings = indicator.getSettingsValues();

        this.modalRenderer.renderComponent(
          <EntitySettingsModal
            tabs={[
              {
                key: 'arguments',
                label: t('Arguments'),
                fields: indicator.getSettingsFields(),
              },
            ]}
            values={settings}
            onChange={(nextSettings) => {
              settings = nextSettings;
            }}
            initialTabKey="arguments"
          />,
          {
            size: 'sm',
            title: indicatorLabelById()[indicatorId],
            onSave: () => indicator.updateSettings(settings),
          },
        );
      },
      // todo: throw isMainPane
    });

    this.legendRenderer.renderComponent(
      <LegendComponent ohlcConfig={this.legend.getConfig()} viewModel={this.legend.getLegendViewModel()} />,
    );
  }

  private bindMainSeriesData(series: SeriesStrategies | null): void {
    this.mainSeriesDataSubscription.unsubscribe();
    this.mainSeriesDataSubscription = new Subscription();

    if (!series) {
      this.updateIndicatorEmptyState();
      return;
    }

    const handleDataChanged = () => this.updateIndicatorEmptyState();
    series.subscribeDataChanged(handleDataChanged);
    this.mainSeriesDataSubscription.add(() => series.unsubscribeDataChanged(handleDataChanged));
    this.updateIndicatorEmptyState();
  }

  private updateIndicatorEmptyState(): void {
    const hasIndicators = Array.from(this.indicatorsMap.value.values()).some(
      (indicator) => indicator.getIndicatorType() !== undefined,
    );
    const hasMainSeriesData = (this.mainSeries.value?.data().length ?? 0) > 0;
    this.indicatorEmptyStateElement.hidden = this.isMain || !hasIndicators || hasMainSeriesData;
  }

  private rebindIndicators(): void {
    for (const indicator of this.indicatorsMap.value.values()) {
      indicator.recreateSeries();
    }
  }

  private initializeMainSerie({ lwcChart, dataSource }: { lwcChart: IChartApi; dataSource: DataSource }): void {
    this.localMainSeries = this.mainSeries;
    this.subscriptions.add(
      this.eventManager.subscribeSeriesSelected((nextSeries) => {
        this.mainSeriesDataSubscription.unsubscribe();
        this.mainSeries.value?.destroy();

        const next = ensureDefined(SeriesFactory.create(nextSeries))({
          lwcChart,
          dataSource,
          mainSymbolId$: this.eventManager.symbolId(),
          mainSymbol$: this.eventManager.symbol(),
          mainSerie$: this.mainSeries,
        });

        this.mainSeries.next(next);
        this.priceScaleControls.refresh();
      }),
    );
  }

  private syncPaneContainers(): void {
    const lwcPaneElement = this.lwcPane.getHTMLElement();
    if (!lwcPaneElement) {
      this.schedulePaneContainerSync();
      return;
    }

    /*
      Внутри lightweight-chart DOM построен как таблица из 3 td
      [0] left priceScale, [1] center chart, [2] right priceScale
      Кладём легенду в td[1] и тогда легенда сама будет адаптироваться при изменении ширины шкал
    */
    const cells = lwcPaneElement.querySelectorAll<HTMLTableCellElement>(':scope > td');
    const chartCell = cells.item(1);
    if (!chartCell) {
      this.schedulePaneContainerSync();
      return;
    }

    chartCell.style.position = 'relative';
    chartCell.appendChild(this.legendContainer);
    chartCell.appendChild(this.indicatorEmptyStateElement);
    chartCell.appendChild(this.paneOverlayContainer);
    this.priceScaleControls.mount(lwcPaneElement);
  }
}







.chart-pane__indicator-empty-state {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  text-align: center;
  pointer-events: none;
  opacity: 0.65;
}

.chart-pane__indicator-empty-state[hidden] {
  display: none;
}