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


import {
  BarData,
  BarPrice,
  BarsInfo,
  Coordinate,
  CreatePriceLineOptions,
  CustomData,
  DataChangedHandler,
  DeepPartial,
  HistogramData,
  IChartApi,
  IPaneApi,
  IPriceFormatter,
  IPriceLine,
  IPriceScaleApi,
  IRange,
  ISeriesApi,
  ISeriesPrimitive,
  LineData,
  MismatchDirection,
  MouseEventParams,
  PriceScaleOptions,
  SeriesDataItemTypeMap,
  SeriesDefinition,
  SeriesOptionsMap,
  SeriesPartialOptionsMap,
  SeriesType,
  Time,
} from 'lightweight-charts';

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

import { DataSource } from '@core/DataSource';
import { Indicator } from '@core/Indicator';
import { ChartTypeToCandleData, IndicatorDataFormatter } from '@core/Indicators';
import { Ohlc } from '@core/Legend';
import { MAIN_PANE_INDEX } from '@src/constants';

import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { Candle, Direction } from '@src/types';
import { formatPrice, getPricePrecisionStep, isBarData, isLineData, normalizeSeriesData } from '@src/utils';

export interface SerieData {
  time: Time;
  customValues: Candle;
}

export interface CreateSeriesParams<TSeries extends SeriesType> {
  chart: IChartApi;
  seriesOptions?: SeriesPartialOptionsMap[TSeries];
  paneIndex?: number;
  priceScaleOptions?: DeepPartial<PriceScaleOptions>;
}

export interface IBaseSeries<TSeries extends SeriesType> extends ISeriesApi<TSeries> {
  getLwcSeries: () => ISeriesApi<TSeries>;
  getLegendData: (
    param?: MouseEventParams,
  ) => Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>;
}

export interface BaseSeriesParams<TSeries extends SeriesType = SeriesType> {
  lwcChart: IChartApi;
  dataSource: DataSource;
  mainSymbol$: Observable<string>;
  mainSerie$: BehaviorSubject<SeriesStrategies | null>;
  customFormatter?: (params: IndicatorDataFormatter<TSeries>) => SeriesDataItemTypeMap<Time>[TSeries][];
  seriesOptions?: SeriesPartialOptionsMap[TSeries];
  priceScaleOptions?: DeepPartial<PriceScaleOptions>;
  showSymbolLabel?: boolean;
  paneIndex?: number;
  indicatorReference?: Indicator;
}

function applyMoscowTimezone(candles: Candle[]): ChartTypeToCandleData['Candlestick'][] {
  // todo this approach is too slow, for timezones impl we should shift timeScale instead of mutating the data
  const offsetMinutes = -180; // utc.time - moscow.time in minutes
  const secondsInMinute = 60;

  return candles
    .filter((c) => typeof c.time === 'number' && Number.isFinite(c.time))
    .map((c) => ({
      ...c,
      time: (c.time as number) - offsetMinutes * secondsInMinute,
    }));
}

export abstract class BaseSeries<TSeries extends SeriesType> implements IBaseSeries<TSeries> {
  protected lwcSeries: ISeriesApi<TSeries>;
  protected customFormatter:
    | undefined
    | ((params: IndicatorDataFormatter<TSeries>) => SeriesDataItemTypeMap<Time>[TSeries][]);

  protected lwcChart: IChartApi;
  protected mainSymbol$: Observable<string>;
  protected mainSerie$: BehaviorSubject<SeriesStrategies | null>;
  protected paneIndex: number | null = null;
  protected indicatorReference: Indicator | null = null;
  protected showSymbolLabel: boolean;

  private subscriptions = new Subscription();
  private dataSub: Subscription | null = null;
  private realtimeSub: Subscription | null = null;

  constructor({
    lwcChart,
    mainSymbol$,
    mainSerie$,
    customFormatter,
    seriesOptions,
    priceScaleOptions,
    showSymbolLabel = true,
    paneIndex,
    indicatorReference,
  }: BaseSeriesParams<TSeries>) {
    this.lwcSeries = this.createSeries({
      chart: lwcChart,
      seriesOptions,
      paneIndex,
      priceScaleOptions,
    });
    this.lwcChart = lwcChart;
    this.customFormatter = customFormatter;
    this.mainSymbol$ = mainSymbol$;
    this.mainSerie$ = mainSerie$;
    this.showSymbolLabel = showSymbolLabel;
    this.indicatorReference = indicatorReference ?? null;
  }

  public getLegendData = (
    param?: MouseEventParams,
  ): Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>> => {
    if (!param) {
      const seriesData = this.data();
      if (seriesData.length < 1) return {};

      const dataToFormat = seriesData[seriesData.length - 1];
      const prevBarData = seriesData.length > 1 ? seriesData[seriesData.length - 2] : null;
      return this.formatLegendValues(dataToFormat, prevBarData);
    }

    const dataToFormat = param?.seriesData.get(this.getLwcSeries()) ?? null;
    const prevBarData = this.dataByIndex(param.logical! - 1) ?? null;

    return this.formatLegendValues(dataToFormat, prevBarData);
  };

  public show(): void {
    this.lwcSeries.applyOptions({ ...this.lwcSeries.options(), visible: true });
  }

  public hide(): void {
    this.lwcSeries.applyOptions({ ...this.lwcSeries.options(), visible: false });
  }

  public isVisible(): boolean {
    return this.lwcSeries.options().visible;
  }

  public destroy(): void {
    this.dataSub?.unsubscribe();
    this.realtimeSub?.unsubscribe();
    this.subscriptions.unsubscribe();
    this.lwcChart.removeSeries(this.lwcSeries);
  }

  public getLwcSeries(): ISeriesApi<TSeries> {
    return this.lwcSeries;
  }

  public applyOptions(options: SeriesPartialOptionsMap[TSeries]): void {
    this.lwcSeries.applyOptions(options);
  }

  public attachPrimitive(primitive: ISeriesPrimitive<Time>): void {
    this.lwcSeries.attachPrimitive(primitive);
  }

  public barsInLogicalRange(range: IRange<number>): BarsInfo<Time> | null {
    return this.lwcSeries.barsInLogicalRange(range);
  }

  public coordinateToPrice(coordinate: number): BarPrice | null {
    return this.lwcSeries.coordinateToPrice(coordinate);
  }

  public createPriceLine(options: CreatePriceLineOptions): IPriceLine {
    return this.lwcSeries.createPriceLine(options);
  }

  public data(): readonly SeriesDataItemTypeMap<Time>[TSeries][] {
    return this.lwcSeries.data();
  }

  public dataByIndex(
    logicalIndex: number,
    mismatchDirection?: MismatchDirection,
  ): SeriesDataItemTypeMap<Time>[TSeries] | null {
    return this.lwcSeries.dataByIndex(logicalIndex, mismatchDirection);
  }

  public detachPrimitive(primitive: ISeriesPrimitive<Time>): void {
    this.lwcSeries.detachPrimitive(primitive);
  }

  public getPane(): IPaneApi<Time> {
    return this.lwcSeries.getPane();
  }

  public moveToPane(paneIndex: number): void {
    this.lwcSeries.moveToPane(paneIndex);
  }

  public options(): Readonly<SeriesOptionsMap[TSeries]> {
    return this.lwcSeries.options();
  }

  public priceFormatter(): IPriceFormatter {
    return this.lwcSeries.priceFormatter();
  }

  public priceLines(): IPriceLine[] {
    return this.lwcSeries.priceLines();
  }

  public priceScale(): IPriceScaleApi {
    return this.lwcSeries.priceScale();
  }

  public priceToCoordinate(price: number): Coordinate | null {
    return this.lwcSeries.priceToCoordinate(price);
  }

  public removePriceLine(line: IPriceLine): void {
    this.lwcSeries.removePriceLine(line);
  }

  public seriesOrder(): number {
    return this.lwcSeries.seriesOrder();
  }

  public seriesType(): TSeries {
    return this.lwcSeries.seriesType();
  }

  public setData(data: SeriesDataItemTypeMap<Time>[TSeries][]): void {
    const normalizedData = normalizeSeriesData(data);
    this.lwcSeries.setData(normalizedData);
  }

  public setSeriesOrder(order: number): void {
    this.lwcSeries.setSeriesOrder(order);
  }

  public subscribeDataChanged(handler: DataChangedHandler): void {
    this.lwcSeries.subscribeDataChanged(handler);
  }

  public unsubscribeDataChanged(handler: DataChangedHandler): void {
    this.lwcSeries.unsubscribeDataChanged(handler);
  }

  public update(bar: SeriesDataItemTypeMap<Time>[TSeries], historicalUpdate?: boolean): void {
    const data = this.lwcSeries.data();
    const last = data.length ? data[data.length - 1] : null;

    if (!last) {
      this.lwcSeries.update(bar, false);
      return;
    }

    const lastTime = last.time;
    const nextTime = bar.time;

    const isHist =
      historicalUpdate ?? (typeof lastTime === 'number' && typeof nextTime === 'number' && nextTime < lastTime);

    this.lwcSeries.update(bar, isHist);
  }

  protected createSeries({
    chart,
    seriesOptions,
    paneIndex = MAIN_PANE_INDEX,
    priceScaleOptions = {},
  }: CreateSeriesParams<TSeries>): ISeriesApi<TSeries> {
    this.paneIndex = paneIndex;

    const defaultOptions = this.getDefaultOptions();
    const mergedOptions = {
      ...defaultOptions,
      ...seriesOptions,
    };

    const series = chart.addSeries<TSeries>(this.seriesDefinition(), mergedOptions, paneIndex);

    chart.priceScale(mergedOptions.priceScaleId ?? Direction.Right, paneIndex).applyOptions(priceScaleOptions);

    return series;
  }

  protected abstract dataSourceSubscription(next: Candle[]): void;
  protected abstract seriesDefinition(): SeriesDefinition<TSeries>;
  protected abstract dataSourceRealtimeSubscription(next: Candle): void;
  protected abstract getDefaultOptions(): SeriesPartialOptionsMap[TSeries];
  protected abstract formatMainSerie(inputData: Candle[]): SeriesDataItemTypeMap<Time>[TSeries][];
  protected abstract formatLegendValues(
    currentBar: null | BarData | LineData | HistogramData | CustomData,
    prevBar: null | BarData | LineData | HistogramData | CustomData,
  ): Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>;

  protected applyTimezone(data: Candle[]): Candle[] {
    return applyMoscowTimezone(data);
  }

  protected formatData(inputData: Candle[]): SeriesDataItemTypeMap<Time>[TSeries][] {
    const formatter = this.customFormatter;

    const data = this.applyTimezone(inputData);

    if (formatter) {
      const mainSeriesData = (this.mainSerie$.value?.data() ?? []) as unknown as SerieData[];

      if (data.length === 1) {
        return formatter({
          mainSeriesData,
          selfData: this.data() as unknown as ChartTypeToCandleData[TSeries][],
          candle: this.formatMainSerie(data)[0] as unknown as SerieData,
          indicatorReference: this.indicatorReference ?? undefined,
        });
      }

      return formatter({
        mainSeriesData,
        indicatorReference: this.indicatorReference ?? undefined,
        selfData: this.data() as unknown as ChartTypeToCandleData[TSeries][],
      });
    }

    return this.formatMainSerie(data);
  }

  protected subscribeDataSource = (dataSource: DataSource): void => {
    const minMove = getPricePrecisionStep();

    this.subscriptions.add(
      this.mainSymbol$.pipe(distinctUntilChanged()).subscribe((symbol) => {
        this.lwcSeries.applyOptions({
          // todo: на каждый апдейт dataSource сеттим options. Не оптимально
          title: this.showSymbolLabel ? symbol : '',
          priceFormat: {
            type: 'custom',
            minMove,
            formatter: (price: number) => formatPrice(price),
          },
        });

        this.dataSub?.unsubscribe();
        this.realtimeSub?.unsubscribe();

        this.dataSub = dataSource.subscribe(symbol, (next) => {
          this.dataSourceSubscription(next);
        });

        this.realtimeSub = dataSource.subscribeRealtime(symbol, (next: Candle) => {
          this.dataSourceRealtimeSubscription(next);
        });
      }),
    );
  };
}

export function calcCandleChange(
  prev: BarData | LineData | HistogramData | CustomData | null,
  current: BarData | LineData | HistogramData | CustomData | null,
): (Ohlc & { customValues?: Record<string, any> }) | null {
  if (!current) {
    return null;
  }

  if (!prev) {
    return current;
  }

  if (isBarData(prev) && isBarData(current)) {
    const absoluteChange = current.close - prev.close;
    const percentageChange = ((current.close - prev.close) / prev.close) * 100;

    return {
      ...current,
      absoluteChange,
      percentageChange,
    };
  }

  if (isLineData(prev) && isLineData(current)) {
    const absoluteChange = current.value - prev.value;
    const percentageChange = ((current.value - prev.value) / prev.value) * 100;

    return {
      time: current.time,
      value: current.value,
      high: current.customValues?.high as number,
      low: current.customValues?.low as number,
      absoluteChange,
      percentageChange,
      customValues: current.customValues,
    };
  }

  return null;
}



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 { IndicatorsIds } from '@src/constants';
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 { Locale, setLocale, t } from '@src/translations';
import { Candle, ChartSeriesType, ChartTypeOptions, OHLCConfig, TooltipConfig } from '@src/types';
import { ISerializable, MoexChartSnapshot } from '@src/types/snapshot';
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';
import '../styles/global.scss';

// todo: forbid @lib in /src
export interface ChartCollectionPreset {
  undoRedoEnabled?: boolean;
  showMenuButton?: boolean;
  showBottomPanel?: boolean;
  showControlBar?: boolean;
  showFullscreenButton?: boolean;
  showSettingsButton?: boolean;
  showCompareButton?: boolean;
  showSymbolSearchButton?: 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 }
   *    }
   *```
   */
  tooltipConfig?: TooltipConfig;

  size?:
    | {
        width: number;
        height: number;
      }
    | false;
  supportedTimeframes: Timeframes[];
  supportedChartSeriesTypes: ChartSeriesType[];
  getDataSource: DataSourceParams['getData'];
  startRealtime: (
    getSymbols: () => string[],
    getTimeframe: () => Timeframes,
    update: (symbol: string, candle: Candle) => void,
    periodMs?: number,
  ) => () => void;
  theme: ThemeKey; // 'mb' | 'mxt' | 'tr'
  ohlc: OHLCConfig;
  locale: Locale;
  mode?: ThemeMode; // 'light' | 'dark'
  openCompareModal?: () => void;
  openSymbolSearchModal?: () => void;
}

export interface IMoexChart {
  snapshot: MoexChartSnapshot;
  chartCollectionPreset: ChartCollectionPreset;

  container: HTMLElement;
  lwcInheritedChartOptions?: ChartTypeOptions;
}

export class MoexChart implements ISerializable<MoexChartSnapshot> {
  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;

  private chartCollectionPresetSettings!: ChartCollectionPreset;

  constructor(config: IMoexChart) {
    setLocale(config.chartCollectionPreset.locale);
    this.setup(config);
  }

  private setup = (config: IMoexChart) => {
    this.chartCollectionPresetSettings = config.chartCollectionPreset;

    setPricePrecision(config.chartCollectionPreset.ohlc.precision);

    this.eventManager = new EventManager({
      initialTimeframe: config.snapshot.charts[0].timeframe,
      initialSeries: config.snapshot.charts[0].chartSeriesType,
      initialSymbol: config.snapshot.charts[0].symbol,
      initialChartOptions: config.lwcInheritedChartOptions,
    });

    // todo: сюда прокидывается не подходящий под сигнатуру интерфейс. Функция не работает
    // if (config.lwcInheritedChartOptions) {
    //   this.setSettings(config.lwcInheritedChartOptions);
    // }

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

    this.rootContainer = config.container;

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

    const store = configureThemeStore(config.chartCollectionPreset);

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

    this.modalRenderer = new ModalRenderer(modalContainer);

    this.chart = new Chart({
      params: {
        dataSource: this.dataSource,
        eventManager: this.eventManager,
        modalRenderer: this.modalRenderer,
        ohlcConfig: config.chartCollectionPreset.ohlc, // todo: omptimize
        tooltipConfig: config.chartCollectionPreset.tooltipConfig ?? {},
        panes: config.snapshot.charts[0].panes,
      },
      lwcChartConfig: {
        container: chartAreaContainer,
        seriesTypes: config.chartCollectionPreset.supportedChartSeriesTypes,
        theme: store.theme,
        mode: store.mode,
        chartOptions: config.lwcInheritedChartOptions, // todo: remove, use only model from eventManager
      },
    });

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

    const realtimeParams = this.chart.getRealtimeApi();

    this.subscriptions.add(
      config.chartCollectionPreset.startRealtime(
        realtimeParams.getSymbols,
        realtimeParams.getTimeframe,
        realtimeParams.update,
      ),
    );

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

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

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

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

    this.renderAttachments(config, toggleToolbar);
  };

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

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

  // todo: описать подробнее в доке. Точно ли public?
  public getRealtimeApi() {
    return this.chart.getRealtimeApi();
  }

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

  public setSnapshot(snap: MoexChartSnapshot) {
    const configConstructorLike: IMoexChart = {
      snapshot: snap,
      chartCollectionPreset: this.chartCollectionPresetSettings,
      container: this.rootContainer,
    };
    this.destroy();

    this.setup(configConstructorLike);
  }

  // todo: описать в доке
  public getSnapshot(): MoexChartSnapshot {
    const res = {
      settings: this.getSettings(),
      charts: [this.chart.getSnapshot()], // todo: в будущем может быть несколько инстансов чартов
    };

    return res;
  }

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

    this.eventManager.setSymbol(symbol);
  }

  private renderAttachments(config: IMoexChart, toggleToolbar: () => boolean) {
    this.headerRenderer.renderComponent(
      <Header
        timeframes={config.chartCollectionPreset.supportedTimeframes}
        selectedTimeframeObs={this.eventManager.getTimeframeObs()}
        setTimeframe={(value) => {
          this.eventManager.setTimeframe(value);
        }}
        seriesTypes={config.chartCollectionPreset.supportedChartSeriesTypes}
        selectedSeriesObs={this.eventManager.getSelectedSeries()}
        setSelectedSeries={(value) => {
          this.eventManager.setSeriesSelected(value);
        }}
        showSettingsModal={
          config.chartCollectionPreset.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: t('Settings') },
                )
            : undefined
        }
        addIndicatorToChart={(indicatorType: IndicatorsIds) =>
          this.chart.getIndicatorManager().addIndicator({ indicatorType })
        }
        showMenuButton={!!config.chartCollectionPreset.showMenuButton}
        showFullscreenButton={!!config.chartCollectionPreset.showFullscreenButton}
        fullscreen={this.fullscreen}
        undoRedo={config.chartCollectionPreset.undoRedoEnabled ? this.eventManager.getUndoRedo() : undefined}
        toggleToolbarVisible={toggleToolbar}
        showCompareButton={!!config.chartCollectionPreset.showCompareButton}
        openCompareModal={
          config.chartCollectionPreset.openCompareModal ? config.chartCollectionPreset.openCompareModal : undefined
        }
        showSymbolSearchButton={!!config.chartCollectionPreset.openSymbolSearchModal}
        openSymbolSearchModal={config.chartCollectionPreset.openSymbolSearchModal}
        isMXT={config.chartCollectionPreset.theme === 'mxt'}
      />,
    );

    if (this.toolbarRenderer && config.chartCollectionPreset.showMenuButton) {
      this.toolbarRenderer.renderComponent(
        <Toolbar
          toggleDOM={this.chart.getDom().toggleDOM}
          addDrawing={(name) => {
            // todo: deal with new panes logic
            this.chart.getDrawingsManager().addDrawingForce(name);
          }}
          setEndlessDrawingsMode={this.chart.getDrawingsManager().setEndlessDrawingMode}
          isEndlessDrawingsMode$={this.chart.getDrawingsManager().isEndlessDrawingsMode()}
          activateCrosshair={() => this.chart.getDrawingsManager().activateCrosshair()}
          activeTool$={this.chart.getDrawingsManager().getActiveTool()}
        />,
      );
    }

    if (this.controlBarRenderer && config.chartCollectionPreset.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.chartCollectionPreset.showBottomPanel) {
      this.footerRenderer.renderComponent(
        <Footer
          supportedTimeframes={config.chartCollectionPreset.supportedTimeframes}
          setInterval={this.eventManager.setInterval}
          intervalObs={this.eventManager.getInterval()}
        />,
      );
    }
  }

  /**
   * Уничтожение графика и очистка ресурсов
   * @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();
    }

    this.dataSource.destroy();

    ContainerManager.clearContainers(this.rootContainer);
  }
}


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

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

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

export class PaneManager implements ISerializable<PaneSnapshot[]> {
  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);
    this.setup(params.panesSnapshot);
  }

  private setup(panesSnapshot: PaneSnapshot[]) {
    panesSnapshot.forEach((paneSnap: PaneSnapshot) => {
      const { isMain, id, indicators, drawings } = paneSnap;

      this.panesMap.get(id)?.destroy();

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

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

        this.mainPane.setDrawingsSnapshot(drawings);
      } else {
        const pane = this.addPane();
        pane.setDrawingsSnapshot(drawings);
      }
      const lastPane = Array.from(this.panesMap.values()).at(-1);

      lastPane?.setIsLast(true);
    });
  }

  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() {
    return this.panesMap;
  }

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

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

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

        const prevPane = Array.from(this.panesMap.values()).at(-1);
        prevPane?.setIsLast(true);
      },
    });

    const prevPane = Array.from(this.panesMap.values()).at(-1);

    prevPane?.setIsLast(false);
    newPane.setIsLast(true);

    this.panesMap.set(id, newPane);

    return newPane;
  }

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

  public getSnapshot(): PaneSnapshot[] {
    const res: PaneSnapshot[] = [];
    this.panesMap.forEach((pane) => {
      res.push(pane.getSnapshot());
    });

    return res;
  }
}


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, DrawingsManagerSnapshot } 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 { EntitySettingsModal } from '@src/components/EntitySettingsModal';
import { DrawingsNames, indicatorLabelById } from '@src/constants';
import { ModalRenderer } from '@src/core/ModalRenderer';
import { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { t } from '@src/translations';
import { OHLCConfig, TooltipConfig } from '@src/types';
import { DOMObjectSnapshot, IndicatorSnapshot, ISerializable, PaneSnapshot } 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;
}

// 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 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 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 modalRenderer: ModalRenderer;

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

  private last = false; // временное решение чтобы блочить удаление не последнего пейна

  constructor({
    lwcChart,
    eventManager,
    dataSource,
    DOM,
    isMainPane,
    ohlcConfig,
    id,
    basedOn,
    subscribeChartEvent,
    tooltipConfig,
    onDelete,
    chartContainer,
    modalRenderer,
  }: PaneParams) {
    this.onDelete = onDelete;
    this.eventManager = eventManager;
    this.lwcChart = lwcChart;
    this.modalRenderer = modalRenderer;
    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,
      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 setIsLast(isLast: boolean): void {
    this.last = isLast;
  }

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

  public isLast = () => {
    return this.last;
  };

  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 setIndicator(indicatorId: string, indicator: Indicator): void {
    const map = this.indicatorsMap.value;

    map.set(indicatorId, indicator);

    this.indicatorsMap.next(map);
  }

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

    map.delete(indicatorId);

    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,
      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 }) {
    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 getSnapshot(): PaneSnapshot {
    const indicators: (DOMObjectSnapshot & IndicatorSnapshot)[] = [];

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

    const snap = {
      isMain: this.isMain,
      id: this.id,
      indicators,
      drawings: this.getDrawingsSnapshot(),
    };

    return snap;
  }

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

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

    this.mainSerieSub?.unsubscribe();

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

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

    this.onDelete();
  }
}


import dayjs from 'dayjs';

import {
  BarPrice,
  ChartOptions,
  createChart,
  CrosshairMode,
  DeepPartial,
  IChartApi,
  IRange,
  LocalizationOptionsBase,
  LogicalRange,
  Time,
  UTCTimestamp,
} from 'lightweight-charts';

import flatten from 'lodash-es/flatten';
import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
import { map, withLatestFrom } from 'rxjs/operators';

import { ChartMouseEvents } from '@core/ChartMouseEvents';
import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { DrawingsManager } from '@core/DrawingsManager';
import { EventManager } from '@core/EventManager';
import { IndicatorManager } from '@core/IndicatorManager';
import { ModalRenderer } from '@core/ModalRenderer';
import { PaneManager } from '@core/PaneManager';
import { CompareManager } from '@src/core/CompareManager';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { getThemeStore } from '@src/theme/store';
import { ThemeKey, ThemeMode } from '@src/theme/types';
import { getLocale } from '@src/translations';
import {
  Candle,
  ChartOptionsModel,
  ChartSeriesType,
  ChartTypeOptions,
  Direction,
  OHLCConfig,
  TooltipConfig,
} from '@src/types';
import { Defaults } from '@src/types/defaults';
import { DayjsOffset, Intervals, intervalsToDayjs } from '@src/types/intervals';

import { ChartSnapshot, ISerializable, PaneSnapshot } from '@src/types/snapshot';
import { formatCompactNumber } from '@src/utils';
import { createTickMarkFormatter, formatDate } from '@src/utils/formatter';

export interface ChartConfig extends Partial<ChartOptionsModel> {
  container: HTMLElement;
  seriesTypes: ChartSeriesType[];
  theme: ThemeKey;
  mode?: ThemeMode;
  chartOptions?: ChartTypeOptions;
  localization?: LocalizationOptionsBase;
}

export enum Resize {
  Shrink,
  Expand,
}

const HISTORY_LOAD_THRESHOLD = 50;

interface ChartParams {
  params: {
    dataSource: DataSource;
    eventManager: EventManager;
    modalRenderer: ModalRenderer;
    ohlcConfig: OHLCConfig;
    tooltipConfig: TooltipConfig;
    panes: PaneSnapshot[];
  };
  lwcChartConfig: ChartConfig;
}

/**
 * Абстракция над библиотекой для построения графиков
 */
export class Chart implements ISerializable<ChartSnapshot> {
  private lwcChart!: IChartApi;
  private container: HTMLElement;
  private eventManager: EventManager;
  private paneManager!: PaneManager;
  private compareManager: CompareManager;
  private mouseEvents: ChartMouseEvents;
  private indicatorManager: IndicatorManager;
  private optionsSubscription: Subscription;
  private dataSource: DataSource;
  private chartConfig: Omit<ChartConfig, 'theme' | 'mode'>;
  private mainSeries: BehaviorSubject<SeriesStrategies | null> = new BehaviorSubject<SeriesStrategies | null>(null); // Main Series. Exists in a single copy
  private DOM: DOMModel;

  private isPointerDown = false;
  private didResetOnDrag = false;

  private subscriptions = new Subscription();

  private currentInterval: Intervals | null = null;

  private activeSymbols: string[] = [];

  private historyBatchRunning = false;

  constructor({ params, lwcChartConfig }: ChartParams) {
    const { eventManager, dataSource, modalRenderer, ohlcConfig, tooltipConfig, panes: panesSnapshot } = params;

    this.eventManager = eventManager;
    this.dataSource = dataSource;
    this.container = lwcChartConfig.container;
    this.chartConfig = lwcChartConfig;

    this.lwcChart = createChart(this.container, getOptions(lwcChartConfig));

    this.optionsSubscription = this.eventManager
      .getChartOptionsModel()
      .subscribe(({ dateFormat, timeFormat, showTime }) => {
        const configToApply = { ...lwcChartConfig, dateFormat, timeFormat, showTime };

        this.lwcChart.applyOptions({
          ...getOptions(configToApply),
          localization: {
            timeFormatter: (time: UTCTimestamp) => formatDate(time, dateFormat, timeFormat, showTime),
          },
        });
      });

    this.subscriptions.add(this.optionsSubscription);

    this.mouseEvents = new ChartMouseEvents({ lwcChart: this.lwcChart, container: this.container });

    this.mouseEvents.subscribe('wheel', this.onWheel);
    this.mouseEvents.subscribe('pointerDown', this.onPointerDown);
    this.mouseEvents.subscribe('pointerMove', this.onPointerMove);
    this.mouseEvents.subscribe('pointerUp', this.onPointerUp);
    this.mouseEvents.subscribe('pointerCancel', this.onPointerUp);

    this.DOM = new DOMModel({ modalRenderer });

    this.paneManager = new PaneManager({
      eventManager: this.eventManager,
      panesSnapshot,
      lwcChart: this.lwcChart,
      dataSource,
      DOM: this.DOM,
      ohlcConfig,
      subscribeChartEvent: this.subscribeChartEvent,
      chartContainer: this.container,
      tooltipConfig,
      modalRenderer,
    });

    this.indicatorManager = new IndicatorManager({
      eventManager,
      initialIndicators: flatten(
        panesSnapshot.map((pane) => pane.indicators.map((ind) => ({ ...ind, paneId: pane.id }))),
      ),
      DOM: this.DOM,
      dataSource: this.dataSource,
      lwcChart: this.lwcChart,
      paneManager: this.paneManager,
      chartOptions: lwcChartConfig.chartOptions,
    });

    this.compareManager = new CompareManager({
      chart: this.lwcChart,
      initialIndicators: flatten(
        panesSnapshot.map((pane) => pane.indicators.map((ind) => ({ ...ind, paneId: pane.id }))),
      ),
      eventManager: this.eventManager,
      dataSource: this.dataSource,
      indicatorManager: this.indicatorManager,
      paneManager: this.paneManager,
    });

    this.setupDataSourceSubs();
    this.setupHistoricalDataLoading();
  }

  public getPriceScaleWidth(direction: Direction): number {
    try {
      const priceScale = this.lwcChart.priceScale(direction);

      return priceScale ? priceScale.width() : 0;
    } catch {
      return 0;
    }
  }

  public getDrawingsManager = (): DrawingsManager => {
    return this.paneManager.getDrawingsManager();
  };

  public getIndicatorManager = (): IndicatorManager => {
    return this.indicatorManager;
  };

  private onWheel = () => {
    this.eventManager.resetInterval({ history: false });
  };

  private onPointerDown = () => {
    this.isPointerDown = true;
    this.didResetOnDrag = false;
  };

  private onPointerMove = () => {
    if (!this.isPointerDown) return;
    if (this.didResetOnDrag) return;

    this.didResetOnDrag = true;
    this.eventManager.resetInterval({ history: false });
  };

  private onPointerUp = () => {
    this.isPointerDown = false;
  };

  public getDom(): DOMModel {
    return this.DOM;
  }

  public getMainSeries(): Observable<SeriesStrategies | null> {
    return this.mainSeries.asObservable();
  }

  public getCompareManager(): CompareManager {
    return this.compareManager;
  }

  public updateTheme(theme: ThemeKey, mode: ThemeMode) {
    this.lwcChart.applyOptions(getOptions({ ...this.chartConfig, theme, mode }));
  }

  public destroy(): void {
    this.mouseEvents.destroy();
    this.compareManager.clear();
    this.subscriptions.unsubscribe();
    this.lwcChart.remove();
  }

  public subscribeChartEvent: ChartMouseEvents['subscribe'] = (event, callback) =>
    this.mouseEvents.subscribe(event, callback);

  public unsubscribeChartEvent: ChartMouseEvents['unsubscribe'] = (event, callback) => {
    this.mouseEvents.unsubscribe(event, callback);
  };

  // todo: add/move to undo/redo model(eventManager)
  public scrollTimeScale = (direction: Direction) => {
    this.eventManager.resetInterval({ history: false });
    const diff = direction === Direction.Left ? -2 : 2;
    const currentPosition = this.lwcChart.timeScale().scrollPosition();
    this.lwcChart.timeScale().scrollToPosition(currentPosition + diff, false);
  };

  // todo: add/move to undo/redo model(eventManager)
  public zoomTimeScale = (resize: Resize) => {
    this.eventManager.resetInterval({ history: false });
    const diff = resize === Resize.Shrink ? -1 : 1;

    const currentRange = this.lwcChart.timeScale().getVisibleRange();
    if (!currentRange) return;

    const { from, to } = currentRange as IRange<number>;
    if (!from || !to) return;

    const next: IRange<Time> = {
      from: (from + (to - from) * 0.1 * diff) as Time,
      to: to as Time,
    };
    this.lwcChart.timeScale().setVisibleRange(next);
  };

  // todo: add to undo/redo model(eventManager)
  public resetZoom = () => {
    this.eventManager.resetInterval({ history: false });
    this.lwcChart.timeScale().resetTimeScale();
    this.lwcChart.priceScale(Direction.Right).setAutoScale(true);
    this.lwcChart.priceScale(Direction.Left).setAutoScale(true);
  };

  public getRealtimeApi() {
    return {
      getTimeframe: () => this.eventManager.getTimeframe(),
      getSymbols: () => this.activeSymbols,
      update: (symbol: string, candle: Candle) => {
        this.dataSource.updateRealtime(symbol, candle);
      },
    };
  }

  public getSnapshot(): ChartSnapshot {
    return {
      panes: this.paneManager.getSnapshot(),
      chartSeriesType: this.eventManager.exportChartSettings().seriesSelected,
      timeframe: this.eventManager.exportChartSettings().timeframe,
      symbol: this.activeSymbols[0],
    };
  }

  private scheduleHistoryBatch = () => {
    if (this.historyBatchRunning) return;

    this.historyBatchRunning = true;

    requestAnimationFrame(() => {
      const symbols = this.activeSymbols.slice();

      Promise.all(symbols.map((s) => this.dataSource.loadMoreHistory(s))).finally(() => {
        this.historyBatchRunning = false;

        const range = this.lwcChart.timeScale().getVisibleLogicalRange();
        if (range && range.from < HISTORY_LOAD_THRESHOLD) {
          this.scheduleHistoryBatch();
        }
      });
    });
  };

  private setupDataSourceSubs() {
    const getWarmupFrom = (): number => {
      if (this.currentInterval && this.currentInterval !== Intervals.All) {
        return getIntervalRange(this.currentInterval).from;
      }

      const range = this.lwcChart.timeScale().getVisibleRange();
      if (!range) return 0;

      const { from } = range as IRange<number>;
      return from as number;
    };

    const warmupSymbols = (symbols: string[]) => {
      if (!symbols.length) return;

      const from = getWarmupFrom();
      if (!from) return;

      Promise.all(symbols.map((symbol) => this.dataSource.loadTill(symbol, from))).catch((error) => {
        console.error('[Chart] Ошибка при прогреве символов:', error);
      });
    };
    const symbols$ = combineLatest([this.eventManager.symbol(), this.compareManager.itemsObs()]).pipe(
      map(([main, items]) => Array.from(new Set([main, ...items.map((i) => i.symbol)]))),
    );

    this.subscriptions.add(
      this.eventManager
        .getInterval()
        .pipe(withLatestFrom(symbols$))
        .subscribe(([interval, symbols]) => {
          this.currentInterval = interval;

          if (!interval) return;

          if (interval === Intervals.All) {
            Promise.all(symbols.map((s) => this.dataSource.loadAllHistory(s)))
              .then(() => {
                requestAnimationFrame(() => this.lwcChart.timeScale().fitContent());
              })
              .catch((error) => console.error('[Chart] Ошибка при загрузке всей истории:', error));

            return;
          }

          const { from, to } = getIntervalRange(interval);

          Promise.all(symbols.map((s) => this.dataSource.loadTill(s, from)))
            .then(() => {
              this.lwcChart.timeScale().setVisibleRange({ from: from as Time, to: to as Time });
            })
            .catch((error) => {
              console.error('[Chart] Ошибка при применении интервала:', error);
            });
        }),
    );

    this.subscriptions.add(
      symbols$.subscribe((symbols) => {
        const prevSymbols = this.activeSymbols;
        this.activeSymbols = symbols;

        this.dataSource.setSymbols(symbols);

        const prevSet = new Set(prevSymbols);
        const added: string[] = [];

        for (let i = 0; i < symbols.length; i += 1) {
          const s = symbols[i];
          if (!s) continue;
          if (prevSet.has(s)) continue;
          added.push(s);
        }

        if (added.length) {
          warmupSymbols(added);
        }
      }),
    );

    this.subscriptions.add(
      combineLatest([
        this.eventManager.symbol(),
        this.compareManager.itemsObs(),
        this.mainSeries.asObservable(),
      ]).subscribe(([main, items, serie]) => {
        if (!serie) return;

        const title = items.length ? main : '';
        serie.getLwcSeries().applyOptions({ title });
      }),
    );
  }

  private setupHistoricalDataLoading(): void {
    // todo (не)вызвать loadMoreHistory после проверки на необходимость дозагрузки после смены таймфрейма
    this.mouseEvents.subscribe('visibleLogicalRangeChange', (logicalRange: LogicalRange | null) => {
      if (!logicalRange) return;
      if (this.currentInterval === Intervals.All) return;

      const needsMoreData = logicalRange.from < HISTORY_LOAD_THRESHOLD;
      if (!needsMoreData) return;

      this.scheduleHistoryBatch();
    });
  }
}

function getIntervalRange(interval: Intervals): { from: number; to: number } {
  const { value, unit } = intervalsToDayjs[interval] as DayjsOffset;

  const from = Math.floor(dayjs().subtract(value, unit).valueOf() / 1000);
  const to = Math.floor(dayjs().valueOf() / 1000);

  return { from, to };
}

function getOptions(config: ChartConfig): DeepPartial<ChartOptions> {
  const timeFormat = config.timeFormat ?? Defaults.timeFormat;
  const showTime = config.showTime ?? Defaults.showTime;

  const use12HourFormat = timeFormat === '12h';
  const timeFormatString = use12HourFormat ? 'h:mm A' : 'HH:mm';

  const { colors } = getThemeStore();

  const localization: LocalizationOptionsBase = {
    locale: getLocale(),
    priceFormatter: (priceValue: BarPrice) => {
      return formatCompactNumber(priceValue);
    },
  };

  return {
    width: config.container.clientWidth,
    height: config.container.clientHeight,
    autoSize: true,
    layout: {
      background: { color: colors.chartBackground },
      textColor: colors.chartTextPrimary,
    },
    grid: {
      vertLines: { color: colors.chartGridLine },
      horzLines: { color: colors.chartGridLine },
    },
    crosshair: {
      mode: CrosshairMode.Normal,
      vertLine: { color: colors.chartCrosshairLine, labelBackgroundColor: colors.chartCrosshairLabel, style: 0 },
      horzLine: { color: colors.chartCrosshairLine, labelBackgroundColor: colors.chartCrosshairLabel, style: 2 },
    },
    timeScale: {
      timeVisible: showTime,
      secondsVisible: false,
      tickMarkFormatter: createTickMarkFormatter(timeFormatString),
      borderVisible: false,
      allowBoldLabels: false,
      rightOffset: 25,
    },
    rightPriceScale: {
      textColor: colors.chartTextPrimary,
      borderVisible: false,
    },
    localization,
  };
}



import dayjs from 'dayjs';

import { BehaviorSubject, firstValueFrom, Observable, Subject } from 'rxjs';
import { filter, take } from 'rxjs/operators';

import { Candle } from '@src/types';
import { Timeframes } from '@src/types/timeframes';
import { ensureDefined, getStartTime, normalizeSeriesData, parseTimeframe } from '@src/utils';

export interface SymbolSourceParams {
  symbol: string;
  getData: (timeframe: Timeframes, symbol: string, until?: Candle) => Promise<Candle[] | null>;
  getTimeframe: () => Timeframes;
}

export class SymbolSource {
  public readonly symbol: string;

  private readonly getData: SymbolSourceParams['getData'];
  private readonly getTimeframe: SymbolSourceParams['getTimeframe'];

  private readonly currentDataSubject = new BehaviorSubject<Candle[]>([]);
  private readonly realtimeSubject = new Subject<Candle>();
  private readonly lastCandleSubject = new BehaviorSubject<Candle | null>(null);
  private readonly isLoadingSubject = new BehaviorSubject<boolean>(false);
  private readonly isInitializedSubject = new BehaviorSubject<boolean>(false);

  private realtimeCache: Candle[] = [];
  private realtimeBuffer: Candle[] = [];

  private oldestCandle: Candle | null = null;
  private newestCandle: Candle | null = null;

  private loadSeq = 0;
  private loadingPromise: Promise<void> | null = null;

  private isEndOfData = false;

  constructor({ symbol, getData, getTimeframe }: SymbolSourceParams) {
    this.symbol = symbol;
    this.getData = getData;
    this.getTimeframe = getTimeframe;
  }

  public init(): void {
    this.reload(this.getTimeframe());
  }

  public data$(): Observable<Candle[]> {
    return this.currentDataSubject.asObservable();
  }

  public realtime$(): Observable<Candle> {
    return this.realtimeSubject.asObservable();
  }

  public lastCandle$(): Observable<Candle | null> {
    return this.lastCandleSubject.asObservable();
  }

  public isInitialized$(): Observable<boolean> {
    return this.isInitializedSubject.asObservable();
  }

  public isLoadingValue(): boolean {
    return this.isLoadingSubject.value;
  }

  public getOldestTime(): number | null {
    return this.oldestCandle?.time ?? null;
  }

  public getLastValue(): Candle | null {
    return this.lastCandleSubject.value;
  }

  public async ready(): Promise<void> {
    if (this.isInitializedSubject.value) return;
    await firstValueFrom(this.isInitializedSubject.pipe(filter(Boolean), take(1)));
  }

  public destroy(): void {
    this.loadSeq += 1;
    this.loadingPromise = null;

    this.currentDataSubject.complete();
    this.realtimeSubject.complete();
    this.lastCandleSubject.complete();
    this.isLoadingSubject.complete();
    this.isInitializedSubject.complete();

    this.realtimeCache = [];
    this.realtimeBuffer = [];
    this.oldestCandle = null;
    this.newestCandle = null;
  }

  public pushRealtime(next: Candle): void {
    const tf = this.getTimeframe();
    const aligned = this.alignCandle(tf, next);

    const res = SymbolSource.stackCandles(tf, aligned, this.newestCandle, this.realtimeCache);
    this.newestCandle = res.newestCandle;
    this.realtimeCache = res.realtimeCache;

    this.lastCandleSubject.next(res.newestCandle);

    if (!this.isInitializedSubject.value) {
      this.realtimeBuffer.push(res.newestCandle);
      return;
    }

    this.realtimeSubject.next(res.newestCandle);
  }

  public saveRealtimeCache(): void {
    if (this.realtimeCache.length === 0) return;

    const tf = this.getTimeframe();
    this.realtimeCache = this.normalizeList(tf, this.realtimeCache);

    const left = this.currentDataSubject.value;
    const right = this.realtimeCache;

    if (left.length === 0 || right.length === 0) {
      this.realtimeCache = [];
      return;
    }

    const lastLeft = left[left.length - 1];
    const firstRight = right[0];

    const next =
      lastLeft && firstRight && lastLeft.time === firstRight.time
        ? left
            .slice(0, -1)
            .concat(SymbolSource.combineCandles(lastLeft, firstRight, true))
            .concat(right.slice(1))
        : left.concat(right);

    const normalizedNext = this.normalizeList(tf, next);

    this.currentDataSubject.next(normalizedNext);
    this.newestCandle = normalizedNext[normalizedNext.length - 1] ?? null;
    this.realtimeCache = [];

    this.lastCandleSubject.next(this.newestCandle);
  }

  public async loadMoreHistory(): Promise<void> {
    await this.ready();

    if (this.isEndOfData) return;

    if (this.loadingPromise) {
      return this.loadingPromise;
    }

    this.loadSeq += 1;

    if (!this.oldestCandle) return;

    const tf = ensureDefined(this.getTimeframe());

    const task = (async () => {
      this.isLoadingSubject.next(true);

      try {
        this.saveRealtimeCache();
        if (!this.oldestCandle) return;

        const olderData = await this.getData(tf, this.symbol, this.oldestCandle);
        if (olderData === null) {
          this.isEndOfData = true;
          return;
        }

        const older = this.normalizeList(tf, olderData);
        if (older.length === 0) return;

        const current = this.currentDataSubject.value;
        const combinedRaw = SymbolSource.mergeHistory(older, current);
        const combined = this.normalizeList(tf, combinedRaw);

        const nextOldest = combined[0] ?? null;

        if (!nextOldest || !this.oldestCandle || nextOldest.time >= this.oldestCandle.time) {
          return;
        }

        this.oldestCandle = nextOldest;
        this.newestCandle = combined[combined.length - 1] ?? this.newestCandle;

        this.currentDataSubject.next(combined);
        this.lastCandleSubject.next(this.newestCandle);
      } catch (error) {
        console.error('[DataSource] Ошибка при догрузке истории:', error);
      } finally {
        this.isLoadingSubject.next(false);
      }
    })();

    this.loadingPromise = task;

    task.finally(() => {
      if (this.loadingPromise === task) {
        this.loadingPromise = null;
      }
    });

    await task;
  }

  public async loadAllHistory(): Promise<void> {
    await this.ready();

    if (this.isEndOfData) return;

    if (this.loadingPromise) {
      await this.loadingPromise;
    }

    const seq = ++this.loadSeq;
    const tf = ensureDefined(this.getTimeframe());

    const task = (async () => {
      this.isLoadingSubject.next(true);

      try {
        this.saveRealtimeCache();

        let current = this.currentDataSubject.value;
        let oldest = current[0] ?? null;

        if (!oldest) return;

        const seenOldestTimes = new Set<number>();

        while (oldest) {
          if (seq !== this.loadSeq) return;

          if (seenOldestTimes.has(oldest.time)) {
            break;
          }

          seenOldestTimes.add(oldest.time);

          // eslint-disable-next-line no-await-in-loop
          const olderData = await this.getData(tf, this.symbol, oldest);
          if (seq !== this.loadSeq) return;
          if (olderData === null) {
            this.isEndOfData = true;
            break;
          }

          const older = this.normalizeList(tf, olderData);
          if (older.length === 0) break;

          const combinedRaw = SymbolSource.mergeHistory(older, current);
          const combined = this.normalizeList(tf, combinedRaw);

          const nextOldest = combined[0] ?? null;

          if (!nextOldest || nextOldest.time >= oldest.time) {
            break;
          }

          oldest = nextOldest;
          current = combined;
        }

        this.oldestCandle = current[0] ?? null;
        this.newestCandle = current[current.length - 1] ?? null;

        this.currentDataSubject.next(current);
        this.saveRealtimeCache();
      } catch (error) {
        console.error('[DataSource] Ошибка при полной загрузке истории:', error);
      } finally {
        if (seq === this.loadSeq) {
          this.isLoadingSubject.next(false);
        }
      }
    })();

    this.loadingPromise = task;

    task.finally(() => {
      if (this.loadingPromise === task) {
        this.loadingPromise = null;
      }
    });

    await task;
  }

  public async loadTill(time: number): Promise<void> {
    await this.ready();

    while (this.oldestCandle && this.oldestCandle.time >= time) {
      const before = this.oldestCandle.time;

      // eslint-disable-next-line no-await-in-loop
      await this.loadMoreHistory();

      if (!this.oldestCandle) break;
      if (this.oldestCandle.time === before) break;
    }
  }

  public async reload(tf: Timeframes): Promise<void> {
    this.loadSeq += 1;
    const seq = this.loadSeq;

    this.isInitializedSubject.next(false);
    this.isLoadingSubject.next(true);

    this.currentDataSubject.next([]);
    this.realtimeCache = [];
    this.realtimeBuffer = [];
    this.oldestCandle = null;
    this.newestCandle = null;
    this.isEndOfData = false;
    this.lastCandleSubject.next(null);

    const task = (async () => {
      try {
        const loaded = (await this.getData(tf, this.symbol)) ?? [];
        if (seq !== this.loadSeq) return;

        const normalized = this.normalizeList(tf, loaded);

        this.oldestCandle = normalized[0] ?? null;
        this.newestCandle = normalized[normalized.length - 1] ?? null;

        this.currentDataSubject.next(normalized);
        this.lastCandleSubject.next(this.newestCandle);

        this.isInitializedSubject.next(true);
        this.flushRealtimeBuffer();
      } catch (error) {
        console.error('[DataSource] Ошибка при загрузке данных:', error);
      } finally {
        if (seq === this.loadSeq) this.isLoadingSubject.next(false);
      }
    })();

    this.loadingPromise = task;

    task.finally(() => {
      if (this.loadingPromise === task) this.loadingPromise = null;
    });

    await task;
  }

  private flushRealtimeBuffer(): void {
    if (this.realtimeBuffer.length === 0) return;

    const newestTime = this.newestCandle?.time ?? Number.NEGATIVE_INFINITY;
    const filtered = this.realtimeBuffer.filter((c) => c.time >= newestTime);

    const map = new Map<number, Candle>();

    for (const c of filtered) {
      map.set(c.time, c);
    }

    const unique = Array.from(map.values()).sort((a, b) => a.time - b.time);

    for (const c of unique) {
      this.realtimeSubject.next(c);
    }

    this.realtimeBuffer = [];
  }

  private alignCandle(tf: Timeframes, c: Candle): Candle {
    const timeMS = c.time > 1e10 ? Math.floor(c.time) : Math.floor(c.time * 1000);
    const startMS = getStartTime(tf, timeMS);

    return { ...c, time: startMS };
  }

  private normalizeList(tf: Timeframes, list: Candle[]): Candle[] {
    const aligned = list.map((c) => this.alignCandle(tf, c)).sort((a, b) => a.time - b.time);

    return normalizeSeriesData(aligned);
  }

  private static stackCandles(
    timeframe: Timeframes,
    next: Candle,
    newestCandle: Candle | null,
    realtimeCache: Candle[],
  ): { realtimeCache: Candle[]; newestCandle: Candle } {
    const { candleWidth, dayjsUnit } = parseTimeframe(timeframe);

    const historicalUpdate = newestCandle
      ? next.time - newestCandle.time < dayjs.duration(candleWidth, dayjsUnit).as('s')
      : false;

    if (!historicalUpdate) {
      const nextNewestCandle = { ...next, time: getStartTime(timeframe, next.time * 1000) };
      realtimeCache.push(nextNewestCandle);
      return { realtimeCache, newestCandle: nextNewestCandle };
    }

    const dataToSet = SymbolSource.combineCandles(ensureDefined(newestCandle), next);

    if (realtimeCache.length === 0) {
      realtimeCache.push(dataToSet);
      return { realtimeCache, newestCandle: dataToSet };
    }

    realtimeCache[realtimeCache.length - 1] = dataToSet;

    return { realtimeCache, newestCandle: dataToSet };
  }

  private static mergeHistory(older: Candle[], current: Candle[]): Candle[] {
    if (current.length === 0) return older;
    if (older.length === 0) return current;

    const lastOlder = older[older.length - 1];
    const firstCurrent = current[0];

    if (lastOlder && firstCurrent && lastOlder.time === firstCurrent.time) {
      const merged = SymbolSource.combineCandles(lastOlder, firstCurrent, true);
      return older.slice(0, -1).concat(merged).concat(current.slice(1));
    }

    return older.concat(current);
  }

  private static combineCandles(left: Candle, right: Candle, volumeStacked?: boolean): Candle {
    return {
      time: left.time,
      open: left.open,
      high: Math.max(left.high, right.high),
      low: Math.min(left.low, right.low),
      close: right.close,
      volume: volumeStacked ? (left.volume ?? 0) + (right.volume ?? 0) : (right.volume ?? 0),
    };
  }
}



import { IChartApi, PriceScaleMode, SeriesType } from 'lightweight-charts';
import { flatten } from 'lodash-es';
import { BehaviorSubject, distinctUntilChanged, map, Observable } 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 { MAIN_PANE_INDEX } from '@src/constants';
import { CompareItem, CompareMode, Direction, IndicatorConfig } from '@src/types';
import { IndicatorSnapshot } from '@src/types/snapshot';
import { getPaletteColorFromIndex, normalizeColor, normalizeSymbol } from '@src/utils';

interface CompareEntry {
  key: string;
  symbol: string;
  mode: CompareMode;
  paneIndex: number;
  symbol$: BehaviorSubject<string>;
  entity: Indicator;
}

function makeKey(symbol: string, mode: CompareMode): string {
  return `${symbol}|${mode}`;
}

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

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

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[]>([]);

  constructor({
    chart,
    eventManager,
    dataSource,
    indicatorManager,
    paneManager,
    initialIndicators = [],
  }: CompareManagerParams) {
    this.chart = chart;
    this.eventManager = eventManager;
    this.dataSource = dataSource;
    this.indicatorManager = indicatorManager;
    this.paneManager = paneManager;

    this.eventManager.timeframe().subscribe(() => {
      this.applyPolicy();
    });

    if (!initialIndicators) {
      return;
    }

    this.setup(initialIndicators);
  }

  private async setup(initialIndicators: IndicatorSnapshot[]) {
    for (const indicator of initialIndicators) {
      if (indicator.config && indicator.config.label) {
        // условие проверки compare ли это
        const serie = indicator.config.series[0];

        const compareMode =
          serie.seriesOptions?.priceScaleId === Direction.Left
            ? CompareMode.NewScale
            : indicator.config.newPane
              ? CompareMode.NewPane
              : CompareMode.Percentage;

        // eslint-disable-next-line no-await-in-loop
        await this.setSymbolMode(serie.name, indicator.config.label, compareMode, indicator.paneId);
      }
    }
  }

  public itemsObs(): Observable<CompareItem[]> {
    return this.itemsSubject.asObservable();
  }

  public clear(): void {
    const keys = Array.from(this.entries.keys());
    for (let i = 0; i < keys.length; i += 1) {
      this.removeByKey(keys[i]);
    }
    this.applyPolicy();
    this.publish();
  }

  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
        (ind) => ind.getConfig().series?.[0]?.seriesOptions?.color,
      );
      const existIndicators = Array.from(this.indicatorManager.getIndicators().value.values());
      const usedColorsByIndicatorsRaw = existIndicators.map((ind) =>
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        ind.config?.series?.map((serie) => serie.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: () => this.removeByKey(key),
        moveUp,
        moveDown,
        paneId: associatedPane.getId(),
      });
    });

    this.entries.set(key, { key, symbol, mode, paneIndex: entity.getPane().getId(), symbol$, entity });

    this.applyPolicy();
    this.publish();

    await this.dataSource.isReady(symbol);
  }

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

    this.removeByKey(makeKey(symbol, mode));
    this.applyPolicy();
    this.publish();
  }

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

    const all = Array.from(this.entries.entries());
    for (let i = 0; i < all.length; i += 1) {
      const [key, entry] = all[i];
      if (entry.symbol === symbol) this.removeByKey(key);
    }

    this.applyPolicy();
    this.publish();
  }

  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.clear();
    this.itemsSubject.complete();
    this.entitiesSubject.complete();
  }

  private removeByKey(key: string): void {
    const entry = this.entries.get(key);
    if (!entry) return;

    this.entries.delete(key);
    this.indicatorManager.removeEntity(entry.entity);
    entry.entity.destroy();
    entry.symbol$.complete();

    this.applyPolicy();
    this.publish();
  }

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

    const items: CompareItem[] = [];
    const entities: Indicator[] = [];

    for (let i = 0; i < values.length; i += 1) {
      items.push({ symbol: values[i].symbol, mode: values[i].mode });
      entities.push(values[i].entity);
    }

    this.itemsSubject.next(items);
    this.entitiesSubject.next(entities);
  }

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

    let percentEnabled = false;
    for (let i = 0; i < list.length; i += 1) {
      if (list[i].mode === CompareMode.Percentage) {
        percentEnabled = true;
        break;
      }
    }

    let hasMainNewScale = false;
    for (let i = 0; i < list.length; i += 1) {
      // [0 - в индикаторах compare сущности может быть только одна серия] [1 - entry]
      const paneIndex = Array.from(list[i].entity.getSeriesMap())[0][1].getPane().paneIndex();
      if (paneIndex === MAIN_PANE_INDEX && list[i].mode === CompareMode.NewScale) {
        hasMainNewScale = true;
        break;
      }
    }

    const paneSet = new Set<number>();
    for (let i = 0; i < list.length; i += 1) {
      // [0 - в индикаторах compare сущности может быть только одна серия] [1 - entry]
      const paneIndex = Array.from(list[i].entity.getSeriesMap())[0][1].getPane().paneIndex();

      if (paneIndex !== MAIN_PANE_INDEX) paneSet.add(paneIndex);
    }

    this.chart.applyOptions({
      leftPriceScale: { visible: hasMainNewScale, borderVisible: false },
    });

    this.chart.priceScale(Direction.Right, MAIN_PANE_INDEX).applyOptions({
      visible: true,
      mode: percentEnabled ? PriceScaleMode.Percentage : PriceScaleMode.Normal,
    });

    this.chart.priceScale(Direction.Left, MAIN_PANE_INDEX).applyOptions({
      visible: hasMainNewScale,
      mode: PriceScaleMode.Normal,
      borderVisible: false,
    });

    const panes = Array.from(paneSet.values());
    for (let i = 0; i < panes.length; i += 1) {
      const paneIndex = panes[i];

      this.chart.priceScale(Direction.Right, paneIndex).applyOptions({
        visible: true,
        mode: PriceScaleMode.Normal,
      });

      if (hasMainNewScale) {
        this.chart.priceScale(Direction.Left, paneIndex).applyOptions({
          visible: true,
          mode: PriceScaleMode.Normal,
          borderVisible: false,
        });
      }
    }

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

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

      if (paneIndex !== MAIN_PANE_INDEX) {
        serie.applyOptions({ priceScaleId: Direction.Right });
        continue;
      }

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

import dayjs from 'dayjs';

import {
  BarPrice,
  ChartOptions,
  createChart,
  CrosshairMode,
  DeepPartial,
  IChartApi,
  IRange,
  LocalizationOptionsBase,
  LogicalRange,
  Time,
  UTCTimestamp,
} from 'lightweight-charts';

import flatten from 'lodash-es/flatten';
import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
import { map, withLatestFrom } from 'rxjs/operators';

import { ChartMouseEvents } from '@core/ChartMouseEvents';
import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { DrawingsManager } from '@core/DrawingsManager';
import { EventManager } from '@core/EventManager';
import { IndicatorManager } from '@core/IndicatorManager';
import { ModalRenderer } from '@core/ModalRenderer';
import { PaneManager } from '@core/PaneManager';
import { CompareManager } from '@src/core/CompareManager';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { getThemeStore } from '@src/theme/store';
import { ThemeKey, ThemeMode } from '@src/theme/types';
import { getLocale } from '@src/translations';
import {
  Candle,
  ChartOptionsModel,
  ChartSeriesType,
  ChartTypeOptions,
  Direction,
  OHLCConfig,
  TooltipConfig,
} from '@src/types';
import { Defaults } from '@src/types/defaults';
import { DayjsOffset, Intervals, intervalsToDayjs } from '@src/types/intervals';

import { ChartSnapshot, ISerializable, PaneSnapshot } from '@src/types/snapshot';
import { formatCompactNumber } from '@src/utils';
import { createTickMarkFormatter, formatDate } from '@src/utils/formatter';

export interface ChartConfig extends Partial<ChartOptionsModel> {
  container: HTMLElement;
  seriesTypes: ChartSeriesType[];
  theme: ThemeKey;
  mode?: ThemeMode;
  chartOptions?: ChartTypeOptions;
  localization?: LocalizationOptionsBase;
}

export enum Resize {
  Shrink,
  Expand,
}

const HISTORY_LOAD_THRESHOLD = 50;

interface ChartParams {
  params: {
    dataSource: DataSource;
    eventManager: EventManager;
    modalRenderer: ModalRenderer;
    ohlcConfig: OHLCConfig;
    tooltipConfig: TooltipConfig;
    panes: PaneSnapshot[];
  };
  lwcChartConfig: ChartConfig;
}

/**
 * Абстракция над библиотекой для построения графиков
 */
export class Chart implements ISerializable<ChartSnapshot> {
  private lwcChart!: IChartApi;
  private container: HTMLElement;
  private eventManager: EventManager;
  private paneManager!: PaneManager;
  private compareManager: CompareManager;
  private mouseEvents: ChartMouseEvents;
  private indicatorManager: IndicatorManager;
  private optionsSubscription: Subscription;
  private dataSource: DataSource;
  private chartConfig: Omit<ChartConfig, 'theme' | 'mode'>;
  private mainSeries: BehaviorSubject<SeriesStrategies | null> = new BehaviorSubject<SeriesStrategies | null>(null); // Main Series. Exists in a single copy
  private DOM: DOMModel;

  private isPointerDown = false;
  private didResetOnDrag = false;

  private subscriptions = new Subscription();

  private currentInterval: Intervals | null = null;

  private activeSymbols: string[] = [];

  private historyBatchRunning = false;

  constructor({ params, lwcChartConfig }: ChartParams) {
    const { eventManager, dataSource, modalRenderer, ohlcConfig, tooltipConfig, panes: panesSnapshot } = params;

    this.eventManager = eventManager;
    this.dataSource = dataSource;
    this.container = lwcChartConfig.container;
    this.chartConfig = lwcChartConfig;

    this.lwcChart = createChart(this.container, getOptions(lwcChartConfig));

    this.optionsSubscription = this.eventManager
      .getChartOptionsModel()
      .subscribe(({ dateFormat, timeFormat, showTime }) => {
        const configToApply = { ...lwcChartConfig, dateFormat, timeFormat, showTime };

        this.lwcChart.applyOptions({
          ...getOptions(configToApply),
          localization: {
            timeFormatter: (time: UTCTimestamp) => formatDate(time, dateFormat, timeFormat, showTime),
          },
        });
      });

    this.subscriptions.add(this.optionsSubscription);

    this.mouseEvents = new ChartMouseEvents({ lwcChart: this.lwcChart, container: this.container });

    this.mouseEvents.subscribe('wheel', this.onWheel);
    this.mouseEvents.subscribe('pointerDown', this.onPointerDown);
    this.mouseEvents.subscribe('pointerMove', this.onPointerMove);
    this.mouseEvents.subscribe('pointerUp', this.onPointerUp);
    this.mouseEvents.subscribe('pointerCancel', this.onPointerUp);

    this.DOM = new DOMModel({ modalRenderer });

    this.paneManager = new PaneManager({
      eventManager: this.eventManager,
      panesSnapshot,
      lwcChart: this.lwcChart,
      dataSource,
      DOM: this.DOM,
      ohlcConfig,
      subscribeChartEvent: this.subscribeChartEvent,
      chartContainer: this.container,
      tooltipConfig,
      modalRenderer,
    });

    this.indicatorManager = new IndicatorManager({
      eventManager,
      initialIndicators: flatten(
        panesSnapshot.map((pane) => pane.indicators.map((ind) => ({ ...ind, paneId: pane.id }))),
      ),
      DOM: this.DOM,
      dataSource: this.dataSource,
      lwcChart: this.lwcChart,
      paneManager: this.paneManager,
      chartOptions: lwcChartConfig.chartOptions,
    });

    this.compareManager = new CompareManager({
      chart: this.lwcChart,
      initialIndicators: flatten(
        panesSnapshot.map((pane) => pane.indicators.map((ind) => ({ ...ind, paneId: pane.id }))),
      ),
      eventManager: this.eventManager,
      dataSource: this.dataSource,
      indicatorManager: this.indicatorManager,
      paneManager: this.paneManager,
    });

    this.setupDataSourceSubs();
    this.setupHistoricalDataLoading();
  }

  public getPriceScaleWidth(direction: Direction): number {
    try {
      const priceScale = this.lwcChart.priceScale(direction);

      return priceScale ? priceScale.width() : 0;
    } catch {
      return 0;
    }
  }

  public getDrawingsManager = (): DrawingsManager => {
    return this.paneManager.getDrawingsManager();
  };

  public getIndicatorManager = (): IndicatorManager => {
    return this.indicatorManager;
  };

  private onWheel = () => {
    this.eventManager.resetInterval({ history: false });
  };

  private onPointerDown = () => {
    this.isPointerDown = true;
    this.didResetOnDrag = false;
  };

  private onPointerMove = () => {
    if (!this.isPointerDown) return;
    if (this.didResetOnDrag) return;

    this.didResetOnDrag = true;
    this.eventManager.resetInterval({ history: false });
  };

  private onPointerUp = () => {
    this.isPointerDown = false;
  };

  public getDom(): DOMModel {
    return this.DOM;
  }

  public getMainSeries(): Observable<SeriesStrategies | null> {
    return this.mainSeries.asObservable();
  }

  public getCompareManager(): CompareManager {
    return this.compareManager;
  }

  public updateTheme(theme: ThemeKey, mode: ThemeMode) {
    this.lwcChart.applyOptions(getOptions({ ...this.chartConfig, theme, mode }));
  }

  public destroy(): void {
    this.mouseEvents.destroy();
    this.compareManager.clear();
    this.subscriptions.unsubscribe();
    this.lwcChart.remove();
  }

  public subscribeChartEvent: ChartMouseEvents['subscribe'] = (event, callback) =>
    this.mouseEvents.subscribe(event, callback);

  public unsubscribeChartEvent: ChartMouseEvents['unsubscribe'] = (event, callback) => {
    this.mouseEvents.unsubscribe(event, callback);
  };

  // todo: add/move to undo/redo model(eventManager)
  public scrollTimeScale = (direction: Direction) => {
    this.eventManager.resetInterval({ history: false });
    const diff = direction === Direction.Left ? -2 : 2;
    const currentPosition = this.lwcChart.timeScale().scrollPosition();
    this.lwcChart.timeScale().scrollToPosition(currentPosition + diff, false);
  };

  // todo: add/move to undo/redo model(eventManager)
  public zoomTimeScale = (resize: Resize) => {
    this.eventManager.resetInterval({ history: false });
    const diff = resize === Resize.Shrink ? -1 : 1;

    const currentRange = this.lwcChart.timeScale().getVisibleRange();
    if (!currentRange) return;

    const { from, to } = currentRange as IRange<number>;
    if (!from || !to) return;

    const next: IRange<Time> = {
      from: (from + (to - from) * 0.1 * diff) as Time,
      to: to as Time,
    };
    this.lwcChart.timeScale().setVisibleRange(next);
  };

  // todo: add to undo/redo model(eventManager)
  public resetZoom = () => {
    this.eventManager.resetInterval({ history: false });
    this.lwcChart.timeScale().resetTimeScale();
    this.lwcChart.priceScale(Direction.Right).setAutoScale(true);
    this.lwcChart.priceScale(Direction.Left).setAutoScale(true);
  };

  public getRealtimeApi() {
    return {
      getTimeframe: () => this.eventManager.getTimeframe(),
      getSymbols: () => this.activeSymbols,
      update: (symbol: string, candle: Candle) => {
        this.dataSource.updateRealtime(symbol, candle);
      },
    };
  }

  public getSnapshot(): ChartSnapshot {
    return {
      panes: this.paneManager.getSnapshot(),
      chartSeriesType: this.eventManager.exportChartSettings().seriesSelected,
      timeframe: this.eventManager.exportChartSettings().timeframe,
      symbol: this.activeSymbols[0],
    };
  }

  private scheduleHistoryBatch = () => {
    if (this.historyBatchRunning) return;

    this.historyBatchRunning = true;

    requestAnimationFrame(() => {
      const symbols = this.activeSymbols.slice();

      Promise.all(symbols.map((s) => this.dataSource.loadMoreHistory(s))).finally(() => {
        this.historyBatchRunning = false;

        const range = this.lwcChart.timeScale().getVisibleLogicalRange();
        if (range && range.from < HISTORY_LOAD_THRESHOLD) {
          this.scheduleHistoryBatch();
        }
      });
    });
  };

  private setupDataSourceSubs() {
    const getWarmupFrom = (): number => {
      if (this.currentInterval && this.currentInterval !== Intervals.All) {
        return getIntervalRange(this.currentInterval).from;
      }

      const range = this.lwcChart.timeScale().getVisibleRange();
      if (!range) return 0;

      const { from } = range as IRange<number>;
      return from as number;
    };

    const warmupSymbols = (symbols: string[]) => {
      if (!symbols.length) return;

      const from = getWarmupFrom();
      if (!from) return;

      Promise.all(symbols.map((symbol) => this.dataSource.loadTill(symbol, from))).catch((error) => {
        console.error('[Chart] Ошибка при прогреве символов:', error);
      });
    };
    const symbols$ = combineLatest([this.eventManager.symbol(), this.compareManager.itemsObs()]).pipe(
      map(([main, items]) => Array.from(new Set([main, ...items.map((i) => i.symbol)]))),
    );

    this.subscriptions.add(
      this.eventManager
        .getInterval()
        .pipe(withLatestFrom(symbols$))
        .subscribe(([interval, symbols]) => {
          this.currentInterval = interval;

          if (!interval) return;

          if (interval === Intervals.All) {
            Promise.all(symbols.map((s) => this.dataSource.loadAllHistory(s)))
              .then(() => {
                requestAnimationFrame(() => this.lwcChart.timeScale().fitContent());
              })
              .catch((error) => console.error('[Chart] Ошибка при загрузке всей истории:', error));

            return;
          }

          const { from, to } = getIntervalRange(interval);

          Promise.all(symbols.map((s) => this.dataSource.loadTill(s, from)))
            .then(() => {
              this.lwcChart.timeScale().setVisibleRange({ from: from as Time, to: to as Time });
            })
            .catch((error) => {
              console.error('[Chart] Ошибка при применении интервала:', error);
            });
        }),
    );

    this.subscriptions.add(
      symbols$.subscribe((symbols) => {
        const prevSymbols = this.activeSymbols;
        this.activeSymbols = symbols;

        this.dataSource.setSymbols(symbols);

        const prevSet = new Set(prevSymbols);
        const added: string[] = [];

        for (let i = 0; i < symbols.length; i += 1) {
          const s = symbols[i];
          if (!s) continue;
          if (prevSet.has(s)) continue;
          added.push(s);
        }

        if (added.length) {
          warmupSymbols(added);
        }
      }),
    );

    this.subscriptions.add(
      combineLatest([
        this.eventManager.symbol(),
        this.compareManager.itemsObs(),
        this.mainSeries.asObservable(),
      ]).subscribe(([main, items, serie]) => {
        if (!serie) return;

        const title = items.length ? main : '';
        serie.getLwcSeries().applyOptions({ title });
      }),
    );
  }

  private setupHistoricalDataLoading(): void {
    // todo (не)вызвать loadMoreHistory после проверки на необходимость дозагрузки после смены таймфрейма
    this.mouseEvents.subscribe('visibleLogicalRangeChange', (logicalRange: LogicalRange | null) => {
      if (!logicalRange) return;
      if (this.currentInterval === Intervals.All) return;

      const needsMoreData = logicalRange.from < HISTORY_LOAD_THRESHOLD;
      if (!needsMoreData) return;

      this.scheduleHistoryBatch();
    });
  }
}

function getIntervalRange(interval: Intervals): { from: number; to: number } {
  const { value, unit } = intervalsToDayjs[interval] as DayjsOffset;

  const from = Math.floor(dayjs().subtract(value, unit).valueOf() / 1000);
  const to = Math.floor(dayjs().valueOf() / 1000);

  return { from, to };
}

function getOptions(config: ChartConfig): DeepPartial<ChartOptions> {
  const timeFormat = config.timeFormat ?? Defaults.timeFormat;
  const showTime = config.showTime ?? Defaults.showTime;

  const use12HourFormat = timeFormat === '12h';
  const timeFormatString = use12HourFormat ? 'h:mm A' : 'HH:mm';

  const { colors } = getThemeStore();

  const localization: LocalizationOptionsBase = {
    locale: getLocale(),
    priceFormatter: (priceValue: BarPrice) => {
      return formatCompactNumber(priceValue);
    },
  };

  return {
    width: config.container.clientWidth,
    height: config.container.clientHeight,
    autoSize: true,
    layout: {
      background: { color: colors.chartBackground },
      textColor: colors.chartTextPrimary,
    },
    grid: {
      vertLines: { color: colors.chartGridLine },
      horzLines: { color: colors.chartGridLine },
    },
    crosshair: {
      mode: CrosshairMode.Normal,
      vertLine: { color: colors.chartCrosshairLine, labelBackgroundColor: colors.chartCrosshairLabel, style: 0 },
      horzLine: { color: colors.chartCrosshairLine, labelBackgroundColor: colors.chartCrosshairLabel, style: 2 },
    },
    timeScale: {
      timeVisible: showTime,
      secondsVisible: false,
      tickMarkFormatter: createTickMarkFormatter(timeFormatString),
      borderVisible: false,
      allowBoldLabels: false,
      rightOffset: 25,
    },
    rightPriceScale: {
      textColor: colors.chartTextPrimary,
      borderVisible: false,
    },
    localization,
  };
}


import { SeriesType } from 'lightweight-charts';

import { IBaseSeries } from '@core/Series/BaseSeries';

import { Candle, Destroyable, LineCandle } from '../../types/chart';

export interface ISeries<TSeries extends SeriesType> extends IBaseSeries<TSeries>, Destroyable {
  show(): void;

  isVisible(): boolean;

  hide(): void;

  validateData(data: (Partial<Candle> & Partial<LineCandle>)[]): boolean;

  getTypeName(): string;
}


import { CandlestickSeriesStrategy, LineSeriesStrategy } from '@core';

import { BaseSeriesParams } from '@core/Series/BaseSeries';
import { BarSeriesStrategy } from '@src/core/Series/BarSeriesStrategy';

import { HistogramSeriesStrategy } from '@src/core/Series/HistogramSeriesStrategy';
import { ISeries } from '@src/modules/series-strategies/ISeries';
import { ChartSeriesType } from '@src/types';

export type SeriesStrategies =
  | CandlestickSeriesStrategy
  | LineSeriesStrategy
  | HistogramSeriesStrategy
  | BarSeriesStrategy
  | ISeries<'Baseline'>
  | ISeries<'Area'>
  | ISeries<'Custom'>;
/**
x * Фабрика для создания стратегий серий
 * Реализует паттерн Factory для создания нужной стратегии по типу графика
 */

export class SeriesFactory {
  static create(
    type: ChartSeriesType,
  ):
    | ((params: BaseSeriesParams<'Candlestick'>) => CandlestickSeriesStrategy)
    | ((params: BaseSeriesParams<'Histogram'>) => HistogramSeriesStrategy)
    | ((params: BaseSeriesParams<'Line'>) => LineSeriesStrategy)
    | ((params: BaseSeriesParams<'Bar'>) => BarSeriesStrategy) {
    if (type === 'Candlestick') {
      return ((params) => new CandlestickSeriesStrategy(params)) as (
        params: BaseSeriesParams<'Candlestick'>,
      ) => CandlestickSeriesStrategy;
    }
    if (type === 'Histogram') {
      return ((params) => new HistogramSeriesStrategy(params)) as (
        params: BaseSeriesParams<'Histogram'>,
      ) => HistogramSeriesStrategy;
    }
    if (type === 'Line') {
      return ((params) => new LineSeriesStrategy(params)) as (params: BaseSeriesParams<'Line'>) => LineSeriesStrategy;
    }
    if (type === 'Bar') {
      return ((params) => new BarSeriesStrategy(params)) as (params: BaseSeriesParams<'Bar'>) => BarSeriesStrategy;
    }
    throw new Error(`Unsupported chart type: ${type}`);
  }
}