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


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

import { Observable } from 'rxjs';

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

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

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

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

const mainSerieKeys = new Set(ohlcValuesToShowForMainSerie);

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

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

  const shouldShowMainSerieValue = (key: string) => {
    if (mainSerieKeys.has(key)) {
      return showMainSerieValues;
    }

    if (key === 'absoluteChange') {
      return ohlcConfig?.showAbsoluteChange ?? false;
    }

    if (key === 'percentageChange') {
      return ohlcConfig?.showPercentageChange ?? true;
    }

    return false;
  };

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

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

            if (!value) {
              return null;
            }

            const text = formatDisplayText(value.value);

            if (!text) {
              return null;
            }

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

          {item.settings && (
            <Button
              size="sm"
              className={styles.button}
              onClick={item.settings}
              label={<GearIcon />}
            />
          )}

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

















.legend {
  display: flex;
  width: 100%;
  flex-direction: column;
  gap: var(--space-0250);
  color: var(--neutral-12);

  .row {
    display: flex;
    justify-content: start;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--space-0125) var(--space-0500);
    pointer-events: auto;
    width: 100%;

    .button {
      display: none;
      width: var(--space-1000);
      height: var(--space-1000);
      border-radius: var(--space-0125);
      color: var(--neutral-13);
      padding: 0;
      min-width: 0;
      opacity: 0;
      pointer-events: none;

      & p {
        display: flex;
        justify-content: center;
        align-items: start;
        padding: 0;

        svg {
          width: var(--space-0875);
          height: var(--space-0875);
        }
        padding: 0;

        svg {
          width: var(--space-0875);
          height: var(--space-0875);
        }
      }
    }

    &:hover .button {
      display: block;
      opacity: 1;
      pointer-events: auto;
    }
  }

  .item {
    display: inline-flex;
    gap: var(--space-0250);
  }

  .item,
  .symbol {
    font-size: var(--space-0750);
    line-height: var(--space-1000);
    font-weight: var(--font-medium);
  }
}

















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

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

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

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

export interface LegendParams {
  config: OHLCConfig;
  eventManager: EventManager;
  indicators: BehaviorSubject<Map<string, Indicator>>;
  subscribeChartEvent: ChartMouseEvents['subscribe'];
  mainSeries: BehaviorSubject<SeriesStrategies | null> | null;
  paneId: number;
  paneIndex: () => number;
  openIndicatorSettings: (id: IndicatorsIds, indicator: Indicator) => void;
}

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

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

export interface LegendVM {
  vm: LegendModel;
}

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

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

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

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

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

  private openIndicatorSettings: (id: IndicatorsIds, indicator: Indicator) => void;
  private subscriptions = new Subscription();
  private indicatorSubscriptions = new Subscription();
  private mainSeriesSubscription = new Subscription();

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

    this.subscriptions.add(
      this.eventManager.symbolName().subscribe((symbolName) => {
        this.mainSymbolName = symbolName;
        this.updateWithLastCandle();
      }),
    );

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

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

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

  private handleIndicatorSeriesDataChange = () => {
    this.indicatorSubscriptions.unsubscribe();
    this.indicatorSubscriptions = new Subscription();

    for (const [_, indicator] of this.indicators) {
      this.indicatorSubscriptions.add(indicator.subscribeDataChange(this.updateWithLastCandle));
    }

    this.updateWithLastCandle();
  };

  private handleMainSeriesChange = () => {
    this.mainSeriesSubscription.unsubscribe();
    this.mainSeriesSubscription = new Subscription();

    const handler = () => this.updateWithLastCandle();

    this.mainSeries?.subscribeDataChanged(handler);

    this.mainSeriesSubscription.add(() => this.mainSeries?.unsubscribeDataChanged(handler));
  };

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

    const model: LegendModel = [];

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

      const serieData = this.mainSeries.getLegendData();

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

      model.push({
        id: `main-series-${this.paneId}`,
        name: this.mainSymbolName,
        values: series as Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>,
        isIndicator: false,
      });
    }

    if (!this.indicators) {
      return;
    }

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

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

        const serieData = serie.getLegendData();
        const value = serieData.value ?? serieData.close;

        if (!value) continue;

        indicatorSeries.set(serieName, { ...value, name: indicator.getSeriesLabel(serieName) });
      }

      model.push({
        id: indicator.getId(),
        name: indicator.getLabel(),
        values: indicatorSeries as Partial<
          Record<keyof Ohlc, { value: number | string | Time; color: string; name?: string }>
        >,
        isIndicator: true,
        remove: () => indicator.delete(),
        settings:
          indicatorType && indicator.hasSettings()
            ? () => this.openIndicatorSettings(indicatorType, indicator)
            : undefined,
      });
    }

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

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

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

    if (param.seriesData.size === 0) {
      this.updateWithLastCandle();
      return;
    }

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

    this.isChartHovered = true;

    const model: LegendModel = [];

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

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

      model.push({
        id: `main-series-${this.paneId}`,
        name: this.mainSymbolName,
        values: series as Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>,
        isIndicator: false,
      });
    }

    if (!this.indicators) {
      return;
    }

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

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

        const serieData = serie.getLegendData(param);
        const value = serieData.value ?? serieData.close;

        if (!value) continue;

        indicatorSeries.set(serieName, { ...value, name: indicator.getSeriesLabel(serieName) });
      }

      model.push({
        id: indicator.getId(),
        name: indicator.getLabel(),
        values: indicatorSeries as Partial<
          Record<keyof Ohlc, { value: number | string | Time; color: string; name?: string }>
        >,
        isIndicator: true,
        remove: () => indicator.delete(),
        settings:
          indicatorType && indicator.hasSettings()
            ? () => this.openIndicatorSettings(indicatorType, indicator)
            : undefined,
      });
    }

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

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

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

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

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

















import { IChartApi, IPaneApi, PriceScaleMode, Time } from 'lightweight-charts';
import { BehaviorSubject, combineLatest, 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 { ActiveDrawingTool, Direction, OHLCConfig, TooltipConfig } from '@src/types';
import {
  CompareSnapshot,
  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;
  addDrawingManager: (manager: DrawingsManager, paneId: number) => void;
  removeDrawingManager: (paneId: number) => void;
  setActiveTool: (name: ActiveDrawingTool) => void;
  getActiveTool: () => ActiveDrawingTool;
  getIsEndlessMode: () => boolean;
  continueDrawing: (name: DrawingsNames) => void;
}

// todo: Pane, ему должна принадлежать mainSerie, а также IndicatorManager и drawingsManager, mouseEvents. Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
// todo: Учитывать, что есть линейка, которая рисуется одна для всех пейнов
// todo: в CompareManage, при создании нового пейна для сравнения - инициализируем новый dataSource, принадлежащий только конкретному пейну. Убираем возможность добавлять индикаторы на такие пейны
// todo: на каждый символ свой DataSource (учитывать что есть MainPane и "главный" DataSource, который инициализиурется во время старта moexChart)
// todo: сделать два разных представления для compare, в зависимости от отображения на главном пейне или на второстепенном
export class Pane implements ISerializable<PaneSnapshot> {
  private readonly id: number;
  private readonly isMain: boolean;
  private mainSeries = new BehaviorSubject<SeriesStrategies | null>(null); // Main Series. Exists in a single copy
  // todo: Отвратительный нейминг. Нужно оставить главной только эту серию и переименовать её localMainSeries => mainSerie
  // Если вдруг понадобится главная серия главного пейна, то брать из КоллекцииПейнов(-сейчас PaneManager)
  // serie to attach drawings
  private localMainSeries = new BehaviorSubject<SeriesStrategies | null>(null);
  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 paneEmptyStateContainer!: 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 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,
    addDrawingManager,
    removeDrawingManager,
    setActiveTool,
    getActiveTool,
    getIsEndlessMode,
    continueDrawing,
  }: 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()}
        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.subscriptions.add(
      this.mainSeries.subscribe(() => {
        this.rebindIndicators();
      }),
    );

    this.subscriptions.add(
      combineLatest([this.eventManager.hasSymbol(), this.indicatorsMap]).subscribe(([hasMainSymbol, indicators]) => {
        const hasIndicator = Array.from(indicators.values()).some(
          (indicator) => indicator.getIndicatorType() !== undefined,
        );

        this.paneEmptyStateContainer.hidden = hasMainSymbol || !hasIndicator;
      }),
    );

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

    addDrawingManager(this.drawingsManager, this.id);

    this.subscriptions.add(() => removeDrawingManager(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 getLocalMainSeries = () => {
    return this.localMainSeries.value;
  };

  public setLocalMainSeries = (next: SeriesStrategies) => {
    if (!this.isMain) {
      this.localMainSeries.next(next);
    }
  };

  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 isReady = async (interval = 50): Promise<void> => {
    return new Promise((resolve) => {
      const check = () => {
        if (this.lwcPane.getHTMLElement() === null) {
          setTimeout(check, interval);
        } else {
          resolve();
        }
      };

      check();
    });
  };

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

  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 indicators = new Map(this.indicatorsMap.value);

    indicators.set(indicatorId, indicator);

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

  public removeIndicator(indicatorId: string): void {
    if (!this.indicatorsMap.value.has(indicatorId)) {
      return;
    }

    const indicators = new Map(this.indicatorsMap.value);

    indicators.delete(indicatorId);

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

    if (indicators.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 | CompareSnapshot))[] = [];

    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.drawingsManager.destroy();
    this.subscriptions.unsubscribe();
    this.tooltip?.destroy();
    this.legend?.destroy();
    this.legendRenderer.destroy();
    this.tooltipRenderer?.destroy();
    this.priceScaleControls.destroy();
    this.paneEmptyStateContainer.remove();
    this.legendContainer.remove();
    this.paneOverlayContainer.remove();
    this.indicatorsMap.complete();
    this.localMainSeries.complete();

    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;
    const defaultPriceScaleSide = this.lwcChart.options().defaultVisiblePriceScaleId as PriceScaleSide;

    if (this.isMain && side === defaultPriceScaleSide && 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 seriesPriceScaleSide = (currentSeries.options().priceScaleId ?? defaultPriceScaleSide) as PriceScaleSide;

        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, paneEmptyStateContainer } = ContainerManager.createPaneContainers();

    this.legendContainer = legendContainer;
    this.paneOverlayContainer = paneOverlayContainer;
    this.paneEmptyStateContainer = paneEmptyStateContainer;
    this.paneEmptyStateContainer.textContent = t('Add a main instrument to display indicators');
    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.getSettingsValues();

        this.modalRenderer.renderComponent(
          <EntitySettingsModal
            tabs={[
              {
                key: 'arguments',
                label: t('Arguments'),
                fields: indicator.getSettingsFields(),
              },
            ]}
            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 rebindIndicators(): void {
    for (const indicator of this.indicatorsMap.value.values()) {
      indicator.recreateSeries();
    }
  }

  private initializeMainSerie({ lwcChart, dataSource }: { lwcChart: IChartApi; dataSource: DataSource }): void {
    this.localMainSeries = this.mainSeries;

    this.subscriptions.add(
      this.eventManager.subscribeSeriesSelected((nextSeries) => {
        this.mainSeries.value?.destroy();

        const next = ensureDefined(SeriesFactory.create(nextSeries))({
          lwcChart,
          dataSource,
          mainSymbolId$: this.eventManager.symbolId(),
          mainSymbol$: this.eventManager.symbol(),
          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.paneEmptyStateContainer);
    chartCell.appendChild(this.paneOverlayContainer);

    this.priceScaleControls.mount(lwcPaneElement);
  }
}

















import { IChartApi, ISeriesApi, MouseEventParams, SeriesType } from 'lightweight-charts';
import { cloneDeep, isEqual } from 'lodash-es';
import { BehaviorSubject, distinctUntilChanged, map, Observable, Subscription } from 'rxjs';

import { EventManager } from '@core';
import { DOMModel } from '@core/DOMModel';
import { Drawing } from '@core/Drawings';
import { Hotkeys } from '@core/Hotkeys';

import { EntitySettingsModal } from '@src/components/EntitySettingsModal';
import { drawingLabelById, drawingsMap, DrawingsNames } from '@src/constants';
import { ModalRenderer } from '@src/core/ModalRenderer';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ActiveDrawingTool, DOMObjectSnapshot } from '@src/types';

import { Pane } from './Pane';

import type { DrawingInteraction } from '@src/core/Drawings/base';
import type { SettingsValues } from '@src/types/settings';

interface DrawingsManagerParams {
  eventManager: EventManager;
  mainSeries$: Observable<SeriesStrategies | null>;
  lwcChart: IChartApi;
  DOM: DOMModel;
  container: HTMLElement;
  modalRenderer: ModalRenderer;
  paneId: number;
  hotkeys: Hotkeys;
  pane: Pane;
  setActiveTool: (name: ActiveDrawingTool) => void;
  getActiveTool: () => ActiveDrawingTool;
  getIsEndlessMode: () => boolean;
  continueDrawing: (name: DrawingsNames) => void;
}

export interface DrawingSnapshotItem extends Partial<DOMObjectSnapshot> {
  id: string;
  drawingName: DrawingsNames;
  state: unknown;
  isLocked?: boolean;
  zIndex?: number;
}

interface CreateDrawingOptions {
  id?: string;
  state?: unknown;
  isLocked?: boolean;
  zIndex?: number;
  shouldUpdateDrawingsList?: boolean;
}

export type DrawingsManagerSnapshot = DrawingSnapshotItem[];

export class DrawingsManager {
  private eventManager: EventManager;
  private lwcChart: IChartApi;
  private DOM: DOMModel;
  private container: HTMLElement;
  private modalRenderer: ModalRenderer;
  private paneId: number;
  private hotkeys: Hotkeys;

  private mainSeries: SeriesStrategies | null = null;
  private subscriptions = new Subscription();
  private drawings$ = new BehaviorSubject<Drawing[]>([]);
  private selectedDrawing$ = new BehaviorSubject<Drawing | null>(null); // todo: переместить в DrawingsManagerCollection
  private pendingSnapshot: DrawingsManagerSnapshot | null = null;
  private selectedDrawingSnapshot: DrawingSnapshotItem | null = null;
  private pane: Pane;

  private copyPasteBuffer: DrawingSnapshotItem | null = null;
  private setActiveTool: (name: ActiveDrawingTool) => void;
  private getActiveTool: () => ActiveDrawingTool;
  private getIsEndlessMode: () => boolean;
  private continueDrawing: (name: DrawingsNames) => void;

  constructor({
    eventManager,
    mainSeries$,
    lwcChart,
    DOM,
    container,
    modalRenderer,
    paneId,
    hotkeys,
    pane,
    setActiveTool,
    getActiveTool,
    getIsEndlessMode,
    continueDrawing,
  }: DrawingsManagerParams) {
    this.DOM = DOM;
    this.eventManager = eventManager;
    this.paneId = paneId;
    this.pane = pane;
    this.lwcChart = lwcChart;
    this.container = container;
    this.modalRenderer = modalRenderer;
    this.hotkeys = hotkeys;
    this.setActiveTool = setActiveTool;
    this.getActiveTool = getActiveTool;
    this.getIsEndlessMode = getIsEndlessMode;
    this.continueDrawing = continueDrawing;

    this.subscriptions.add(
      mainSeries$.subscribe((series) => {
        if (!series) {
          return;
        }

        this.mainSeries = series;
        this.drawings$.value.forEach((drawing) => drawing.rebind(series));

        if (this.pendingSnapshot) {
          const snapshot = this.pendingSnapshot;
          this.pendingSnapshot = null;
          doAfterPromise(() => this.setSnapshot(snapshot), this.pane.isReady());
        }
      }),
    );

    window.addEventListener('pointerup', this.handlePointerUp);
    window.addEventListener('pointercancel', this.handlePointerUp);
    this.container.addEventListener('click', this.handleClick);
    this.container.addEventListener('pointerdown', this.handlePointerDown);
    this.container.addEventListener('dblclick', this.handleDoubleClick);
    this.container.addEventListener('contextmenu', this.handleContextMenu);
    // todo: implement ctrl+v
    // hotkeys.register({
    //   keys: [Keys.control, Keys.v],
    //   callback: () => {
    //     const bufferWithAppliedPosition = {
    //       ...this.copyPasteBuffer,
    //       state: {
    //         ...this.copyPasteBuffer?.state,
    //         startAnchor: {
    //           price: 73.36210252637723,
    //           time: 1783679170
    //         }
    //       }
    //     }
    //
    //     this.setSnapshot([
    //       ...this.getSnapshot(),
    //       bufferWithAppliedPosition
    //     ])
    //     // hotkeys.unregister({ // todo: unregister all else ctrl+c's
    //     //   keys: [Keys.control, Keys.c]
    //     // })
    //   }
    // })
  }

  // TODO: handlePointerDown конфликтует с DrawingsManagerCollection.handlePaneClick
  private handlePointerDown = (event: PointerEvent): void => {
    if (!this.isEventInPane(event)) {
      return;
    }

    this.selectedDrawingSnapshot = null;

    if (event.button === 0) {
      const drawings = this.drawings$.value;
      const pendingDrawing = drawings.find((drawing) => drawing.isCreationPending());

      if (!pendingDrawing && this.getActiveTool() !== 'crosshair') {
        return;
      }

      const selectedDrawing = this.selectedDrawing$.value;

      const drawing =
        pendingDrawing ??
        (selectedDrawing?.getSeriesDrawing().isHit(event) ? selectedDrawing : null) ??
        this.findTopDrawing(event);

      if (drawing && !drawing.isCreationPending()) {
        this.selectedDrawingSnapshot = this.createDrawingSnapshot(drawing);
      }

      (drawing ?? selectedDrawing)?.getSeriesDrawing().pointerDown(event);
    }

    this.DOM.refreshEntities();
  };

  private handlePointerUp = (): void => {
    const previousSnapshot = this.selectedDrawingSnapshot;

    this.selectedDrawingSnapshot = null;

    queueMicrotask(() => {
      if (!previousSnapshot) {
        return;
      }

      const drawing = this.findDrawing(previousSnapshot.id);

      if (!drawing || drawing.isCreationPending()) {
        return;
      }

      this.pushDrawingChange(previousSnapshot, this.createDrawingSnapshot(drawing));
    });

    this.DOM.refreshEntities();
  };

  private handleDoubleClick = (event: MouseEvent): void => {
    if (!this.isEventInPane(event)) {
      return;
    }

    const pendingDrawing = this.drawings$.value.find((drawing) => drawing.isCreationPending());
    const drawing = pendingDrawing ?? this.findTopDrawing(event);

    if (!drawing) {
      return;
    }

    if (!drawing.isCreationPending() && drawing !== this.selectedDrawing$.value) {
      this.selectedDrawing$.next(drawing);
    }

    drawing.getSeriesDrawing().doubleClick(event);

    this.DOM.refreshEntities();
  };

  private handleContextMenu = (event: MouseEvent): void => {
    if (!this.isEventInPane(event)) {
      return;
    }

    const pendingDrawing = this.drawings$.value.find((drawing) => drawing.isCreationPending());
    const drawing = pendingDrawing ?? this.findTopDrawing(event);

    drawing?.getSeriesDrawing().contextMenu(event);
  };

  private handleClick = (event: MouseEvent): void => {
    if (!this.isEventInPane(event)) {
      return;
    }

    const pendingDrawing = this.drawings$.value.find((drawing) => drawing.isCreationPending());

    pendingDrawing?.getSeriesDrawing().click(event);

    this.DOM.refreshEntities();
  };

  private isEventInPane(event: MouseEvent): boolean {
    const paneElement = this.pane.getHTMLElement();

    return paneElement !== null && event.target instanceof Node && paneElement.contains(event.target);
  }

  private findTopDrawing(event: MouseEvent): Drawing | null {
    let topDrawing: Drawing | null = null;

    for (const drawing of this.drawings$.value) {
      if (!drawing.getSeriesDrawing().isHit(event)) {
        continue;
      }

      if (!topDrawing || drawing.zIndex > topDrawing.zIndex) {
        topDrawing = drawing;
      }
    }

    return topDrawing;
  }

  private findDrawing(id: string): Drawing | undefined {
    return this.drawings$.value.find((drawing) => drawing.id === id);
  }

  private createDrawingSnapshot(drawing: Drawing): DrawingSnapshotItem {
    return {
      ...drawing.getSnapshot(),
      drawingName: drawing.getDrawingName(),
      state: cloneDeep(drawing.getState()),
      isLocked: drawing.isLocked(),
    };
  }

  private updateDrawing(drawing: Drawing, update: () => void): void {
    if (drawing.isCreationPending()) {
      return;
    }

    const previousSnapshot = this.createDrawingSnapshot(drawing);

    update();

    this.pushDrawingChange(previousSnapshot, this.createDrawingSnapshot(drawing));
  }

  private pushDrawingChange(
    previousSnapshot: DrawingSnapshotItem | null,
    nextSnapshot: DrawingSnapshotItem | null,
  ): void {
    if (isEqual(previousSnapshot, nextSnapshot)) {
      return;
    }

    const previous = cloneDeep(previousSnapshot);
    const next = cloneDeep(nextSnapshot);

    // todo: объединять последовательные изменения одного дровинга в одну запись истории
    this.eventManager.getUndoRedo().pushCommand({
      undo: () => {
        this.replaceDrawingSnapshot(next, previous);
      },
      redo: () => {
        this.replaceDrawingSnapshot(previous, next);
      },
    });
  }

  private replaceDrawingSnapshot(
    currentSnapshot: DrawingSnapshotItem | null,
    nextSnapshot: DrawingSnapshotItem | null,
  ): void {
    if (currentSnapshot && nextSnapshot && currentSnapshot.id === nextSnapshot.id) {
      const drawing = this.findDrawing(nextSnapshot.id);

      if (drawing) {
        drawing.setState(cloneDeep(nextSnapshot.state));
        drawing.setLocked(nextSnapshot.isLocked ?? false);
        this.DOM.refreshEntities();

        return;
      }
    }

    if (currentSnapshot) {
      this.removeDrawingInternal(currentSnapshot.id, false);
    }

    if (nextSnapshot) {
      this.restoreDrawing(nextSnapshot);
    }

    this.setActiveTool('crosshair');
  }

  private restoreDrawing(snapshot: DrawingSnapshotItem): Drawing {
    const existingDrawing = this.findDrawing(snapshot.id);

    if (existingDrawing) {
      existingDrawing.setState(cloneDeep(snapshot.state));
      existingDrawing.setLocked(snapshot.isLocked ?? false);

      if (snapshot.zIndex !== undefined) {
        existingDrawing.setZIndex(snapshot.zIndex);
      }

      this.drawings$.next([...this.drawings$.value].sort((left, right) => left.zIndex - right.zIndex));

      this.DOM.refreshEntities();

      return existingDrawing;
    }

    return this.createDrawing({
      name: snapshot.drawingName,
      options: {
        id: snapshot.id,
        state: cloneDeep(snapshot.state),
        isLocked: snapshot.isLocked,
        zIndex: snapshot.zIndex,
      },
    });
  }

  private updateActiveTool(): void {
    const hasPendingDrawing = this.drawings$.value.some((drawing) => drawing.isCreationPending());

    if (hasPendingDrawing) {
      return;
    }

    const activeTool = this.getActiveTool();

    if (activeTool !== 'crosshair' && this.getIsEndlessMode()) {
      this.continueDrawing(activeTool);

      return;
    }

    this.setActiveTool('crosshair');
  }

  private removeDrawing = (id: string): void => {
    const drawing = this.findDrawing(id);

    if (!drawing) {
      return;
    }

    if (drawing.isCreationPending()) {
      this.removeDrawingInternal(id);

      return;
    }

    const snapshot = this.createDrawingSnapshot(drawing);

    this.removeDrawingInternal(id);
    this.pushDrawingChange(snapshot, null);
  };

  private removeDrawingInternal(id: string, shouldUpdateTool = true): void {
    const drawing = this.findDrawing(id);

    if (!drawing) {
      return;
    }

    this.removeDrawings([drawing], shouldUpdateTool);
  }

  private removePendingDrawings(shouldUpdateTool = true): void {
    const drawingsToRemove = this.drawings$.value.filter((drawing) => drawing.isCreationPending());

    this.removeDrawings(drawingsToRemove, shouldUpdateTool);
  }

  private removeDrawings(drawingsToRemove: Drawing[], shouldUpdateTool = true): void {
    if (!drawingsToRemove.length) {
      return;
    }

    const selectedDrawing = this.selectedDrawing$.value;

    if (selectedDrawing && drawingsToRemove.includes(selectedDrawing)) {
      this.selectedDrawing$.next(null);
    }

    drawingsToRemove.forEach((drawing) => {
      drawing.destroy();
      this.DOM.removeEntity(drawing);
    });

    this.drawings$.next(this.drawings$.value.filter((drawing) => !drawingsToRemove.includes(drawing)));

    if (shouldUpdateTool) {
      this.updateActiveTool();
    }

    this.DOM.refreshEntities();
  }

  public startDrawing = async (name: DrawingsNames, event?: MouseEventParams): Promise<void> => {
    this.removePendingDrawings(false);

    const previousDrawing = drawingsMap[name].singleInstance
      ? this.drawings$.value.find((drawing) => drawing.getDrawingName() === name)
      : undefined;

    const previousSnapshot = previousDrawing ? this.createDrawingSnapshot(previousDrawing) : null;

    if (previousDrawing) {
      this.removeDrawingInternal(previousDrawing.id, false);
    }

    if (this.selectedDrawing$.value) {
      this.selectedDrawing$.next(null);
    }

    this.setActiveTool(name);

    const drawing = this.createDrawing({
      name,
      event,
    });

    this.DOM.refreshEntities();

    await drawing.waitForCreation();

    if (!this.findDrawing(drawing.id)) {
      if (previousSnapshot) {
        this.restoreDrawing(previousSnapshot);
      }

      return;
    }

    this.pushDrawingChange(previousSnapshot, this.createDrawingSnapshot(drawing));

    if (this.getActiveTool() === name) {
      this.selectedDrawing$.next(drawing);
      this.updateActiveTool();
    }

    this.DOM.refreshEntities();
  };

  private createDrawing({
    name,
    options = {},
    event,
  }: {
    name: DrawingsNames;
    options?: CreateDrawingOptions;
    event?: MouseEventParams;
  }): Drawing {
    const { mainSeries } = this;

    if (!mainSeries) {
      throw new Error('[Drawings] main series is not defined');
    }

    const { id, state, isLocked = false, zIndex, shouldUpdateDrawingsList = true } = options;

    const config = drawingsMap[name];
    const drawingId = id ?? crypto.randomUUID();

    let createdDrawing: Drawing | null = null;

    const selected$ = this.selectedDrawing$.pipe(
      map((drawing) => drawing?.id === drawingId),
      distinctUntilChanged(),
    );

    const construct = (chart: IChartApi, series: ISeriesApi<SeriesType>, interaction: DrawingInteraction) => {
      const paneElement = this.pane.getHTMLElement();

      if (!paneElement) {
        throw new Error('[Drawing Manager]: cannot place drawing, there is no pane');
      }

      const cells = paneElement.querySelectorAll<HTMLTableCellElement>(':scope > td');
      const canvasElement = cells.item(1);

      return config.construct({
        chart,
        series,
        eventManager: this.eventManager,
        container: canvasElement,
        interaction,
        removeSelf: () => this.removeDrawing(drawingId),
        openSettings: () => {
          if (createdDrawing) {
            this.openSettings(createdDrawing);
          }
        },
        initialEvent: event,
      });
    };

    const drawingFactory = (entityZIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) =>
      new Drawing({
        lwcChart: this.lwcChart,
        mainSeries,
        id: drawingId,
        drawingName: name,
        name: drawingLabelById()[name],
        onDelete: this.removeDrawing,
        onCopy: () => {
          if (createdDrawing) {
            this.copyPasteBuffer = this.createDrawingSnapshot(createdDrawing);
          }
        },
        zIndex: entityZIndex,
        moveDown,
        moveUp,
        construct,
        selected$,
        isSelected: () => this.selectedDrawing$.value?.id === drawingId,
        select: () => {
          if (!createdDrawing || createdDrawing.isCreationPending() || this.selectedDrawing$.value === createdDrawing) {
            return;
          }

          this.selectedDrawing$.next(createdDrawing);
        },
        deselect: () => {
          if (!createdDrawing || this.selectedDrawing$.value !== createdDrawing) {
            return;
          }

          this.selectedDrawing$.next(null);
        },
        isLocked,
        paneId: this.paneId,
        hotkeys: this.hotkeys,
      });

    const entity = this.DOM.setEntity<Drawing>(drawingFactory, zIndex);

    createdDrawing = entity;

    if (state !== undefined) {
      entity.setState(cloneDeep(state));
    }

    if (shouldUpdateDrawingsList) {
      this.drawings$.next([...this.drawings$.value, entity].sort((left, right) => left.zIndex - right.zIndex));
    }

    return entity;
  }

  public getSnapshot(): DrawingsManagerSnapshot {
    return this.drawings$.value
      .filter((drawing) => !drawing.isCreationPending())
      .map((drawing) => this.createDrawingSnapshot(drawing));
  }

  public setSnapshot(snapshot: DrawingsManagerSnapshot): void {
    if (!Array.isArray(snapshot)) {
      return;
    }

    if (!this.mainSeries) {
      this.pendingSnapshot = cloneDeep(snapshot);

      return;
    }

    this.selectedDrawingSnapshot = null;
    this.removeDrawings(this.drawings$.value, false);

    const restoredDrawings = snapshot.reduce<Drawing[]>((drawings, item) => {
      if (!drawingsMap[item.drawingName]) {
        return drawings;
      }

      drawings.push(
        this.createDrawing({
          name: item.drawingName,
          options: {
            id: item.id,
            state: cloneDeep(item.state),
            isLocked: item.isLocked,
            zIndex: item.zIndex,
            shouldUpdateDrawingsList: false,
          },
        }),
      );

      return drawings;
    }, []);

    this.drawings$.next(restoredDrawings.sort((left, right) => left.zIndex - right.zIndex));

    this.setActiveTool('crosshair');
    this.DOM.refreshEntities();
  }

  public cancelPendingDrawing(): void {
    this.removePendingDrawings(false);
    this.DOM.refreshEntities();
  }

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

  public selectedDrawing(): Observable<Drawing | null> {
    return this.selectedDrawing$.asObservable();
  }

  public updateSelectedDrawingSettings = (settings: SettingsValues): void => {
    const drawing = this.selectedDrawing$.value;

    if (!drawing) {
      return;
    }

    this.updateDrawing(drawing, () => {
      drawing.updateSettings(settings);
    });
  };

  public openSelectedDrawingSettings(): void {
    const drawing = this.selectedDrawing$.value;

    if (!drawing) {
      return;
    }

    this.openSettings(drawing);
  }

  public deleteSelectedDrawing(): void {
    const drawing = this.selectedDrawing$.value;

    if (!drawing) {
      return;
    }

    this.removeDrawing(drawing.id);
  }

  public toggleSelectedDrawingLock(): void {
    const drawing = this.selectedDrawing$.value;

    if (!drawing) {
      return;
    }

    this.updateDrawing(drawing, () => {
      drawing.toggleLock();
    });
  }

  private openSettings = (drawing: Drawing): void => {
    const tabs = drawing.getSettingsTabs();

    if (!tabs.length || tabs.every((tab) => tab.fields.length === 0)) {
      return;
    }

    let settings = drawing.getSettings();

    this.modalRenderer.renderComponent(
      <EntitySettingsModal
        tabs={tabs}
        values={settings}
        onChange={(nextSettings) => {
          settings = nextSettings;
        }}
        initialTabKey={tabs[0]?.key}
      />,
      {
        size: 'sm',
        title: drawing.name,
        onSave: () => {
          if (!this.findDrawing(drawing.id)) {
            return;
          }

          this.updateDrawing(drawing, () => {
            drawing.updateSettings(settings);
          });
        },
      },
    );
  };

  public getDrawings(): Drawing[] {
    return this.drawings$.value;
  }

  public hideAll(): void {
    if (this.selectedDrawing$.value) {
      this.selectedDrawing$.next(null);
    }

    this.drawings$.value.forEach((drawing) => drawing.hide());
    this.DOM.refreshEntities();
  }

  public destroy(): void {
    window.removeEventListener('pointerup', this.handlePointerUp);
    window.removeEventListener('pointercancel', this.handlePointerUp);
    this.container.removeEventListener('click', this.handleClick);
    this.container.removeEventListener('pointerdown', this.handlePointerDown);
    this.container.removeEventListener('dblclick', this.handleDoubleClick);
    this.container.removeEventListener('contextmenu', this.handleContextMenu);

    this.drawings$.value.forEach((drawing) => drawing.destroy());

    this.selectedDrawingSnapshot = null;
    this.copyPasteBuffer = null;

    this.subscriptions.unsubscribe();
    this.drawings$.complete();
    this.selectedDrawing$.complete();
  }
}

async function doAfterPromise(cb: () => void, waiter: Promise<void>) {
  await waiter;
  cb();
}

















import { BehaviorSubject, Observable } from 'rxjs';

import { DrawingsNames } from '@src/constants';
import { DRAWING_KEYBOARD_SHORTCUTS, findDrawingPointerShortcut } from '@src/core/Drawings/shortcuts';
import { ActiveDrawingTool, SettingsValues } from '@src/types';

import { Drawing } from './Drawings';
import { DrawingsManager } from './DrawingsManager';
import { Hotkeys, Keys } from './Hotkeys';

import type { Pane } from './Pane';

import type { MouseEventParams } from 'lightweight-charts';

// todo: нужно дописывать класс)
export class DrawingsManagerCollection {
  private managersMap: Map<number, DrawingsManager> = new Map();
  private hotkeys: Hotkeys;

  private activeTool$ = new BehaviorSubject<ActiveDrawingTool>('crosshair');
  private endlessMode$ = new BehaviorSubject(false);

  private isAwaitingDrawingStart = false;
  private unregisterHotkeys: (() => void)[] = [];

  constructor({ hotkeys }: { hotkeys: Hotkeys }) {
    this.hotkeys = hotkeys;

    for (const { keys, drawingName } of DRAWING_KEYBOARD_SHORTCUTS) {
      this.unregisterHotkeys.push(
        this.hotkeys.register({
          keys,
          callback: () => {
            this.activateDrawingTool(drawingName);
          },
        }),
      );
    }

    this.unregisterHotkeys.push(
      this.hotkeys.register({
        keys: [Keys.escape],
        callback: this.activateCrosshair,
      }),
    );
  }

  public getIsEndlessMode(): boolean {
    return this.endlessMode$.value;
  }

  public getActiveToolValue(): ActiveDrawingTool {
    return this.activeTool$.value;
  }

  public setActiveTool(next: ActiveDrawingTool): void {
    this.activeTool$.next(next);

    if (next === 'crosshair') {
      this.isAwaitingDrawingStart = false;
    }
  }

  public removeDrawingManager(paneId: number): void {
    this.managersMap.delete(paneId);
  }

  public addDrawingManager(manager: DrawingsManager, paneId: number): void {
    this.managersMap.set(paneId, manager);
  }

  public activateDrawingTool = (name: DrawingsNames): void => {
    this.cancelPendingDrawings();
    this.setActiveTool(name);
    this.isAwaitingDrawingStart = true;
  };

  public handlePaneClick(pane: Pane, event: MouseEventParams): void {
    const activeTool = this.activeTool$.value;

    if (activeTool === 'crosshair') {
      const pointerShortcut = findDrawingPointerShortcut(event);

      if (pointerShortcut) {
        pane.getDrawingManager().startDrawing(pointerShortcut.drawingName, event);
      }
      return;
    }

    if (!this.isAwaitingDrawingStart) {
      return;
    }

    this.isAwaitingDrawingStart = false;

    pane.getDrawingManager().startDrawing(activeTool, event);
  }

  public setEndlessDrawingMode = (value: boolean): void => {
    if (this.endlessMode$.value === value) {
      return;
    }

    this.endlessMode$.next(value);
  };

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

  public activateCrosshair = (): void => {
    this.cancelPendingDrawings();
    this.setActiveTool('crosshair');
  };

  public getActiveTool(): Observable<ActiveDrawingTool> {
    return this.activeTool$.asObservable();
  }

  public selectedDrawing(): Observable<Drawing | null> {
    return Array.from(this.managersMap.values())[0].selectedDrawing();
  }

  public updateSelectedDrawingSettings = (settings: SettingsValues): void => {
    for (const manager of this.managersMap.values()) {
      manager.updateSelectedDrawingSettings(settings);
    }
  };

  public toggleSelectedDrawingLock = (): void => {
    Array.from(this.managersMap.values())[0].toggleSelectedDrawingLock();
  };

  public openSelectedDrawingSettings = (): void => {
    Array.from(this.managersMap.values())[0].openSelectedDrawingSettings();
  };

  public deleteSelectedDrawing = (): void => {
    Array.from(this.managersMap.values())[0].deleteSelectedDrawing();
  };

  public destroy(): void {
    for (const unregisterHotkey of this.unregisterHotkeys) {
      unregisterHotkey();
    }

    this.unregisterHotkeys = [];

    this.activeTool$.complete();
    this.endlessMode$.complete();
  }

  private cancelPendingDrawings(): void {
    for (const manager of this.managersMap.values()) {
      manager.cancelPendingDrawing();
    }
  }
}