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


import { PriceScaleMode } from 'lightweight-charts';

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

import { PriceScale } from './PriceScale';

interface PriceScaleControlsParams {
  leftPriceScale: PriceScale;
  rightPriceScale: PriceScale;
  onPriceScaleChange: () => void;
}

interface PriceScaleElements {
  left: HTMLTableCellElement;
  right: HTMLTableCellElement;
}

export class PriceScaleControls {
  private readonly leftPriceScale: PriceScale;
  private readonly rightPriceScale: PriceScale;
  private readonly onPriceScaleChange: () => void;
  private readonly container: HTMLElement;
  private readonly renderer: ReactRenderer;

  private paneElement: HTMLElement | null = null;
  private leftPriceScaleElement: HTMLTableCellElement | null = null;
  private rightPriceScaleElement: HTMLTableCellElement | null = null;
  private hoveredPriceScale: PriceScale | null = null;

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

    this.container = document.createElement('div');
    this.container.className = 'moex-price-scale-controls';
    this.renderer = new ReactRenderer(this.container);
  }

  public mount(paneElement: HTMLElement): void {
    const priceScaleElements = this.getPriceScaleElements(paneElement);

    if (!priceScaleElements) {
      this.unmount();
      return;
    }

    if (this.paneElement !== paneElement) {
      this.unmount();

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

    this.leftPriceScaleElement = priceScaleElements.left;
    this.rightPriceScaleElement = priceScaleElements.right;

    this.leftPriceScaleElement.style.position = 'relative';
    this.rightPriceScaleElement.style.position = 'relative';

    this.refresh();
  }

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

    this.render(this.hoveredPriceScale);
  }

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

  private handlePointerMove = (event: PointerEvent): void => {
    const nextPriceScale = this.getHoveredPriceScale(event.clientX);

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

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

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

  private render(priceScale: PriceScale): void {
    const priceScaleElement = this.getPriceScaleElement(priceScale);

    if (!priceScaleElement || !this.canShowControls(priceScale, priceScaleElement)) {
      this.hide();
      return;
    }

    if (this.container.parentElement !== priceScaleElement) {
      priceScaleElement.appendChild(this.container);
    }

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

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

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

  private getHoveredPriceScale(clientX: number): PriceScale | null {
    if (this.isPointerInsidePriceScale(this.leftPriceScale, clientX)) {
      return this.leftPriceScale;
    }

    if (this.isPointerInsidePriceScale(this.rightPriceScale, clientX)) {
      return this.rightPriceScale;
    }

    return null;
  }

  private isPointerInsidePriceScale(priceScale: PriceScale, clientX: number): boolean {
    const priceScaleElement = this.getPriceScaleElement(priceScale);

    if (!priceScaleElement || !this.canShowControls(priceScale, priceScaleElement)) {
      return false;
    }

    const priceScaleRect = priceScaleElement.getBoundingClientRect();

    return clientX >= priceScaleRect.left && clientX <= priceScaleRect.right;
  }

  private canShowControls(priceScale: PriceScale, priceScaleElement: HTMLTableCellElement): boolean {
    return priceScale.isVisible() && priceScale.hasVisibleSeriesData() && priceScaleElement.clientWidth > 0;
  }

  private getPriceScaleElement(priceScale: PriceScale): HTMLTableCellElement | null {
    return priceScale.side === Direction.Left ? this.leftPriceScaleElement : this.rightPriceScaleElement;
  }

  private getPriceScaleElements(paneElement: HTMLElement): PriceScaleElements | null {
    if (paneElement.children.length < 3) {
      return null;
    }

    const leftPriceScaleElement = paneElement.children.item(0);
    const rightPriceScaleElement = paneElement.children.item(paneElement.children.length - 1);

    if (
      !(leftPriceScaleElement instanceof HTMLTableCellElement) ||
      !(rightPriceScaleElement instanceof HTMLTableCellElement)
    ) {
      return null;
    }

    return {
      left: leftPriceScaleElement,
      right: rightPriceScaleElement,
    };
  }

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

    this.paneElement = null;
    this.leftPriceScaleElement = null;
    this.rightPriceScaleElement = null;
    this.hoveredPriceScale = null;

    this.container.remove();
    this.hide();
  }
}



import classNames from 'classnames';
import { Button, Tooltip } from 'exchange-elements/v2';

import { t } from '@src/translations';

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

import type { SyntheticEvent } from 'react';

interface PriceScaleControlsProps {
  isAutoScale: boolean;
  isLogarithmic: boolean;
  onToggleAutoScale: () => void;
  onToggleLogarithmic: () => void;
}

export function PriceScaleControls({
  isAutoScale,
  isLogarithmic,
  onToggleAutoScale,
  onToggleLogarithmic,
}: PriceScaleControlsProps) {
  const stopPropagation = (event: SyntheticEvent) => {
    event.stopPropagation();
  };

  return (
    <section
      className={classNames(styles.root, 'moex-price-scale-controls-content')}
      onPointerDown={stopPropagation}
      onMouseDown={stopPropagation}
      onClick={stopPropagation}
    >
      <Tooltip
        tooltipClassName={styles.tooltip}
        label={t('AutoScale')}
      >
        <Button
          size="sm"
          className={classNames(styles.button, {
            [styles.pressed]: isAutoScale,
          })}
          onClick={onToggleAutoScale}
          label="A"
        />
      </Tooltip>

      <Tooltip
        tooltipClassName={styles.tooltip}
        label={t('Logarithmic')}
      >
        <Button
          size="sm"
          className={classNames(styles.button, {
            [styles.pressed]: isLogarithmic,
          })}
          onClick={onToggleLogarithmic}
          label="L"
        />
      </Tooltip>
    </section>
  );
}


import { IChartApi, IPaneApi, PriceScaleMode, Time } from 'lightweight-charts';

import { BehaviorSubject, Subscription } from 'rxjs';

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

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

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

export class Pane implements ISerializable<PaneSnapshot> {
  private readonly id: number;
  private readonly isMain: boolean;
  private mainSeries = new BehaviorSubject<SeriesStrategies | null>(null); // Main Series. Exists in a single copy
  private legend!: Legend;
  private tooltip: TooltipService | undefined;
  private readonly indicatorsMap = new BehaviorSubject<Map<string, Indicator>>(new Map());
  private readonly lwcPane: IPaneApi<Time>;
  private readonly lwcChart: IChartApi;
  private readonly eventManager: EventManager;
  private readonly drawingsManager: DrawingsManager;
  private legendContainer!: HTMLElement;
  private paneOverlayContainer!: HTMLElement;
  private legendRenderer!: UIRenderer;
  private tooltipRenderer: UIRenderer | undefined;
  private readonly modalRenderer: ModalRenderer;
  private readonly leftPriceScale: PriceScale;
  private readonly rightPriceScale: PriceScale;
  private readonly priceScaleControls: PriceScaleControls;
  private mainSerieSub?: Subscription;
  private readonly subscribeChartEvent: ChartMouseEvents['subscribe'];
  private readonly onDelete: () => void;
  private readonly onPriceScaleStateChange: () => void;
  private readonly subscriptions = new Subscription();
  private paneContainerSyncFrameId: number | null = null;

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

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

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

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

    this.initializeLegend({ ohlcConfig });

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

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

    this.tooltipRenderer.renderComponent(
      <ChartTooltip
        formatObs={this.eventManager.getChartOptionsModel()}
        timeframeObs={this.eventManager.getTimeframeObs()}
        viewModel={this.tooltip.getTooltipViewModel()}
        // 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,
      hotkeys,
    });

    this.subscriptions.add(
      this.drawingsManager.entities().subscribe((drawings) => {
        const hasRuler = drawings.some((drawing) => drawing.getDrawingName() === DrawingsNames.ruler);

        this.legendContainer.style.display = hasRuler ? 'none' : '';
      }),
    );
  }

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

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

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

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

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

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

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

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

    map.set(indicatorId, indicator);
    this.indicatorsMap.next(map);
    this.priceScaleControls.refresh();
  }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    const indicators = Array.from(this.indicatorsMap.value.values());

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

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

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

    return false;
  }

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

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

    this.legendContainer = legendContainer;
    this.paneOverlayContainer = paneOverlayContainer;
    this.legendRenderer = new ReactRenderer(legendContainer);

    this.schedulePaneContainerSync();

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

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

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

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

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

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

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

    if (!lwcPaneElement) {
      this.schedulePaneContainerSync();
      return;
    }

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

    if (!chartCell) {
      this.schedulePaneContainerSync();
      return;
    }

    chartCell.style.position = 'relative';
    chartCell.appendChild(this.legendContainer);
    chartCell.appendChild(this.paneOverlayContainer);

    this.priceScaleControls.mount(lwcPaneElement);
  }
}