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


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, MAIN_PANE_INDEX } from '@src/constants';
import { ModalRenderer } from '@src/core/ModalRenderer';
import { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { t } from '@src/translations';
import { 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 paneContainerSyncFrameId: number | null = null;

  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;

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

    this.initializeLegend({ ohlcConfig });

    this.tooltip = new TooltipService({
      config: tooltipConfig,
      legend: this.legend,
      paneOverlayContainer: this.paneOverlayContainer,
    });

    this.tooltipRenderer = new ReactRenderer(this.paneOverlayContainer);

    this.tooltipRenderer.renderComponent(
      <ChartTooltip
        formatObs={this.eventManager.getChartOptionsModel()}
        timeframeObs={this.eventManager.getTimeframeObs()}
        viewModel={this.tooltip.getTooltipViewModel()}
        // ohlcConfig={this.legend.getConfig()}
        ohlcConfig={ohlcConfig}
        tooltipConfig={this.tooltip.getConfig()}
      />,
    );

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

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

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

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

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

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

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

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

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

  public 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.onDelete();
    }
  }

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

    this.schedulePaneContainerSync();

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

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

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

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

  private initializeMainSerie({ lwcChart, dataSource }: { lwcChart: IChartApi; dataSource: DataSource }) {
    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 schedulePaneContainerSync(): void {
    if (this.paneContainerSyncFrameId !== null) {
      return;
    }

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

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

    lwcPaneElement.style.position = 'relative';

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

  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() {
    if (this.paneContainerSyncFrameId !== null) {
      cancelAnimationFrame(this.paneContainerSyncFrameId);
      this.paneContainerSyncFrameId = null;
    }

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

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

    this.mainSerieSub?.unsubscribe();

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

    const paneIndex = this.paneIndex();

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



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 nextPaneId = 0;

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

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

    this.panesMap.set(this.nextPaneId++, this.mainPane);
    this.setup(params.panesSnapshot);
  }

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

      this.destroyPane(id, false);

      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(undefined, id);
        pane.setDrawingsSnapshot(drawings);
      }
    });

    this.syncPaneContainers();
  }

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

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

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

  public getPanes() {
    return this.panesMap;
  }

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

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

    if (!pane) {
      return;
    }

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

    if (syncAfter) {
      this.syncPaneContainers();
    }
  }

  public addPane(dataSource?: DataSource, paneId?: number): Pane {
    const id = paneId ?? this.nextPaneId++;
    this.nextPaneId = Math.max(this.nextPaneId, id + 1);

    const newPane = new Pane({
      ...this.paneChartInheritedParams,
      id,
      dataSource: dataSource ?? null,
      basedOn: dataSource ? undefined : this.mainPane,
      onDelete: () => this.destroyPane(id),
    });

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

    return newPane;
  }

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

  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 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 { PriceAxisLabelsController } from '@core/PriceAxisLabels';
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 priceAxisLabelsController: PriceAxisLabelsController;
  private optionsSubscription: Subscription;
  private dataSource: DataSource;
  private chartConfig: Omit<ChartConfig, 'theme' | 'mode'>;
  private mainSeries: BehaviorSubject<SeriesStrategies | 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.mainSeries = this.paneManager.getMainPane().getMainSerie();

    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.priceAxisLabelsController = new PriceAxisLabelsController({
      mainSeries$: this.paneManager.getMainPane().getMainSerie().asObservable(),
      mainSymbol$: this.eventManager.symbol(),
      compareEntities$: this.compareManager.entities(),
      indicatorEntities$: this.indicatorManager.entities(),
    });

    this.priceAxisLabelsController.setVisibleLogicalRange(this.lwcChart.timeScale().getVisibleLogicalRange());

    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 }));
    this.priceAxisLabelsController.invalidate();
  }

  public destroy(): void {
    this.priceAxisLabelsController.destroy();
    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);
        }
      }),
    );
  }

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

      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 { IPriceScaleApi, PriceScaleMode } from 'lightweight-charts';

import { ReactRenderer } from '@core/ReactRenderer';
import { PriceScaleControls } from '@src/components/PriceScaleControls';
import { PriceScaleTarget } from '@src/core/PriceScale';
import { Direction } from '@src/types';

interface PriceScaleControlsControllerParams {
  paneId: number;
  getPaneElement: () => HTMLElement | null;
  getPriceScale: (priceScaleId: Direction) => IPriceScaleApi;
  getPriceScaleTarget: (priceScaleId: Direction) => PriceScaleTarget;
  getSavedPriceScaleMode: (target: PriceScaleTarget) => PriceScaleMode;
  togglePriceScaleMode: (target: PriceScaleTarget, mode: PriceScaleMode) => void;
}

export class PriceScaleControlsController {
  private readonly paneId: number;
  private readonly getPaneElement: () => HTMLElement | null;
  private readonly getPriceScale: (priceScaleId: Direction) => IPriceScaleApi;
  private readonly getPriceScaleTarget: (priceScaleId: Direction) => PriceScaleTarget;
  private readonly getSavedPriceScaleMode: (target: PriceScaleTarget) => PriceScaleMode;
  private readonly togglePriceScaleMode: (target: PriceScaleTarget, mode: PriceScaleMode) => void;

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

  private paneElement: HTMLElement | null = null;
  private hoveredPriceScaleId: Direction | null = null;

  constructor({
    paneId,
    getPaneElement,
    getPriceScale,
    getPriceScaleTarget,
    getSavedPriceScaleMode,
    togglePriceScaleMode,
  }: PriceScaleControlsControllerParams) {
    this.paneId = paneId;
    this.getPaneElement = getPaneElement;
    this.getPriceScale = getPriceScale;
    this.getPriceScaleTarget = getPriceScaleTarget;
    this.getSavedPriceScaleMode = getSavedPriceScaleMode;
    this.togglePriceScaleMode = togglePriceScaleMode;

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

  public mount(): void {
    const nextPaneElement = this.getPaneElement();

    if (!nextPaneElement) {
      return;
    }

    if (this.paneElement === nextPaneElement && this.container.parentElement === nextPaneElement) {
      return;
    }

    this.unmountEvents();

    this.paneElement = nextPaneElement;
    this.paneElement.style.position = 'relative';
    this.paneElement.appendChild(this.container);

    this.paneElement.addEventListener('pointermove', this.handlePointerMove);
    this.paneElement.addEventListener('pointerleave', this.handlePointerLeave);

    this.update();
  }

  public update(): void {
    if (!this.hoveredPriceScaleId) {
      this.hide();
      return;
    }

    this.render(this.hoveredPriceScaleId);
  }

  public resetAutoScale(): void {
    this.setAutoScale(Direction.Left);
    this.setAutoScale(Direction.Right);
    this.update();
  }

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

  private createContainer(): HTMLElement {
    const container = document.createElement('div');

    container.className = 'moex-price-scale-controls';
    container.style.position = 'absolute';
    container.style.bottom = 'var(--space-0500)';
    container.style.zIndex = '10';
    container.style.pointerEvents = 'none';

    return container;
  }

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

    const paneRect = this.paneElement.getBoundingClientRect();
    const pointerX = event.clientX - paneRect.left;
    const nextPriceScaleId = this.getHoveredPriceScaleId(pointerX, paneRect.width);

    if (this.hoveredPriceScaleId === nextPriceScaleId) {
      if (nextPriceScaleId) {
        this.updatePosition(nextPriceScaleId);
      }

      return;
    }

    this.hoveredPriceScaleId = nextPriceScaleId;
    this.update();
  };

  private handlePointerLeave = (): void => {
    this.hoveredPriceScaleId = null;
    this.update();
  };

  private render(priceScaleId: Direction): void {
    if (!this.isAvailable(priceScaleId)) {
      this.hide();
      return;
    }

    this.updatePosition(priceScaleId);

    const target = this.getPriceScaleTarget(priceScaleId);
    const savedMode = this.getSavedPriceScaleMode(target);

    this.renderer.renderComponent(
      <PriceScaleControls
        isAutoScale={this.isAutoScale(priceScaleId)}
        isLogarithmic={savedMode === PriceScaleMode.Logarithmic}
        onAutoScale={() => {
          this.setAutoScale(priceScaleId);
          this.update();
        }}
        onToggleLogarithmic={() => {
          this.togglePriceScaleMode(target, PriceScaleMode.Logarithmic);
          this.update();
        }}
        onRefresh={() => {
          this.update();
        }}
      />,
    );

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

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

  private getHoveredPriceScaleId(pointerX: number, paneWidth: number): Direction | null {
    const leftWidth = this.getWidth(Direction.Left);
    const rightWidth = this.getWidth(Direction.Right);

    if (this.isAvailable(Direction.Left) && pointerX >= 0 && pointerX <= leftWidth) {
      return Direction.Left;
    }

    if (this.isAvailable(Direction.Right) && pointerX >= paneWidth - rightWidth && pointerX <= paneWidth) {
      return Direction.Right;
    }

    return null;
  }

  private updatePosition(priceScaleId: Direction): void {
    const width = this.getWidth(priceScaleId);

    this.container.style.width = `${width}px`;

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

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

  private setAutoScale(priceScaleId: Direction): void {
    try {
      this.getPriceScale(priceScaleId).setAutoScale(true);
    } catch {
      // ignore inactive price scale
    }
  }

  private isAutoScale(priceScaleId: Direction): boolean {
    try {
      return this.getPriceScale(priceScaleId).options().autoScale ?? true;
    } catch {
      return true;
    }
  }

  private isAvailable(priceScaleId: Direction): boolean {
    try {
      const priceScale = this.getPriceScale(priceScaleId);
      const options = priceScale.options();

      if (options.visible === false) {
        return false;
      }

      return this.getWidth(priceScaleId) > 0;
    } catch {
      return false;
    }
  }

  private getWidth(priceScaleId: Direction): number {
    try {
      return this.getPriceScale(priceScaleId).width();
    } catch {
      return 0;
    }
  }

  private unmountEvents(): void {
    if (!this.paneElement) {
      return;
    }

    this.paneElement.removeEventListener('pointermove', this.handlePointerMove);
    this.paneElement.removeEventListener('pointerleave', this.handlePointerLeave);
    this.paneElement = null;
  }
}



import { CHART_ROOT_CLASSNAME } from '@src/constants';

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

interface CreateContainersOptions {
  parentContainer: HTMLElement;
  showBottomPanel?: boolean;
  showMenuButton?: boolean;
}

enum ZIndex {
  Chart = '0',
  Base = '10',
  Modal = '1000',
}

const ContainerLayoutConfig = {
  headerHeight: 36,
  footerHeight: 32,
  toolbarWidth: 42,
  verticalGap: 8,
};

/**
 * Утилита для создания DOM контейнеров
 */
export class ContainerManager {
  private static injectFont(): void {
    const existingLink = document.querySelector('link[href*="fonts.googleapis.com"]');
    if (existingLink) return;

    const link = document.createElement('link');
    link.rel = 'stylesheet';
    link.href = 'https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,100..900&display=swap';
    document.head.appendChild(link);
  }

  /**
   * Создание контейнеров для графика и UI компонентов
   */
  static createContainers({ parentContainer, showBottomPanel, showMenuButton }: CreateContainersOptions) {
    this.injectFont();

    const { headerHeight, footerHeight, toolbarWidth, verticalGap } = ContainerLayoutConfig;

    parentContainer.innerHTML = '';
    parentContainer.classList.add(CHART_ROOT_CLASSNAME);
    parentContainer.style.height = '100%';
    parentContainer.style.width = '100%';
    parentContainer.style.padding = 'var(--space-1000)';
    parentContainer.style.backgroundColor = 'var(--neutral-0)';
    parentContainer.style.borderRadius = 'var(--space-0500)';

    parentContainer.style.display = 'grid';
    parentContainer.style.rowGap = `${verticalGap}px`;
    parentContainer.style.gridTemplateRows = showBottomPanel
      ? `${headerHeight}px minmax(0, 1fr) ${footerHeight}px`
      : `${headerHeight}px minmax(0, 1fr)`;

    const headerContainer = document.createElement('div');
    headerContainer.style.width = '100%';
    headerContainer.style.height = `${headerHeight}px`;
    headerContainer.style.overflow = 'auto hidden';
    headerContainer.className = styles.scrollableBox;

    const footerContainer = document.createElement('div');
    footerContainer.style.width = '100%';
    footerContainer.style.height = `${footerHeight}px`;

    const chartContainer = document.createElement('div');
    chartContainer.style.height = '100%';
    chartContainer.style.width = '100%';
    chartContainer.style.minWidth = '0';
    chartContainer.style.minHeight = '0';
    chartContainer.style.display = 'grid';
    chartContainer.style.columnGap = 'var(--space-0500)';
    chartContainer.style.gridTemplateColumns = 'minmax(0, 1fr)';

    const chartAreaContainer = document.createElement('div');
    chartAreaContainer.style.position = 'relative';
    chartAreaContainer.style.height = '100%';
    chartAreaContainer.style.minHeight = '0';
    chartAreaContainer.style.minWidth = '0';
    chartAreaContainer.style.cursor = 'crosshair';

    const toolBarContainer = document.createElement('div');
    toolBarContainer.style.height = '100%';
    toolBarContainer.style.minHeight = '0';
    toolBarContainer.style.overflow = 'hidden';

    const controlBarContainer = document.createElement('div');
    controlBarContainer.style.width = '250px';
    controlBarContainer.style.position = 'absolute';
    controlBarContainer.style.left = '50%';
    controlBarContainer.style.transform = 'translateX(-50%)';
    controlBarContainer.style.bottom = 'var(--space-2000)';
    controlBarContainer.style.zIndex = ZIndex.Base;

    const modalContainer = document.createElement('div');
    modalContainer.className = 'moex-chart-modal-container';
    modalContainer.style.position = 'absolute';
    modalContainer.style.width = '100%';
    modalContainer.style.height = '100%';
    modalContainer.style.maxWidth = '100%';
    modalContainer.style.maxHeight = '100%';
    modalContainer.style.zIndex = ZIndex.Modal;
    modalContainer.style.pointerEvents = 'none';

    chartAreaContainer.append(controlBarContainer, modalContainer);
    chartContainer.append(chartAreaContainer);
    parentContainer.append(headerContainer, chartContainer);

    if (showBottomPanel) {
      parentContainer.append(footerContainer);
    }

    const toggleToolbar = () => {
      const mounted = toolBarContainer.isConnected;

      if (mounted) {
        toolBarContainer.remove();
        chartContainer.style.gridTemplateColumns = 'minmax(0, 1fr)';
        return false;
      }

      chartContainer.insertBefore(toolBarContainer, chartAreaContainer);
      chartContainer.style.gridTemplateColumns = `${toolbarWidth}px minmax(0, 1fr)`;
      return true;
    };

    chartContainer.insertBefore(toolBarContainer, chartAreaContainer);
    chartContainer.style.gridTemplateColumns = `${toolbarWidth}px minmax(0, 1fr)`;

    if (!showMenuButton) {
      toggleToolbar();
    }

    return {
      headerContainer,
      footerContainer,

      chartContainer,
      chartAreaContainer,
      toolBarContainer,

      modalContainer,
      controlBarContainer,

      toggleToolbar,
    };
  }

  /**
   * Очистка контейнеров
   */
  static clearContainers(parentContainer: HTMLElement): void {
    parentContainer.innerHTML = '';
  }

  static createPaneContainers() {
    const legendContainer = document.createElement('div');
    legendContainer.style.width = '80%';
    legendContainer.style.position = 'absolute';
    legendContainer.style.top = '0';
    legendContainer.style.left = '0';
    legendContainer.style.zIndex = ZIndex.Base;
    legendContainer.style.pointerEvents = 'none';

    const paneOverlayContainer = document.createElement('div');
    paneOverlayContainer.className = 'moex-chart-pane-overlay-container';
    paneOverlayContainer.style.position = 'absolute';
    paneOverlayContainer.style.inset = '0';
    paneOverlayContainer.style.zIndex = ZIndex.Base;
    paneOverlayContainer.style.pointerEvents = 'none';

    return {
      legendContainer,
      paneOverlayContainer,
    };
  }
}