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


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

import { Indicator } from '@core/Indicator';
import { emaIndicator } from '@core/Indicators/ema';
import { macdHist, macdLine, macdSignal } from '@core/Indicators/macd';
import { smaIndicator } from '@core/Indicators/sma';
import { volume } from '@core/Indicators/volume';
import { SerieData } from '@core/Series/BaseSeries';

import { Candle } from '@lib';
import { getThemeStore } from '@src/theme';
import { Direction, IndicatorConfig, IndicatorSettings, IndicatorsIds, LineCandle } from '@src/types';

export type ChartTypeToCandleData = {
  ['Bar']: Candle;
  ['Candlestick']: Candle;
  ['Area']: LineCandle;
  ['Baseline']: LineCandle;
  ['Line']: LineCandle;
  ['Histogram']: LineCandle;
  ['Custom']: Candle;
};

export interface IndicatorDataFormatter<T extends SeriesType> {
  mainSeriesData: SerieData[];
  selfData: ChartTypeToCandleData[T][];
  candle?: SerieData;
  indicatorReference?: Indicator;
  settings?: IndicatorSettings;
}

export const indicatorLabelById = {
  [IndicatorsIds.Volume]: 'Объём',
  [IndicatorsIds.SMA]: 'SMA',
  [IndicatorsIds.EMA]: 'EMA',
  [IndicatorsIds.MACD]: 'MACD',
} as const satisfies Record<IndicatorsIds, string>;

export const indicatorsMap: Partial<Record<IndicatorsIds, IndicatorConfig>> = {
  [IndicatorsIds.Volume]: {
    series: [
      {
        name: 'Histogram',
        id: 'volume',
        priceScaleOptions: {
          scaleMargins: { top: 0.7, bottom: 0 },
        },
        seriesOptions: {
          priceScaleId: 'vol',
          priceFormat: {
            type: 'volume',
          },
        },
        dataFormatter: (params) => volume(params as IndicatorDataFormatter<'Histogram'>),
      },
    ],
  },
  [IndicatorsIds.SMA]: {
    series: [
      {
        name: 'Line', // todo: change with enum
        id: 'sma',
        seriesOptions: {
          color: getThemeStore().colors.indicatorLineSma,
        },
        dataFormatter: (params) => smaIndicator(params as IndicatorDataFormatter<'Line'>, 10),
      },
    ],
    settings: [
      { type: 'number', key: 'length', label: 'Длина', defaultValue: 10, min: 1 },
      {
        type: 'select',
        key: 'source',
        label: 'Данные',
        defaultValue: 'close',
        options: [
          { label: 'Цена открытия', value: 'open' },
          { label: 'Максимум', value: 'high' },
          { label: 'Минимум', value: 'low' },
          { label: 'Цена закрытия', value: 'close' },
        ],
      },
      { type: 'number', key: 'offset', label: 'Отступ', defaultValue: 0, min: -100, max: 100 },
    ],
  },
  [IndicatorsIds.EMA]: {
    series: [
      {
        name: 'Line', // todo: change with enum
        id: 'ema',
        seriesOptions: {
          color: getThemeStore().colors.indicatorLineEma,
        },
        dataFormatter: (params) => {
          return emaIndicator(params as IndicatorDataFormatter<'Line'>, 25);
        },
      },
    ],
    settings: [
      { type: 'number', key: 'length', label: 'Длина', defaultValue: 10, min: 1 },
      {
        type: 'select',
        key: 'source',
        label: 'Данные',
        defaultValue: 'close',
        options: [
          { label: 'Цена открытия', value: 'open' },
          { label: 'Максимум', value: 'high' },
          { label: 'Минимум', value: 'low' },
          { label: 'Цена закрытия', value: 'close' },
        ],
      },
      { type: 'number', key: 'offset', label: 'Отступ', defaultValue: 0, min: -100, max: 100 },
    ],
  },
  [IndicatorsIds.MACD]: {
    newPane: true,
    series: [
      {
        name: 'Line', // todo: change with enum
        id: 'longEma',
        dataFormatter: (params) => {
          return emaIndicator(params as IndicatorDataFormatter<'Line'>, 26);
        },
        seriesOptions: {
          priceScaleId: 'macd_emas',
          visible: false,
          lastValueVisible: false,
          color: getThemeStore().colors.chartPriceLineText,
        },
      },
      {
        name: 'Line', // todo: change with enum
        id: 'shortEma',
        dataFormatter: (params) => {
          return emaIndicator(params as IndicatorDataFormatter<'Line'>, 12);
        },
        seriesOptions: {
          priceScaleId: 'macd_emas',
          visible: false,
          lastValueVisible: false,
          color: getThemeStore().colors.chartPriceLineText,
        },
      },
      {
        name: 'Line', // todo: change with enum
        id: 'macd',
        priceScaleOptions: {
          mode: PriceScaleMode.Normal,
        },
        dataFormatter: (params) => macdLine(params as IndicatorDataFormatter<'Line'>),
        seriesOptions: {
          priceScaleId: Direction.Right,
          lastValueVisible: false,
          color: getThemeStore().colors.indicatorLineSma,
        },
      },
      {
        name: 'Line', // todo: change with enum
        id: 'macd_signal',
        priceScaleOptions: {
          mode: PriceScaleMode.Normal,
        },
        dataFormatter: (params) => macdSignal(params as IndicatorDataFormatter<'Line'>),
        seriesOptions: {
          priceScaleId: Direction.Right,
          lastValueVisible: false,
          color: getThemeStore().colors.chartCandleWickUp,
        },
      },
      {
        name: 'Histogram', // todo: change with enum
        id: 'hist',
        priceScaleOptions: {
          autoScale: true,
        },
        seriesOptions: {
          priceScaleId: Direction.Right,
          lastValueVisible: false,
        },
        dataFormatter: (params) => macdHist(params as IndicatorDataFormatter<'Histogram'>),
      },
    ],
  },
};


import { IChartApi } from 'lightweight-charts';
import { BehaviorSubject, Observable, Subscription, throttleTime } from 'rxjs';

import { DataSource } from '@core/DataSource';
import { DOMObject, DOMObjectParams } from '@core/DOMObject';
import { Pane } from '@core/Pane';
import { PaneManager } from '@core/PaneManager';

import { indicatorsMap } from '@src/core/Indicators';
import { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ChartTypeOptions, IndicatorConfig, IndicatorsIds } from '@src/types';
import { ensureDefined } from '@src/utils';

type IIndicator = DOMObject;

export interface IndicatorParams extends DOMObjectParams {
  mainSymbol$: Observable<string>;
  lwcChart: IChartApi;
  dataSource: DataSource;
  paneManager: PaneManager;
  chartOptions?: ChartTypeOptions;
  config?: IndicatorConfig;
}

export class Indicator extends DOMObject implements IIndicator {
  private series: SeriesStrategies[] = [];
  private seriesMap: Map<string, SeriesStrategies> = new Map();
  private lwcChart: IChartApi;
  private associatedPane: Pane;

  private subscriptions = new Subscription();

  constructor({
    id,
    lwcChart,
    dataSource,
    zIndex,
    onDelete,
    moveUp,
    moveDown,
    mainSymbol$,
    paneManager,
    config,
  }: IndicatorParams) {
    super({ id: id as string, zIndex, onDelete, moveUp, moveDown });
    this.lwcChart = lwcChart;

    const indicatorConfig = indicatorsMap[id as IndicatorsIds] ?? config;

    const { series, newPane } = ensureDefined(indicatorConfig);

    this.associatedPane = newPane ? paneManager.addPane() : paneManager.getMainPane();

    series.forEach(({ name, id: serieId, dataFormatter, seriesOptions, priceScaleOptions }) => {
      const serie = SeriesFactory.create(name!)({
        lwcChart,
        dataSource,
        customFormatter: dataFormatter,
        seriesOptions,
        priceScaleOptions,
        mainSymbol$,
        mainSerie$: this.associatedPane.getMainSerie(),
        showSymbolLabel: false,
        paneIndex: this.associatedPane.getId(),
        indicatorReference: this,
      });

      this.seriesMap.set(serieId, serie);

      this.series.push(serie);
    });

    this.associatedPane.setIndicator(id as IndicatorsIds, this);
  }

  public subscribeDataChange(handler: () => void): Subscription {
    // todo: возможно нужно переписать завязавшись гна this.associatedPane.getMainSerie()
    const firer = new BehaviorSubject<number>(0);
    this.series.forEach((serie) => {
      const handle = () => {
        firer.next(firer.value + 1);
      };

      serie.subscribeDataChanged(handle);

      this.subscriptions.add(() => serie.unsubscribeDataChanged(handle));
    });

    return firer.pipe(throttleTime(500)).subscribe(() => {
      handler();
    });
  }

  public getPane(): Pane {
    return this.associatedPane;
  }

  public getSeriesMap(): Map<string, SeriesStrategies> {
    return this.seriesMap;
  }

  public show() {
    this.series.forEach((s) => {
      s.show();
    });
    super.show();
  }

  public hide() {
    this.series.forEach((s) => {
      s.hide();
    });
    super.hide();
  }

  // destroy и delete принципиально отличаются!
  // delete вызовет destroy в конце концов. По сути - это destroy с сайд-эффектом в eventManager
  public delete() {
    super.delete();
  }

  // destroy и delete принципиально отличаются!
  public destroy() {
    this.subscriptions.unsubscribe();
    this.series.forEach((s) => {
      s.destroy();
    });
    this.associatedPane.removeIndicator(this.id as IndicatorsIds);
  }
}



import { MouseEventParams, Point, Time } from 'lightweight-charts';

import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';

import { ChartMouseEvents } from '@core/ChartMouseEvents';

import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { indicatorLabelById } from '@src/core/Indicators';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { CompareMode, IndicatorLabel, IndicatorsIds, OHLCConfig } from '@src/types';

export interface LegendParams {
  config: OHLCConfig;
  eventManager: EventManager;
  indicators: BehaviorSubject<Map<IndicatorsIds, Indicator>>;
  subscribeChartEvent: ChartMouseEvents['subscribe'];
  mainSeries: BehaviorSubject<SeriesStrategies | null> | null;
  paneId: number;
}

export interface Ohlc {
  time: Time;
  open?: number;
  high?: number;
  low?: number;
  close?: number;
  value?: number;
  absoluteChange?: number;
  percentageChange?: number;
}

export interface CompareLegendItem {
  symbol: string;
  mode: CompareMode;
  value: string;
  color: string;
}

export interface LegendVM {
  vm: LegendModel;
}

export type LegendModel = {
  name: IndicatorLabel | string;
  isIndicator: boolean;
  values: Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>;
  remove?: () => void;
}[];

export const ohlcValuesToShowForMainSerie = [
  // 'time',
  'open',
  'high',
  'low',
  'close',
  'value',
  // 'absoluteChange',
  'percentageChange',
];

export const ohlcValuesToShowForIndicators = [
  // 'time',
  'open',
  // 'high',
  // 'low',
  // 'close',
  'value',
  // 'absoluteChange',
  // 'percentageChange'
];

export class Legend {
  private eventManager: EventManager;
  private indicators: Map<IndicatorsIds, Indicator> = new Map();
  private config: OHLCConfig;

  private mainSeries!: SeriesStrategies;
  private isChartHovered = false;
  private model$ = new BehaviorSubject<LegendModel>([]);
  private tooltipVisability = new BehaviorSubject<boolean>(false);
  private tooltipPos = new BehaviorSubject<null | Point>(null);
  private paneId: number;

  private subscriptions = new Subscription();

  constructor({ config, eventManager, indicators, subscribeChartEvent, mainSeries, paneId }: LegendParams) {
    this.config = config;
    this.eventManager = eventManager;
    this.paneId = paneId;

    if (!mainSeries) {
      this.subscriptions.add(
        indicators.subscribe((value: Map<IndicatorsIds, Indicator>) => {
          this.indicators = value;
          this.handleIndicatorSeriesDataChange();
        }),
      );
    } else {
      this.subscriptions.add(
        combineLatest([mainSeries, indicators]).subscribe(([mainSerie, inds]) => {
          if (!mainSerie) {
            return;
          }
          this.mainSeries = mainSerie;
          this.indicators = inds;
          this.handleMainSeriesDataChange();
        }),
      );
    }
    this.subscriptions.add(subscribeChartEvent('crosshairMove', this.handleCrosshairMove));
  }

  public subscribeCursorPosition(cb: (point: Point | null) => void) {
    this.subscriptions.add(this.tooltipPos.subscribe(cb));
  }

  public subscribeCursorVisability(cb: (isVisible: boolean) => void) {
    this.subscriptions.add(this.tooltipVisability.subscribe(cb));
  }

  private handleIndicatorSeriesDataChange = () => {
    for (const [_, indicator] of this.indicators) {
      this.subscriptions.add(indicator?.subscribeDataChange(this.updateWithLastCandle));
    }
  };

  private handleMainSeriesDataChange = () => {
    const handler = () => {
      this.updateWithLastCandle();
    };
    this.mainSeries?.subscribeDataChanged(handler);
    this.subscriptions.add(() => this.mainSeries?.unsubscribeDataChanged(handler));
  };

  private updateWithLastCandle = () => {
    if (this.isChartHovered) return;

    const model: LegendModel = [];

    if (this.mainSeries) {
      const series = new Map();

      const serieData = this.mainSeries.getLegendData();

      Object.entries(serieData).forEach(([key, sd]) => {
        series.set(`${key}`, {
          ...sd,
        });
      });

      model.push({
        name: this.mainSeries.options().title,
        values: series as Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>,
        isIndicator: false,
      });
    }

    if (!this.indicators) {
      return;
    }

    for (const [indicatorName, indicator] of this.indicators) {
      const indicatorSeries = new Map();

      for (const [serieName, serie] of indicator.getSeriesMap()) {
        if (!serie.isVisible()) {
          continue;
        }

        const serieData = serie.getLegendData();

        const value = serieData.value ?? serieData.close;

        indicatorSeries.set(serieName, { ...value });
      }

      model.push({
        name: indicatorLabelById[indicatorName],
        values: indicatorSeries as Partial<
          Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>
        >,
        isIndicator: true,
        remove: () => indicator.delete(),
      });
    }

    this.model$.next(model);
  };

  private handleCrosshairMove = (param: MouseEventParams) => {
    // todo: есть одинаковый код с updateWithLastCandle
    if (param.point === undefined || !param.time || param.point.x < 0 || param.point.y < 0) {
      this.tooltipVisability.next(false);

      this.isChartHovered = false;
      this.updateWithLastCandle();
      return;
    }

    if (this.paneId === param.paneIndex) {
      this.tooltipVisability.next(true);
    } else {
      this.tooltipVisability.next(false);
    }

    this.isChartHovered = true;

    const model: LegendModel = [];

    if (this.mainSeries) {
      const series = new Map();
      const serieData = this.mainSeries.getLegendData(param);

      Object.entries(serieData).forEach(([key, sd]) => {
        series.set(`${key}`, {
          ...sd,
        });
      });

      model.push({
        name: this.mainSeries.options().title,
        values: series as Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>,
        isIndicator: false,
      });
    }

    if (!this.indicators) {
      return;
    }

    for (const [indicatorName, indicator] of this.indicators) {
      const indicatorSeries = new Map();

      for (const [serieName, serie] of indicator.getSeriesMap()) {
        if (!serie.isVisible()) {
          continue;
        }

        const serieData = serie.getLegendData(param);

        const value = serieData.value ?? serieData.close;

        indicatorSeries.set(serieName, value);
      }

      model.push({
        name: indicatorLabelById[indicatorName],
        values: indicatorSeries as Partial<
          Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>
        >,
        isIndicator: true,
        remove: () => indicator.delete(),
      });
    }

    this.model$.next(model);
    this.tooltipPos.next(param.point);
  };

  public getConfig(): OHLCConfig {
    return this.config;
  }

  public getLegendViewModel(): Observable<LegendModel> {
    return this.model$;
  }

  public destroy = () => {
    this.subscriptions.unsubscribe();

    this.model$.complete();
    this.tooltipVisability.complete();
    this.tooltipPos.complete();
  };
}


import { IChartApi } from 'lightweight-charts';
import { BehaviorSubject, 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 { DOMObject } from '@src/core/DOMObject';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ChartTypeOptions, IndicatorsIds } from '@src/types';

interface SeriesParams {
  eventManager: EventManager;
  dataSource: DataSource;
  mainSerie$: BehaviorSubject<SeriesStrategies | null>;
  lwcChart: IChartApi;
  paneManager: PaneManager;
  DOM: DOMModel;
  chartOptions?: ChartTypeOptions;
}

export class IndicatorManager {
  private eventManager: EventManager;
  private lwcChart: IChartApi;
  private chartOptions?: ChartTypeOptions;

  private entities$: BehaviorSubject<Indicator[]> = new BehaviorSubject<Indicator[]>([]);
  private indicatorsMap$: BehaviorSubject<Map<IndicatorsIds, Indicator>> = new BehaviorSubject(new Map());
  private DOM: DOMModel;

  constructor({ eventManager, dataSource, lwcChart, DOM, chartOptions, mainSerie$, paneManager }: SeriesParams) {
    this.eventManager = eventManager;
    this.lwcChart = lwcChart;
    this.chartOptions = chartOptions;
    this.DOM = DOM;

    this.eventManager.getUserIndicatorsList().subscribe((ids: Set<IndicatorsIds>) => {
      const indicatorsMap = new Map(this.indicatorsMap$.value);

      ids.forEach((id: IndicatorsIds) => {
        if (!indicatorsMap.has(id)) {
          const ind = this.addEntity<Indicator>(
            (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) =>
              new Indicator({
                id,
                lwcChart,
                mainSymbol$: this.eventManager.getSymbol(),
                dataSource,
                chartOptions,
                zIndex,
                onDelete: this.deleteIndicator,
                moveUp,
                moveDown,
                paneManager,
              }),
          );

          indicatorsMap.set(id, ind);
        }
      });

      // todo: move to deleteSeries
      const indicatorsIdsToDelete = Array.from(indicatorsMap.keys()).filter((existingKey) => {
        return !ids.has(existingKey);
      });

      indicatorsIdsToDelete.forEach((s: IndicatorsIds) => {
        // todo: move to deleteSeries
        const indicatorToDestroy = indicatorsMap.get(s);

        if (!indicatorToDestroy) return;

        this.removeEntity(indicatorToDestroy);

        indicatorToDestroy.destroy();
        indicatorsMap.delete(s);
      });

      this.indicatorsMap$.next(indicatorsMap);
      this.entities$.next(Array.from(indicatorsMap.values()));
    });
  }

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

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

  public deleteIndicator = (id: string) => {
    this.eventManager.toggleUserIndicator(id as IndicatorsIds);
  };

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



import { IChartApi, IPaneApi, 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, DrawingsNames } from '@core/DrawingsManager';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { Legend } from '@core/Legend';
import { ReactRenderer } from '@core/ReactRenderer';
import { TooltipService } from '@core/Tooltip';
import { UIRenderer } from '@core/UIRenderer';
import { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { IndicatorsIds, OHLCConfig, TooltipConfig } from '@src/types';
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;
}

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

export class Pane {
  private readonly id: number;
  private 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<IndicatorsIds, Indicator>> = new BehaviorSubject<
    Map<IndicatorsIds, Indicator>
  >(new Map());

  private lwcPane: IPaneApi<Time>;
  private lwcChart: IChartApi;

  private eventManager: EventManager;
  private drawingsManager: DrawingsManager;

  private legendContainer!: HTMLElement;
  private paneOverlayContainer!: HTMLElement;
  private legendRenderer!: UIRenderer;
  private tooltipRenderer: UIRenderer | undefined;

  private mainSerieSub!: Subscription;
  private subscribeChartEvent: ChartMouseEvents['subscribe'];
  private onDelete: () => void;
  private subscriptions = new Subscription();

  constructor({
    lwcChart,
    eventManager,
    dataSource,
    DOM,
    isMainPane,
    ohlcConfig,
    id,
    basedOn,
    subscribeChartEvent,
    tooltipConfig,
    onDelete,
    chartContainer,
  }: PaneParams) {
    this.onDelete = onDelete;
    this.eventManager = eventManager;
    this.lwcChart = lwcChart;
    this.subscribeChartEvent = subscribeChartEvent;
    this.isMain = isMainPane ?? false;
    this.id = id;

    this.initializeLegend({ ohlcConfig });

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

    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,
    });

    this.subscriptions.add(
      // todo: переедет в пейн
      this.drawingsManager.entities().subscribe((drawings) => {
        const hasRuler = drawings.some((drawing) => drawing.id.startsWith(DrawingsNames.ruler));
        this.legendContainer.style.display = hasRuler ? 'none' : '';
      }),
    );
  }

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

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

  public setIndicator(indicatorName: IndicatorsIds, indicator: Indicator): void {
    const map = this.indicatorsMap.value;

    map.set(indicatorName, indicator);

    this.indicatorsMap.next(map);
  }

  public removeIndicator(indicatorName: IndicatorsIds): void {
    const map = this.indicatorsMap.value;

    map.delete(indicatorName);

    this.indicatorsMap.next(map);

    if (map.size === 0 && !this.isMain) {
      this.destroy();
    }
  }

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

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

    requestAnimationFrame(() => {
      setTimeout(() => {
        const lwcPaneElement = this.lwcPane.getHTMLElement();
        if (!lwcPaneElement) return;
        lwcPaneElement.style.position = 'relative';
        lwcPaneElement.appendChild(legendContainer);
        lwcPaneElement.appendChild(paneOverlayContainer);
      }, 0);
    });

    // 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,
      // todo: throw isMainPane
    });

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

  private initializeMainSerie({ lwcChart, dataSource }: { lwcChart: IChartApi; dataSource: DataSource }) {
    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);
    });
  }

  public destroy() {
    this.subscriptions.unsubscribe();
    this.tooltip?.destroy();
    this.legend?.destroy();
    this.legendRenderer.destroy();

    this.tooltipRenderer?.destroy();
    this.indicatorsMap.complete();

    this.mainSerieSub?.unsubscribe();
    try {
      this.lwcChart.removePane(this.id);
    } catch (e) {
      console.log(e);
    }

    this.onDelete();
  }
}


import { DataSource } from '@core/DataSource';
import { DrawingsManager } from '@core/DrawingsManager';
import { Pane, PaneParams } from '@core/Pane';

type PaneManagerParams = Omit<PaneParams, 'isMainPane' | 'id' | 'basedOn' | 'onDelete'>;

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

export class PaneManager {
  private mainPane: Pane;
  private paneChartInheritedParams: PaneManagerParams & { isMainPane: boolean };
  private panesMap: Map<number, Pane> = new Map<number, Pane>();
  private panesIdIterator = 0;

  constructor(params: PaneManagerParams) {
    this.paneChartInheritedParams = { ...params, isMainPane: false };

    this.mainPane = new Pane({ ...params, isMainPane: true, id: 0, onDelete: () => {} });

    this.panesMap.set(this.panesIdIterator++, this.mainPane);
  }

  public getPanes() {
    return this.panesMap;
  }

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

  public addPane(dataSource?: DataSource): Pane {
    const id = this.panesIdIterator++;

    const pane = new Pane({
      ...this.paneChartInheritedParams,
      id,
      dataSource: dataSource ?? null,
      basedOn: dataSource ? undefined : this.mainPane,
      onDelete: () => {
        this.panesIdIterator--;
        this.panesMap.delete(id);
      },
    });

    this.panesMap.set(id, pane);

    return pane;
  }

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



import { combineLatest, Subscription } from 'rxjs';

import { ControlBar } from '@components/ControlBar';
import { Footer } from '@components/Footer';

import { Header } from '@components/Header';
import { DataSource, DataSourceParams } from '@core/DataSource';
import { ModalRenderer } from '@core/ModalRenderer';
import { SettingsModal } from '@src/components/SettingsModal';

import Toolbar from '@src/components/Toolbar';
import { CompareManager } from '@src/core/CompareManager';
import { FullscreenController } from '@src/core/Fullscreen';

import { configureThemeStore } from '@src/theme/store';
import { ThemeKey, ThemeMode } from '@src/theme/types';
import { ChartSeriesType, ChartTypeOptions, IndicatorsIds, OHLCConfig, TooltipConfig } from '@src/types';
import { Timeframes } from '@src/types/timeframes';

import { setPricePrecision } from '@src/utils';

import { Chart } from './Chart';
import { ChartSettings, ChartSettingsSource } from './ChartSettings';
import { ContainerManager } from './ContainerManager';
import { EventManager } from './EventManager';
import { ReactRenderer } from './ReactRenderer';
import { TimeScaleHoverController } from './TimescaleHoverController';
import { UIRenderer } from './UIRenderer';

import 'exchange-elements/dist/fonts/inter/font.css';
import 'exchange-elements/dist/style.css';
import 'exchange-elements/dist/tokens/moex.css';

// todo: forbid @lib in /src
export interface IMoexChart {
  container: HTMLElement;
  supportedTimeframes: Timeframes[];
  initialTimeframe: Timeframes;
  supportedChartSeriesTypes: ChartSeriesType[];
  initialChartSeriesTypes: ChartSeriesType;
  initialSymbol: string;
  getDataSource: DataSourceParams['getData'];
  theme: ThemeKey; // 'mb' | 'mxt' | 'tr'
  ohlc: OHLCConfig;
  initialIndicators?: IndicatorsIds[];
  size?:
    | {
        width: number;
        height: number;
      }
    | false;
  mode?: ThemeMode; // 'light' | 'dark'
  undoRedoEnabled?: boolean;
  showMenuButton?: boolean;
  showBottomPanel?: boolean;
  showControlBar?: boolean;
  showFullscreenButton?: boolean;
  showSettingsButton?: boolean;
  showCompareButton?: boolean;
  /**
   * Дефолтная конфигурация тултипа - всегда показывается по умолчанию.
   * При добавлении/изменении полей в конфиге - они объединяются с дефолтными значениями.
   *
   * Полная кастомизация:
   * @example
   * ```typescript
   *    tooltipConfig: {
   *      time: { visible: true, label: 'Дата и время' },
   *      symbol: { visible: true, label: 'Инструмент' },
   *      close: { visible: true, label: 'Курс' },
   *      change: { visible: true, label: 'Изменение' },
   *      volume: { visible: true, label: 'Объем' },
   *      open: { visible: false },
   *      high: { visible: false },
   *      low: { visible: false }
   *    }
   *```
   */
  chartSettings?: ChartSettingsSource;
  chartOptions?: ChartTypeOptions; // todo: разнести по разным полям в соответствии с тиами графика
  tooltipConfig?: TooltipConfig;
  openCompareModal?: () => void;
}

export class MoexChart {
  private chart: Chart;
  private resizeObserver?: ResizeObserver;
  private eventManager: EventManager;
  private rootContainer: HTMLElement;

  private headerRenderer: UIRenderer;
  private modalRenderer: ModalRenderer;
  private toolbarRenderer: UIRenderer | undefined;
  private controlBarRenderer?: UIRenderer;
  private footerRenderer?: UIRenderer;

  private timeScaleHoverController: TimeScaleHoverController;
  private dataSource: DataSource;

  private subscriptions = new Subscription();

  private fullscreen: FullscreenController;

  constructor(config: IMoexChart) {
    setPricePrecision(config.ohlc.precision);

    this.eventManager = new EventManager({
      initialTimeframe: config.initialTimeframe,
      initialSeries: config.initialChartSeriesTypes,
      initialSymbol: config.initialSymbol,
      initialChartOptions: config.chartOptions,
    });

    if (config.initialIndicators) {
      this.eventManager.setUserIndicators(config.initialIndicators);
    }

    if (config.chartSettings) {
      this.setSettings(config.chartSettings);
    }

    this.dataSource = new DataSource({
      getData: config.getDataSource,
      eventManager: this.eventManager,
    });

    this.rootContainer = config.container;

    this.fullscreen = new FullscreenController(this.rootContainer);

    const store = configureThemeStore(config);

    const {
      chartAreaContainer,
      toolBarContainer,
      headerContainer,
      modalContainer,
      controlBarContainer,
      footerContainer,
      toggleToolbar, // todo: move this function to toolbarRenderer
    } = ContainerManager.createContainers({
      parentContainer: this.rootContainer,
      showBottomPanel: config.showBottomPanel, // todo: apply config.showBottomPanel in FullscreenController
      showMenuButton: config.showMenuButton,
    });

    this.modalRenderer = new ModalRenderer(modalContainer);

    this.chart = new Chart({
      eventManager: this.eventManager,
      modalRenderer: this.modalRenderer,
      container: chartAreaContainer,
      theme: store.theme,
      mode: store.mode,
      seriesTypes: config.supportedChartSeriesTypes,
      dataSource: this.dataSource,
      chartOptions: config.chartOptions, // todo: remove, use only model from eventManager
      ohlcConfig: config.ohlc, // todo: omptimize
      tooltipConfig: config.tooltipConfig ?? {},
    });

    this.subscriptions.add(
      combineLatest([store.theme$, store.mode$]).subscribe(([theme, mode]) => {
        this.chart.updateTheme(theme, mode);

        document.documentElement.dataset.theme = theme;
        document.documentElement.dataset.mode = mode;
      }),
    );

    this.headerRenderer = new ReactRenderer(headerContainer);
    this.toolbarRenderer = new ReactRenderer(toolBarContainer);

    if (config.showControlBar) {
      this.controlBarRenderer = new ReactRenderer(controlBarContainer);
    }

    if (config.showBottomPanel) {
      this.footerRenderer = new ReactRenderer(footerContainer);
    }

    this.headerRenderer.renderComponent(
      <Header
        timeframes={config.supportedTimeframes}
        selectedTimeframeObs={this.eventManager.getTimeframeObs()}
        setTimeframe={(value) => {
          this.eventManager.setTimeframe(value);
        }}
        seriesTypes={config.supportedChartSeriesTypes}
        selectedSeriesObs={this.eventManager.getSelectedSeries()}
        setSelectedSeries={(value) => {
          this.eventManager.setSeriesSelected(value);
        }}
        showSettingsModal={
          config.showSettingsButton
            ? () =>
                this.modalRenderer.renderComponent(
                  <SettingsModal
                    // todo: deal with onSave
                    changeTimeFormat={(format) => this.eventManager.setTimeFormat(format)}
                    changeDateFormat={(format) => this.eventManager.setDateFormat(format)}
                    chartDateTimeFormatObs={this.eventManager.getChartOptionsModel()}
                  />,
                  { title: 'Настройки' },
                )
            : undefined
        }
        toggleUserIndicator={this.eventManager.toggleUserIndicator}
        activeIndicatorsObs={this.eventManager.getUserIndicatorsListArr()}
        showMenuButton={config.showMenuButton}
        showFullscreenButton={config.showFullscreenButton}
        fullscreen={this.fullscreen}
        undoRedo={config.undoRedoEnabled ? this.eventManager.getUndoRedo() : undefined}
        toggleToolbarVisible={toggleToolbar}
        showCompareButton={!!config.showCompareButton}
        openCompareModal={config.openCompareModal ? config.openCompareModal : undefined}
        isMXT={config.theme === 'mxt'}
      />,
    );

    this.timeScaleHoverController = new TimeScaleHoverController({
      eventManager: this.eventManager,
      controlBarContainer,
      chartContainer: chartAreaContainer,
    });

    if (config.showMenuButton) {
      this.toolbarRenderer.renderComponent(
        <Toolbar
          toggleDOM={this.chart.getDom().toggleDOM}
          addDrawing={(name) => {
            // todo: deal with new panes logic
            this.chart.getDrawingsManager().addDrawing(name);
          }}
        />,
      );
    }

    if (this.controlBarRenderer && config.showControlBar) {
      this.controlBarRenderer.renderComponent(
        <ControlBar
          scroll={this.chart.scrollTimeScale}
          zoom={this.chart.zoomTimeScale}
          reset={this.chart.resetZoom}
          visible={this.eventManager.getControlBarVisible()}
        />,
      );
    }

    if (this.footerRenderer && config.showBottomPanel) {
      this.footerRenderer.renderComponent(
        <Footer
          supportedTimeframes={config.supportedTimeframes}
          setInterval={this.eventManager.setInterval}
          intervalObs={this.eventManager.getInterval()}
        />,
      );
    }
  }

  public setSettings(settings: ChartSettingsSource): void {
    this.eventManager.importChartSettings(settings);
  }

  public getSettings(): ChartSettings {
    return this.eventManager.exportChartSettings();
  }

  public getRealtimeApi() {
    return this.chart.getRealtimeApi();
  }

  // todo: описать в доке
  public getCompareManager(): CompareManager {
    return this.chart.getCompareManager();
  }

  public setSymbol(symbol: string): void {
    if (!symbol) return;

    this.eventManager.setSymbol(symbol);
  }

  /**
   * Уничтожение графика и очистка ресурсов
   * @returns void
   */
  destroy(): void {
    this.headerRenderer.destroy();
    this.subscriptions.unsubscribe();
    this.timeScaleHoverController.destroy();

    if (this.resizeObserver) {
      this.resizeObserver.disconnect();
      this.resizeObserver = undefined;
    }

    if (this.controlBarRenderer) {
      this.controlBarRenderer.destroy();
    }

    if (this.footerRenderer) {
      this.footerRenderer.destroy();
    }

    if (this.chart) {
      this.chart.destroy();
    }

    if (this.eventManager) {
      this.eventManager.destroy();
    }

    ContainerManager.clearContainers(this.rootContainer);
  }
}



import { ReactElement } from 'react';

import { Modal, ModalProps } from '@components/Modal';
import { ReactRenderer } from '@core/ReactRenderer';

export class ModalRenderer extends ReactRenderer {
  constructor(container: HTMLElement) {
    super(container);
  }

  public override renderComponent(component: ReactElement, props?: ModalProps): void {
    if (!props) {
      throw new Error('[Modal]: modal props are not defined');
    }

    super.renderComponent(
      <Modal
        {...props}
        onHide={this.clear}
        appendTo={this.container as HTMLElement}
      >
        {component}
      </Modal>,
    );
  }
}



import { Button } from 'exchange-elements/v2';

import { Observable } from 'rxjs';

import { TrashIcon } from '@components/Icon';
import { LegendModel, ohlcValuesToShowForMainSerie } from '@core/Legend';

import { OHLCConfig } from '@src/types';
import { formatDisplayText, useObservable } from '@src/utils';

import styles from './index.module.scss';

export interface LegendProps {
  ohlcConfig?: OHLCConfig;
  viewModel: Observable<LegendModel>;
}

const mainSerieKeys = new Set(ohlcValuesToShowForMainSerie);

function getEntries(values: LegendModel[number]['values']) {
  return values instanceof Map ? Array.from(values.entries()) : Object.entries(values);
}

export const LegendComponent = ({ ohlcConfig, viewModel }: LegendProps) => {
  const model = useObservable(viewModel);
  const showMainSerieValues = Boolean(ohlcConfig?.show);

  return (
    <section className={styles.legend}>
      {model?.map((item) => (
        <div
          key={`legend-${item.name}`}
          className={styles.row}
        >
          <div className={styles.symbol}>{item.name}</div>

          {getEntries(item.values).map(([key, value]) => {
            if (!item.isIndicator && (!showMainSerieValues || !mainSerieKeys.has(key))) {
              return null;
            }

            const text = formatDisplayText(value.value);

            if (!text) {
              return null;
            }

            return (
              <div
                key={`${item.name}-${key}`}
                className={styles.item}
              >
                {value.name && <span>{value.name}</span>}
                <span style={{ color: value.color }}>{text}</span>
              </div>
            );
          })}

          {item.remove && (
            <Button
              size="sm"
              className={styles.button}
              onClick={item.remove}
              label={<TrashIcon />}
            />
          )}
        </div>
      ))}
    </section>
  );
};