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


import { IPriceScaleApi, PriceScaleMode } from 'lightweight-charts';

import type { PriceScaleSide, PriceScaleSnapshot } from '@src/types/snapshot';

interface PriceScaleParams {
  paneId: number;
  side: PriceScaleSide;
  priceScaleApi: IPriceScaleApi;
  initialMode?: PriceScaleMode;
  hasVisibleSeriesData: () => boolean;
  onStateChange: () => void;
}

export class PriceScale {
  public readonly paneId: number;
  public readonly side: PriceScaleSide;

  private readonly priceScaleApi: IPriceScaleApi;
  private readonly hasVisibleSeriesDataCallback: () => boolean;
  private readonly onStateChange: () => void;

  constructor({
    paneId,
    side,
    priceScaleApi,
    initialMode = PriceScaleMode.Normal,
    hasVisibleSeriesData,
    onStateChange,
  }: PriceScaleParams) {
    this.paneId = paneId;
    this.side = side;
    this.priceScaleApi = priceScaleApi;
    this.hasVisibleSeriesDataCallback = hasVisibleSeriesData;
    this.onStateChange = onStateChange;

    const autoScaleEnabled = this.isAutoScaleEnabled();

    this.priceScaleApi.applyOptions({
      mode: initialMode,
      autoScale: autoScaleEnabled,
      borderVisible: false,
    });
  }

  public getMode(): PriceScaleMode {
    return this.priceScaleApi.options().mode ?? PriceScaleMode.Normal;
  }

  public setMode(mode: PriceScaleMode): void {
    if (this.getMode() === mode) {
      return;
    }

    const autoScaleEnabled = this.isAutoScaleEnabled();

    this.priceScaleApi.applyOptions({
      mode,
      autoScale: autoScaleEnabled,
    });

    this.onStateChange();
  }

  public toggleLogarithmic(): void {
    const nextMode =
      this.getMode() === PriceScaleMode.Logarithmic
        ? PriceScaleMode.Normal
        : PriceScaleMode.Logarithmic;

    this.setMode(nextMode);
  }

  public isAutoScaleEnabled(): boolean {
    return this.priceScaleApi.options().autoScale ?? true;
  }

  public toggleAutoScale(): void {
    this.priceScaleApi.setAutoScale(!this.isAutoScaleEnabled());
    this.onStateChange();
  }

  public enableAutoScale(): void {
    if (this.isAutoScaleEnabled()) {
      return;
    }

    this.priceScaleApi.setAutoScale(true);
    this.onStateChange();
  }

  public isVisible(): boolean {
    return this.priceScaleApi.options().visible !== false;
  }

  public setVisible(visible: boolean): void {
    if (this.isVisible() === visible) {
      return;
    }

    this.priceScaleApi.applyOptions({
      visible,
      borderVisible: false,
    });

    this.onStateChange();
  }

  public getWidth(): number {
    return this.priceScaleApi.width();
  }

  public hasVisibleSeriesData(): boolean {
    return this.hasVisibleSeriesDataCallback();
  }

  public getSnapshot(): PriceScaleSnapshot {
    return {
      side: this.side,
      mode: this.getMode(),
    };
  }
}






import { PriceScaleMode } from 'lightweight-charts';

import { PriceScaleControls as PriceScaleControlsView } from '@components/PriceScaleControls';
import { ReactRenderer } from '@core/ReactRenderer';
import { Direction } from '@src/types';

import { PriceScale } from './PriceScale';

interface PriceScaleControlsParams {
  chartContainer: HTMLElement;
  leftPriceScale: PriceScale;
  rightPriceScale: PriceScale;
}

export class PriceScaleControls {
  private readonly chartContainer: HTMLElement;
  private readonly leftPriceScale: PriceScale;
  private readonly rightPriceScale: PriceScale;

  private readonly container: HTMLElement;
  private readonly renderer: ReactRenderer;

  private paneElement: HTMLElement | null = null;
  private hoveredPriceScale: PriceScale | null = null;

  constructor({
    chartContainer,
    leftPriceScale,
    rightPriceScale,
  }: PriceScaleControlsParams) {
    this.chartContainer = chartContainer;
    this.leftPriceScale = leftPriceScale;
    this.rightPriceScale = rightPriceScale;

    this.container = document.createElement('div');
    this.container.className = 'moex-price-scale-controls';
    this.container.style.position = 'absolute';
    this.container.style.zIndex = '10';
    this.container.style.pointerEvents = 'none';

    this.renderer = new ReactRenderer(this.container);
  }

  public mount(paneElement: HTMLElement): void {
    if (
      this.paneElement === paneElement &&
      this.container.parentElement === this.chartContainer
    ) {
      this.refresh();
      return;
    }

    this.unmount();

    this.paneElement = paneElement;

    this.chartContainer.appendChild(this.container);
    this.chartContainer.addEventListener('pointermove', this.handlePointerMove);
    this.chartContainer.addEventListener('pointerleave', this.handlePointerLeave);

    this.refresh();
  }

  public refresh(): void {
    if (!this.hoveredPriceScale) {
      this.hide();
      return;
    }

    this.render(this.hoveredPriceScale);
  }

  public destroy(): void {
    this.unmount();
    this.renderer.destroy();
    this.container.remove();
  }

  private handlePointerMove = (event: PointerEvent): void => {
    if (!this.paneElement) {
      return;
    }

    const chartRect = this.chartContainer.getBoundingClientRect();
    const paneRect = this.paneElement.getBoundingClientRect();

    const isInsidePane =
      event.clientY >= paneRect.top &&
      event.clientY < paneRect.bottom;

    const nextPriceScale = isInsidePane
      ? this.getHoveredPriceScale(
          event.clientX - chartRect.left,
          chartRect.width,
        )
      : null;

    if (this.hoveredPriceScale === nextPriceScale) {
      if (nextPriceScale) {
        this.updatePosition(nextPriceScale);
      }

      return;
    }

    this.hoveredPriceScale = nextPriceScale;
    this.refresh();
  };

  private handlePointerLeave = (): void => {
    this.hoveredPriceScale = null;
    this.refresh();
  };

  private render(priceScale: PriceScale): void {
    if (!this.canShowControls(priceScale)) {
      this.hide();
      return;
    }

    this.updatePosition(priceScale);

    this.renderer.renderComponent(
      <PriceScaleControlsView
        isAutoScale={priceScale.isAutoScaleEnabled()}
        isLogarithmic={priceScale.getMode() === PriceScaleMode.Logarithmic}
        onToggleAutoScale={() => {
          priceScale.toggleAutoScale();
        }}
        onToggleLogarithmic={() => {
          priceScale.toggleLogarithmic();
        }}
      />,
    );

    this.container.classList.add('moex-price-scale-controls_visible');
  }

  private hide(): void {
    this.container.classList.remove('moex-price-scale-controls_visible');
  }

  private getHoveredPriceScale(
    pointerX: number,
    chartWidth: number,
  ): PriceScale | null {
    const leftWidth = this.leftPriceScale.getWidth();
    const rightWidth = this.rightPriceScale.getWidth();

    if (
      this.canShowControls(this.leftPriceScale) &&
      pointerX >= 0 &&
      pointerX <= leftWidth
    ) {
      return this.leftPriceScale;
    }

    if (
      this.canShowControls(this.rightPriceScale) &&
      pointerX >= chartWidth - rightWidth &&
      pointerX <= chartWidth
    ) {
      return this.rightPriceScale;
    }

    return null;
  }

  private updatePosition(priceScale: PriceScale): void {
    if (!this.paneElement) {
      return;
    }

    const chartRect = this.chartContainer.getBoundingClientRect();
    const paneRect = this.paneElement.getBoundingClientRect();

    const bottomOffset = Math.max(0, chartRect.bottom - paneRect.bottom);

    this.container.style.width = `${priceScale.getWidth()}px`;
    this.container.style.bottom = `${bottomOffset}px`;

    if (priceScale.side === Direction.Left) {
      this.container.style.left = '0';
      this.container.style.right = '';
      return;
    }

    this.container.style.left = '';
    this.container.style.right = '0';
  }

  private canShowControls(priceScale: PriceScale): boolean {
    return (
      priceScale.isVisible() &&
      priceScale.getWidth() > 0 &&
      priceScale.hasVisibleSeriesData()
    );
  }

  private unmount(): void {
    this.chartContainer.removeEventListener(
      'pointermove',
      this.handlePointerMove,
    );
    this.chartContainer.removeEventListener(
      'pointerleave',
      this.handlePointerLeave,
    );

    this.paneElement = null;
    this.hoveredPriceScale = null;

    this.hide();
  }
}




export { PriceScale } from './PriceScale';
export { PriceScaleControls } from './PriceScaleControls';




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 { 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 {
  Direction,
  OHLCConfig,
  TooltipConfig,
} from '@src/types';
import {
  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;
}

// 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: BehaviorSubject<SeriesStrategies | null> =
    new BehaviorSubject<SeriesStrategies | null>(null); // Main Series. Exists in a single copy

  private legend!: Legend;
  private tooltip: TooltipService | undefined;

  private indicatorsMap: BehaviorSubject<Map<string, Indicator>> =
    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 legendContainer!: HTMLElement;
  private paneOverlayContainer!: HTMLElement;
  private legendRenderer!: UIRenderer;
  private tooltipRenderer: UIRenderer | undefined;
  private readonly modalRenderer: ModalRenderer;

  private readonly leftPriceScale: PriceScale;
  private readonly rightPriceScale: PriceScale;
  private readonly priceScaleControls: PriceScaleControls;

  private mainSerieSub: Subscription | undefined;
  private readonly subscribeChartEvent: ChartMouseEvents['subscribe'];
  private readonly onDelete: () => void;
  private readonly onPriceScaleStateChange: () => void;

  private readonly subscriptions = new Subscription();
  private paneContainerSyncFrameId: number | null = null;

  constructor({
    lwcChart,
    eventManager,
    dataSource,
    DOM,
    isMainPane,
    ohlcConfig,
    id,
    basedOn,
    subscribeChartEvent,
    tooltipConfig,
    onDelete,
    chartContainer,
    modalRenderer,
    initialPriceScales = [],
    onPriceScaleStateChange,
  }: 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,
    );

    this.rightPriceScale = this.createPriceScale(
      Direction.Right,
      initialPriceScales,
    );

    this.priceScaleControls = new PriceScaleControls({
      chartContainer,
      leftPriceScale: this.leftPriceScale,
      rightPriceScale: this.rightPriceScale,
    });

    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()}
        // ohlcConfig={this.legend.getConfig()}
        ohlcConfig={ohlcConfig}
        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.drawingsManager = new DrawingsManager({
      // todo: менеджер дровингов должен быть один на чарт, не на пейн
      eventManager,
      DOM,
      mainSeries$: this.mainSeries.asObservable(),
      lwcChart,
      container: chartContainer,
      modalRenderer: this.modalRenderer,
      paneId: 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 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 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 map = this.indicatorsMap.value;

    map.set(indicatorId, indicator);

    this.indicatorsMap.next(map);
    this.priceScaleControls.refresh();
  }

  public removeIndicator(indicatorId: string): void {
    const map = this.indicatorsMap.value;

    map.delete(indicatorId);

    this.indicatorsMap.next(map);
    this.priceScaleControls.refresh();

    if (map.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();
  }

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

    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.subscriptions.unsubscribe();
    this.tooltip?.destroy();
    this.legend?.destroy();
    this.legendRenderer.destroy();

    this.tooltipRenderer?.destroy();
    this.legendContainer.remove();
    this.paneOverlayContainer.remove();
    this.priceScaleControls.destroy();
    this.indicatorsMap.complete();

    this.mainSerieSub?.unsubscribe();

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

    const paneIndex = this.paneIndex();

    if (paneIndex >= 0) {
      try {
        this.lwcChart.removePane(paneIndex);
      } catch (error) {
        console.log(error);
      }
    }
  }

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

    return new PriceScale({
      paneId: this.id,
      side,
      priceScaleApi: this.lwcChart.priceScale(side, this.paneIndex()),
      initialMode,
      hasVisibleSeriesData: () => this.hasVisibleSeriesData(side),
      onStateChange: this.handlePriceScaleStateChange,
    });
  }

  private hasVisibleSeriesData(side: PriceScaleSide): boolean {
    const mainSeries = this.mainSeries.value;

    if (
      this.isMain &&
      side === Direction.Right &&
      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 options = currentSeries.options();
        const seriesPriceScaleSide =
          options.priceScaleId ?? Direction.Right;

        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.schedulePaneContainerSync();

    // todo: переписать код ниже под логику пейнов
    // /*
    //   Внутри lightweight-chart DOM построен как таблица из 3 td
    //   [0] left priceScale, [1] center chart, [2] right priceScale
    //   Кладём легенду в td[1] и тогда легенда сама будет адаптироваться при изменении ширины шкал
    // */
    // requestAnimationFrame(() => {
    //   const root = chartAreaContainer.querySelector('.tv-lightweight-charts');
    //   console.log(root)
    //   const table = root?.querySelector('table');
    //   console.log(table)
    //
    //   const htmlCollectionOfPanes = table?.getElementsByTagName('td')
    //   console.log(htmlCollectionOfPanes)
    //
    //   const centerId = htmlCollectionOfPanes?.[1];
    //   console.log(centerId)
    //
    //   if (centerId && legendContainer && legendContainer.parentElement !== centerId) {
    //     centerId.appendChild(legendContainer);
    //   }
    // });
    // /*
    //   Внутри lightweight-chart DOM построен как таблица из 3 td
    //   [0] left priceScale, [1] center chart, [2] right priceScale
    //   Кладём легенду в td[1] и тогда легенда сама будет адаптироваться при изменении ширины шкал
    // */
    // requestAnimationFrame(() => {
    //   const root = chartAreaContainer.querySelector('.tv-lightweight-charts');
    //   const table = root?.querySelector('table');
    //   const centerId = table?.getElementsByTagName('td')?.[1];
    //
    //   if (centerId && legendContainer && legendContainer.parentElement !== centerId) {
    //     centerId.appendChild(legendContainer);
    //   }
    // });

    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.getSettings();

        this.modalRenderer.renderComponent(
          <EntitySettingsModal
            tabs={[
              {
                key: 'arguments',
                label: t('Arguments'),
                fields: indicator.getSettingsConfig(),
              },
            ]}
            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 initializeMainSerie({
    lwcChart,
    dataSource,
  }: {
    lwcChart: IChartApi;
    dataSource: DataSource;
  }): void {
    this.mainSerieSub = this.eventManager.subscribeSeriesSelected(
      (nextSeries) => {
        this.mainSeries.value?.destroy();

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

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

  private syncPaneContainers(): void {
    const lwcPaneElement = this.lwcPane.getHTMLElement();

    if (!lwcPaneElement) {
      return;
    }

    lwcPaneElement.style.position = 'relative';

    lwcPaneElement.appendChild(this.legendContainer);
    lwcPaneElement.appendChild(this.paneOverlayContainer);

    this.priceScaleControls.mount(lwcPaneElement);
  }
}



import { DataSource } from '@core/DataSource';
import {
  DrawingsManager,
  DrawingsManagerSnapshot,
} from '@core/DrawingsManager';
import { Pane, PaneParams } from '@core/Pane';
import {
  ISerializable,
  PaneSnapshot,
  PriceScaleSnapshot,
} from '@src/types/snapshot';

interface PaneManagerParams
  extends Omit<
    PaneParams,
    | 'id'
    | 'isMainPane'
    | 'basedOn'
    | 'onDelete'
    | 'initialPriceScales'
  > {
  panesSnapshot: PaneSnapshot[];
}

type SharedPaneParams = Omit<PaneManagerParams, 'panesSnapshot'>;

// todo: PaneManager, регулирует порядок пейнов. Знает про MainPane.
// todo: Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
// todo: в CompareManage, при создании нового пейна для сравнения - инициализируем новый dataSource, принадлежащий только конкретному пейну. Убираем возможность добавлять индикаторы на такие пейны
// todo: на каждый символ свой DataSource (учитывать что есть MainPane и "главный" DataSource, который инициализиурется во время старта moexChart)
// todo: сделать два разных представления для compare, в зависимости от отображения на главном пейне или на второстепенном

export class PaneManager implements ISerializable<PaneSnapshot[]> {
  private readonly sharedPaneParams: SharedPaneParams;
  private readonly panesMap = new Map<number, Pane>();

  private mainPane: Pane;
  private nextPaneId: number;

  constructor({
    panesSnapshot,
    ...sharedPaneParams
  }: PaneManagerParams) {
    this.sharedPaneParams = sharedPaneParams;

    const mainPaneSnapshot = panesSnapshot.find(
      (paneSnapshot) => paneSnapshot.isMain,
    );

    const mainPaneId = mainPaneSnapshot?.id ?? 0;

    this.mainPane = new Pane({
      ...this.sharedPaneParams,
      id: mainPaneId,
      isMainPane: true,
      onDelete: () => {},
      initialPriceScales: mainPaneSnapshot?.priceScales,
    });

    this.panesMap.set(mainPaneId, this.mainPane);

    if (mainPaneSnapshot) {
      this.mainPane.setDrawingsSnapshot(mainPaneSnapshot.drawings);
    }

    const greatestPaneId = panesSnapshot.reduce(
      (greatestId, paneSnapshot) =>
        Math.max(greatestId, paneSnapshot.id),
      mainPaneId,
    );

    this.nextPaneId = greatestPaneId + 1;

    panesSnapshot.forEach((paneSnapshot) => {
      if (paneSnapshot.isMain) {
        return;
      }

      const pane = this.addPane(
        undefined,
        paneSnapshot.id,
        paneSnapshot.priceScales,
      );

      pane.setDrawingsSnapshot(paneSnapshot.drawings);
    });

    this.syncPaneContainers();
  }

  public getPaneById(id: number): Pane | undefined {
    return this.panesMap.get(id);
  }

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

  public setDrawingsSnapshot(
    snapshot: DrawingsManagerSnapshot,
  ): void {
    this.mainPane.setDrawingsSnapshot(snapshot);
  }

  public getPanes(): Map<number, Pane> {
    return this.panesMap;
  }

  public getMainPane = (): Pane => {
    return this.mainPane;
  };

  public addPane(
    dataSource?: DataSource,
    paneId?: number,
    initialPriceScales?: PriceScaleSnapshot[],
  ): Pane {
    const id = paneId ?? this.nextPaneId++;

    this.nextPaneId = Math.max(this.nextPaneId, id + 1);

    const pane = new Pane({
      ...this.sharedPaneParams,
      id,
      isMainPane: false,
      dataSource: dataSource ?? null,
      basedOn: dataSource ? undefined : this.mainPane,
      onDelete: () => this.destroyPane(id),
      initialPriceScales,
    });

    this.panesMap.set(id, pane);
    this.syncPaneContainers();

    return pane;
  }

  public refreshPriceScaleControls(): void {
    this.panesMap.forEach((pane) => {
      pane.refreshPriceScaleControls();
    });
  }

  public resetPriceScalesAutoScale(): void {
    this.panesMap.forEach((pane) => {
      pane.resetPriceScalesAutoScale();
    });
  }

  public getDrawingsManager(): DrawingsManager {
    // todo: temp
    return this.mainPane.getDrawingManager();
  }

  public getSnapshot(): PaneSnapshot[] {
    const snapshot: PaneSnapshot[] = [];

    this.panesMap.forEach((pane) => {
      snapshot.push(pane.getSnapshot());
    });

    return snapshot;
  }

  public destroy(): void {
    const panes = Array.from(this.panesMap.values()).sort(
      (leftPane, rightPane) =>
        rightPane.paneIndex() - leftPane.paneIndex(),
    );

    this.panesMap.clear();

    panes.forEach((pane) => {
      pane.destroy();
    });
  }

  private destroyPane(id: number): void {
    const pane = this.panesMap.get(id);

    if (!pane) {
      return;
    }

    this.panesMap.delete(id);
    pane.destroy();
    this.syncPaneContainers();
  }

  private syncPaneContainers(): void {
    this.panesMap.forEach((pane) => {
      pane.schedulePaneContainerSync();
    });
  }
}




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';

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

    if (!symbol) {
      return;
    }

    if (
      mode === CompareMode.NewScale &&
      this.isNewScaleDisabled()
    ) {
      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(
          symbol,
          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,
      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, entity, mode }) => ({
        symbol,
        entity,
        mode,
      }),
    );
  }

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

  private async setup(
    initialIndicators: IndicatorSnapshot[],
  ): Promise<void> {
    for (const indicator of initialIndicators) {
      if (indicator.indicatorType !== undefined) {
        continue;
      }

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

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

      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,
        indicator.config.label,
        compareMode,
        indicator.paneId,
      );
    }
  }

  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,
        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.hasPercentageComparison === hasPercentageComparison
    ) {
      return;
    }

    const hadPercentageComparison = this.hasPercentageComparison;

    this.hasPercentageComparison = hasPercentageComparison;

    if (
      hasPercentageComparison &&
      !hadPercentageComparison
    ) {
      priceScale.setMode(PriceScaleMode.Percentage);
      return;
    }

    if (
      !hasPercentageComparison &&
      hadPercentageComparison &&
      priceScale.getMode() === PriceScaleMode.Percentage
    ) {
      priceScale.setMode(PriceScaleMode.Normal);
    }
  }

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

    let hasPercentageComparison = false;

    const leftScalePaneIds = new Set<number>();
    const additionalPaneIds = new Set<number>();

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

      if (entry.mode === CompareMode.Percentage) {
        hasPercentageComparison = true;
      }

      if (entry.mode === CompareMode.NewScale) {
        leftScalePaneIds.add(pane.getId());
      }

      if (!pane.isMainPane()) {
        additionalPaneIds.add(pane.getId());
      }
    }

    const mainLeftPriceScale = mainPane.getPriceScale(
      Direction.Left,
    );

    const mainRightPriceScale = mainPane.getPriceScale(
      Direction.Right,
    );

    mainLeftPriceScale.setVisible(
      leftScalePaneIds.has(mainPane.getId()),
    );

    mainRightPriceScale.setVisible(true);

    this.syncPercentageMode(
      mainRightPriceScale,
      hasPercentageComparison,
    );

    const additionalPaneIdsList = Array.from(
      additionalPaneIds.values(),
    );

    for (
      let index = 0;
      index < additionalPaneIdsList.length;
      index += 1
    ) {
      const pane = this.paneManager.getPaneById(
        additionalPaneIdsList[index],
      );

      if (!pane) {
        continue;
      }

      pane.getPriceScale(Direction.Right).setVisible(true);

      pane
        .getPriceScale(Direction.Left)
        .setVisible(leftScalePaneIds.has(pane.getId()));
    }

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

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

      const pane = entry.entity.getPane();

      if (!pane.isMainPane()) {
        series.applyOptions({
          priceScaleId: Direction.Right,
        });

        continue;
      }

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

    this.paneManager.refreshPriceScaleControls();
  }
}

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

  return {
    newPane: true,
    label: symbol,
    series: [
      {
        name: 'Line', // todo: change with enum
        id: `compare-${crypto.randomUUID()}`,
        seriesOptions: {
          visible: true,
          color: getPaletteColorFromIndex(
            reservedColors,
            0,
          ),
        },
      },
    ],
  };
};