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


import { BehaviorSubject, Observable } from 'rxjs';

import { DrawingsNames } from '@src/constants';
import { ActiveDrawingTool, SettingsValues } from '@src/types';

import { Drawing } from './Drawings';
import { DrawingsManager } from './DrawingsManager';
import { Hotkeys, Keys } from './Hotkeys';
import { PaneManager } from './PaneManager';
// todo: нужно дописывать класс)
export class DrawingsManagerCollection {
  private managersMap: Map<number, DrawingsManager> = new Map();
  private paneCollection: PaneManager;
  private hotkeys: Hotkeys;

  private activeTool$ = new BehaviorSubject<ActiveDrawingTool>('crosshair');
  private endlessMode$ = new BehaviorSubject(false);
  private paneClickListenerUnsub = () => {};
  private escapeUnregisterHash: string | null = null;

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

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

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

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

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

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

  public addDrawingForce = async (name: DrawingsNames): Promise<void> => {
    this.paneClickListenerUnsub();
    this.setActiveTool(name);

    this.paneClickListenerUnsub = this.paneCollection.listenPanesToAddDrawing(name);
  };

  public setEndlessDrawingMode = (value: boolean): void => {
    if (value) {
      this.escapeUnregisterHash = this.hotkeys.register({
        keys: [Keys.escape],
        callback: () => {
          this.setEndlessDrawingMode(false);
        },
      });
    } else {
      this.hotkeys.unregister({
        keys: [Keys.escape],
        hash: this.escapeUnregisterHash,
      });

      this.escapeUnregisterHash = null;
    }

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

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

  public activateCrosshair(): void {
    this.paneClickListenerUnsub();

    Array.from(this.managersMap.values()).forEach((manager) => {
      manager.activateCrosshair();
    });
  }

  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 => {
    Array.from(this.managersMap.values()).forEach((manager) => {
      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() {
    this.hotkeys.unregister({
      keys: [Keys.escape],
      hash: this.escapeUnregisterHash,
    });

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




import { type Observable, Subscription } from 'rxjs';

import { DataSource } from '@core/DataSource';
import { DrawingsManagerSnapshot } from '@core/DrawingsManager';
import { DrawingsManagerCollection } from '@core/DrawingsManagerCollection';
import { Pane, PaneParams } from '@core/Pane';
import { PriceAxisLabels } from '@core/PriceAxisLabels';
import { DrawingsNames } from '@src/constants';
import { ActiveDrawingTool, Direction } from '@src/types';
import { ISerializable, PaneSnapshot, PriceScaleSide, PriceScaleSnapshot } from '@src/types/snapshot';

import type { Indicator } from '@core/Indicator';
import type { IChartApi, LogicalRange, MouseEventParams } from 'lightweight-charts';

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

type CollectionDependencies =
  | 'onPriceScaleStateChange'
  | 'leftPriceScaleVisible'
  | 'rightPriceScaleVisible'
  | 'addDrawingManager'
  | 'removeDrawingManager'
  | 'setActiveTool'
  | 'getActiveTool'
  | 'getIsEndlessMode';

interface PaneManagerStartParams {
  compareEntities$: Observable<Indicator[]>;
  indicatorEntities$: Observable<Indicator[]>;
}

type SharedPaneParams = Omit<PaneManagerParams, 'panesSnapshot'>;

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

export class PaneManager implements ISerializable<PaneSnapshot[]> {
  private readonly sharedPaneParams: SharedPaneParams;
  private readonly lwcChart: IChartApi;
  private readonly panesMap = new Map<number, Pane>();

  private mainPane: Pane;
  private nextPaneId: number;
  private priceAxisLabels: PriceAxisLabels | null = null;
  private leftPriceScaleVisible = false;
  private rightPriceScaleVisible = true;
  private drawingsManagerCollection: DrawingsManagerCollection;
  private readonly subscriptions = new Subscription();

  constructor({ panesSnapshot, ...sharedPaneParams }: PaneManagerParams) {
    this.sharedPaneParams = {
      ...sharedPaneParams,
    };
    this.lwcChart = sharedPaneParams.lwcChart;
    const mainPaneSnapshot = panesSnapshot.find((paneSnapshot) => paneSnapshot.isMain);
    const mainPaneId = mainPaneSnapshot?.id ?? 0;

    this.drawingsManagerCollection = new DrawingsManagerCollection({
      paneCollection: this,
      hotkeys: sharedPaneParams.hotkeys,
    });

    this.mainPane = new Pane({
      ...this.sharedPaneParams,
      ...this.getCollectionDependencies(),
      id: mainPaneId,
      isMainPane: true,
      onDelete: () => {},
      initialPriceScales: mainPaneSnapshot?.priceScales, // todo: add to sharedParams?
    });

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

    if (mainPaneSnapshot) {
      this.mainPane.setDrawingsSnapshot(mainPaneSnapshot.drawings);
    }

    const greatestPaneId = panesSnapshot.reduce(
      (greatestId, paneSnapshot) => Math.max(greatestId, paneSnapshot.id),
      mainPaneId,
    );

    this.nextPaneId = greatestPaneId + 1;

    panesSnapshot.forEach((paneSnapshot) => {
      if (paneSnapshot.isMain) {
        return;
      }

      const pane = this.addPane(undefined, paneSnapshot.id, paneSnapshot.priceScales);

      pane.setDrawingsSnapshot(paneSnapshot.drawings);
    });

    this.initClickListener();

    this.syncPaneContainers();
  }

  public start({ compareEntities$, indicatorEntities$ }: PaneManagerStartParams): void {
    this.priceAxisLabels?.destroy();

    this.priceAxisLabels = new PriceAxisLabels({
      mainSeries$: this.mainPane.getMainSerie().asObservable(),
      mainSymbol$: this.sharedPaneParams.eventManager.symbol(),
      compareEntities$,
      indicatorEntities$,
    });
  }

  public listenPanesToAddDrawing(name: DrawingsNames): () => void {
    const panes = Array.from(this.panesMap.values());
    const unsub = () => {
      panes.forEach((pane) => {
        pane.unsubscribeInitialDrawingClick();
      });
    };

    panes.forEach((pane) => {
      pane.subscribeInitialDrawingClick(unsub, name);
    });

    return unsub;
  }

  public getDrawingsCollectionManager(): DrawingsManagerCollection {
    return this.drawingsManagerCollection;
  }

  public setVisibleLogicalRange(logicalRange: LogicalRange | null): void {
    this.priceAxisLabels?.setVisibleLogicalRange(logicalRange);
  }

  public invalidate(): void {
    this.priceAxisLabels?.invalidate();
    this.refreshPriceScaleControls();
  }

  public setPriceScaleSideVisible(side: PriceScaleSide, visible: boolean): void {
    if (side === Direction.Left) {
      this.leftPriceScaleVisible = visible;
    } else {
      this.rightPriceScaleVisible = visible;
    }

    this.panesMap.forEach((pane) => {
      pane.getPriceScale(side).setVisible(visible);
    });
  }

  public getPaneByIndex(index: number): Pane | undefined {
    return Array.from(this.panesMap.values()).find((pane) => pane.paneIndex() === index);
  }

  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(): Map<number, Pane> {
    return this.panesMap;
  }

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

  public addPane(dataSource?: DataSource, paneId?: number, initialPriceScales?: PriceScaleSnapshot[]): Pane {
    const id = paneId ?? this.nextPaneId++;

    this.nextPaneId = Math.max(this.nextPaneId, id + 1);

    const pane = new Pane({
      ...this.sharedPaneParams,
      ...this.getCollectionDependencies(),
      id,
      isMainPane: false,
      dataSource: dataSource ?? null,
      basedOn: dataSource ? undefined : this.mainPane,
      onDelete: () => this.destroyPane(id),
      initialPriceScales,
    });

    this.panesMap.set(id, pane);
    this.syncPaneContainers();
    this.priceAxisLabels?.invalidate();

    return pane;
  }

  public resetPriceScalesAutoScale(): void {
    this.panesMap.forEach((pane) => {
      pane.resetPriceScalesAutoScale();
    });
  }

  public getSnapshot(): PaneSnapshot[] {
    const snapshot: PaneSnapshot[] = [];

    this.panesMap.forEach((pane) => {
      snapshot.push(pane.getSnapshot());
    });

    return snapshot;
  }

  public destroy(): void {
    this.priceAxisLabels?.destroy();
    this.priceAxisLabels = null;
    this.subscriptions.unsubscribe();

    this.panesMap.forEach((pane) => {
      pane.destroy();
    });

    this.drawingsManagerCollection.destroy();

    this.panesMap.clear();
  }

  private initClickListener(): void {
    const handler = (param: MouseEventParams) => {
      if (param.paneIndex === undefined) return;
      const clickedPane = this.getPaneByIndex(param.paneIndex);
      clickedPane?.fireClick(param);
    };

    this.lwcChart.subscribeClick(handler);
    this.subscriptions.add(() => this.lwcChart.unsubscribeClick(handler));
  }

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

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

    if (!pane) {
      return;
    }

    const paneIndex = pane.paneIndex();

    this.subscriptions.unsubscribe();

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

    if (paneIndex >= 0) {
      this.sharedPaneParams.lwcChart.removePane(paneIndex);
    }

    this.syncPaneContainers();
    this.priceAxisLabels?.invalidate();
  }

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

  private getCollectionDependencies(): Pick<PaneParams, CollectionDependencies> {
    return {
      onPriceScaleStateChange: this.handlePriceScaleStateChange,
      leftPriceScaleVisible: this.leftPriceScaleVisible,
      rightPriceScaleVisible: this.rightPriceScaleVisible,
      addDrawingManager: (manager, paneId) => this.drawingsManagerCollection.addDrawingManager(manager, paneId),
      removeDrawingManager: (paneId: number) => this.drawingsManagerCollection.removeDrawingManager(paneId),
      setActiveTool: (name: ActiveDrawingTool) => this.drawingsManagerCollection.setActiveTool(name),
      getActiveTool: () => this.drawingsManagerCollection.getActiveToolValue(),
      getIsEndlessMode: () => this.drawingsManagerCollection.getIsEndlessMode(),
    };
  }

  private handlePriceScaleStateChange = (): void => {
    this.priceAxisLabels?.invalidate();
  };
}



import { IChartApi, IPaneApi, MouseEventParams, 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 { 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;
}

// 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 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;
  private initialDrawingClickListener: (event: MouseEventParams) => void = () => {};

  constructor({
    lwcChart,
    eventManager,
    dataSource,
    DOM,
    isMainPane,
    ohlcConfig,
    id,
    basedOn,
    subscribeChartEvent,
    tooltipConfig,
    onDelete,
    chartContainer,
    modalRenderer,
    initialPriceScales = [],
    onPriceScaleStateChange,
    leftPriceScaleVisible,
    rightPriceScaleVisible,
    hotkeys,
    addDrawingManager,
    removeDrawingManager,
    setActiveTool,
    getActiveTool,
    getIsEndlessMode,
  }: 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();
      this.mainSeries.subscribe(() => {
        this.rebindIndicators();
      });
    } else {
      console.error('[Pane]: There is no any mainSerie for new pane');
    }

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

    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 fireClick = (event: MouseEventParams) => {
    this.initialDrawingClickListener(event);
  };

  public unsubscribeInitialDrawingClick = () => {
    this.initialDrawingClickListener = () => {};
  };

  public subscribeInitialDrawingClick = (cb: () => void, name: DrawingsNames) => {
    this.initialDrawingClickListener = (event: MouseEventParams) => {
      this.drawingsManager.addDrawingForce(name, event);
      cb();
    };
  };

  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 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 | 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.legendContainer.remove();
    this.paneOverlayContainer.remove();
    this.indicatorsMap.complete();
    this.mainSerieSub?.unsubscribe();
    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;

    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.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.mainSerieSub = 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.rebindIndicators();

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



import { AxisLine } from '@src/core/Drawings/axisLine';
import { Diapson } from '@src/core/Drawings/diapson';
import { FibonacciRetracement } from '@src/core/Drawings/fibonacciRetracement';
import { LineDrawing } from '@src/core/Drawings/line';
import { ParallelChannel } from '@src/core/Drawings/parallelChannel';
import { Ray } from '@src/core/Drawings/ray';
import { Rectangle } from '@src/core/Drawings/rectangle';
import { RegressionTrend } from '@src/core/Drawings/regressionTrend';
import { Ruler } from '@src/core/Drawings/ruler';
import { SliderPosition } from '@src/core/Drawings/sliderPosition';
import { Text } from '@src/core/Drawings/text';
import { Traectory } from '@src/core/Drawings/traectory';
import { VolumeProfile } from '@src/core/Drawings/volumeProfile';
import { t } from '@src/translations';
import { DrawingConfig, DrawingParams, LineMarker } from '@src/types';

export enum DrawingsNames {
  'trendLine' = 'trendLine',
  'arrow' = 'arrow',
  'parallelChannel' = 'parallelChannel',
  'regressionTrend' = 'regressionTrend',
  'ray' = 'ray',
  'horizontalLine' = 'horizontalLine',
  'horizontalRay' = 'horizontalRay',
  'verticalLine' = 'verticalLine',
  'ruler' = 'ruler',

  'fibonacciRetracement' = 'fibonacciRetracement',

  'sliderLong' = 'sliderLong',
  'sliderShort' = 'sliderShort',
  'diapsonDates' = 'diapsonDates',
  'diapsonPrices' = 'diapsonPrices',
  'fixedRangeProfile' = 'fixedRangeProfile',
  'visibleRangeProfile' = 'visibleRangeProfile',

  'rectangle' = 'rectangle',
  'traectory' = 'traectory',

  'text' = 'text',
}

export const drawingLabelById = (): Record<DrawingsNames, string> => ({
  [DrawingsNames.trendLine]: t('Trend line'),
  [DrawingsNames.arrow]: t('Arrow'),
  [DrawingsNames.parallelChannel]: t('Parallel channel'),
  [DrawingsNames.regressionTrend]: t('Regression trend'),
  [DrawingsNames.ray]: t('Ray'),
  [DrawingsNames.horizontalLine]: t('Horizontal line'),
  [DrawingsNames.horizontalRay]: t('Horizontal ray'),
  [DrawingsNames.verticalLine]: t('Vertical line'),
  [DrawingsNames.fibonacciRetracement]: t('Fibonacci retracement'),
  [DrawingsNames.ruler]: t('Ruler'),
  [DrawingsNames.sliderLong]: t('Long position'),
  [DrawingsNames.sliderShort]: t('Short position'),
  [DrawingsNames.diapsonDates]: t('Dates range'),
  [DrawingsNames.diapsonPrices]: t('Prices range'),
  [DrawingsNames.fixedRangeProfile]: t('Fixed range volume profile'),
  [DrawingsNames.visibleRangeProfile]: t('Anchored volume profile'),
  [DrawingsNames.rectangle]: t('Rectangle'),
  [DrawingsNames.traectory]: t('Traectory'),
  [DrawingsNames.text]: t('Text'),
});

export const drawingsMap: Record<DrawingsNames, DrawingConfig> = {
  [DrawingsNames.trendLine]: {
    construct: (params: DrawingParams) => {
      const { eventManager, ...rest } = params;

      return new LineDrawing({
        ...rest,
        formatObservable: eventManager.getChartOptionsModel(),
      });
    },
  },
  [DrawingsNames.arrow]: {
    construct: (params: DrawingParams) => {
      const { eventManager, ...rest } = params;

      return new LineDrawing({
        ...rest,
        formatObservable: eventManager.getChartOptionsModel(),
        defaultMarkers: {
          endMarker: LineMarker.arrow,
        },
      });
    },
  },
  [DrawingsNames.parallelChannel]: {
    construct: (params: DrawingParams) => {
      const { eventManager, ...rest } = params;

      return new ParallelChannel({
        ...rest,
        formatObservable: eventManager.getChartOptionsModel(),
      });
    },
  },
  [DrawingsNames.regressionTrend]: {
    construct: (params: DrawingParams) => {
      const { eventManager, ...rest } = params;

      return new RegressionTrend({
        ...rest,
        formatObservable: eventManager.getChartOptionsModel(),
      });
    },
  },
  [DrawingsNames.ray]: {
    construct: (params: DrawingParams) => {
      const { eventManager, ...rest } = params;

      return new Ray({
        ...rest,
        formatObservable: eventManager.getChartOptionsModel(),
      });
    },
  },
  [DrawingsNames.horizontalLine]: {
    construct: (params: DrawingParams) => {
      const { eventManager, ...rest } = params;

      return new AxisLine({
        ...rest,
        direction: 'horizontal',
        formatObservable: eventManager.getChartOptionsModel(),
      });
    },
  },
  [DrawingsNames.horizontalRay]: {
    construct: (params: DrawingParams) => {
      const { eventManager, ...rest } = params;

      return new LineDrawing({
        ...rest,
        formatObservable: eventManager.getChartOptionsModel(),
        defaultMarkers: {
          endMarker: LineMarker.arrow,
        },
      });
    },
  },
  [DrawingsNames.verticalLine]: {
    construct: (params: DrawingParams) => {
      const { eventManager, ...rest } = params;

      return new AxisLine({
        ...rest,
        direction: 'vertical',
        formatObservable: eventManager.getChartOptionsModel(),
      });
    },
  },
  [DrawingsNames.sliderLong]: {
    construct: (params: DrawingParams) => {
      const { eventManager, ...rest } = params;

      return new SliderPosition({
        ...rest,
        side: 'long',
        formatObservable: eventManager.getChartOptionsModel(),
      });
    },
  },
  [DrawingsNames.fibonacciRetracement]: {
    construct: (params: DrawingParams) => {
      const { eventManager, ...rest } = params;

      return new FibonacciRetracement({
        ...rest,
        formatObservable: eventManager.getChartOptionsModel(),
      });
    },
  },
  [DrawingsNames.sliderShort]: {
    construct: (params: DrawingParams) => {
      const { eventManager, ...rest } = params;

      return new SliderPosition({
        ...rest,
        side: 'short',
        formatObservable: eventManager.getChartOptionsModel(),
      });
    },
  },
  [DrawingsNames.diapsonDates]: {
    construct: (params: DrawingParams) => {
      const { eventManager, ...rest } = params;

      return new Diapson({
        ...rest,
        rangeMode: 'date',
        formatObservable: eventManager.getChartOptionsModel(),
      });
    },
  },
  [DrawingsNames.diapsonPrices]: {
    construct: (params: DrawingParams) => {
      const { eventManager, ...rest } = params;

      return new Diapson({
        ...rest,
        rangeMode: 'price',
        formatObservable: eventManager.getChartOptionsModel(),
      });
    },
  },
  [DrawingsNames.fixedRangeProfile]: {
    construct: (params: DrawingParams) => {
      const { eventManager, ...rest } = params;

      return new VolumeProfile({
        ...rest,
        profileKind: 'fixedRange',
        formatObservable: eventManager.getChartOptionsModel(),
      });
    },
  },
  [DrawingsNames.visibleRangeProfile]: {
    singleInstance: true,
    construct: (params: DrawingParams) => {
      const { eventManager, ...rest } = params;

      return new VolumeProfile({
        ...rest,
        profileKind: 'visibleRange',
        formatObservable: eventManager.getChartOptionsModel(),
      });
    },
  },
  [DrawingsNames.rectangle]: {
    construct: (params: DrawingParams) => {
      const { eventManager, ...rest } = params;

      return new Rectangle({
        ...rest,
        formatObservable: eventManager.getChartOptionsModel(),
      });
    },
  },
  [DrawingsNames.ruler]: {
    singleInstance: true,
    construct: (params: DrawingParams) => {
      const { eventManager, ...rest } = params;

      return new Ruler({
        ...rest,
        formatObservable: eventManager.getChartOptionsModel(),
        resetTriggers: [eventManager.getTimeframeObs(), eventManager.getInterval()],
      });
    },
  },
  [DrawingsNames.traectory]: {
    construct: (params: DrawingParams) => {
      const { eventManager, ...rest } = params;

      return new Traectory({
        ...rest,
        formatObservable: eventManager.getChartOptionsModel(),
      });
    },
  },
  [DrawingsNames.text]: {
    construct: (params: DrawingParams) => {
      const { eventManager, ...rest } = params;

      return new Text({
        ...rest,
        formatObservable: eventManager.getChartOptionsModel(),
      });
    },
  },
};


import { PrimitiveHoveredItem } from 'lightweight-charts';
import { Observable, skip } from 'rxjs';

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
  getAnchorFromPoint,
  getPriceFromYCoordinate,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';

import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { ChartOptionsModel, Direction } from '@src/types';
import { Defaults } from '@src/types/defaults';
import { SettingsTab, SettingsValues } from '@src/types/settings';
import { formatPrice, formatVolume } from '@src/utils';
import { formatDate } from '@src/utils/formatter';

import { RulerPaneView } from './paneView';

import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type {
  AutoscaleInfo,
  Coordinate,
  IPrimitivePaneView,
  ISeriesApi,
  ISeriesPrimitiveAxisView,
  Logical,
  MouseEventHandler,
  MouseEventParams,
  SeriesOptionsMap,
  Time,
  UTCTimestamp,
} from 'lightweight-charts';

type SeriesApi = ISeriesApi<keyof SeriesOptionsMap, Time>;
type RulerMode = 'idle' | 'placingEnd' | 'ready';

interface RulerState {
  hidden: boolean;
  mode: RulerMode;
  startAnchor: Anchor | null;
  endAnchor: Anchor | null;
}

interface RulerParams extends BaseDrawingParams {
  resetTriggers?: Observable<unknown>[];
}

export interface RulerRenderData {
  hidden: boolean;
  startPoint: Point | null;
  endPoint: Point | null;
  lineColor: string;
  fillColor: string;
  textColor: string;
  infoLines: string[];
  horizontalArrowSide: Direction.Left | Direction.Right | null;
  verticalArrowSide: Direction.Top | Direction.Bottom | null;
}

export class Ruler extends SeriesDrawingBase implements ISeriesDrawing {
  private removeSelf?: () => void;

  protected settings: SettingsValues = {};
  protected mode: RulerMode = 'idle';

  private displayFormat: ChartOptionsModel = {
    dateFormat: Defaults.dateFormat,
    timeFormat: Defaults.timeFormat,
    showTime: Defaults.showTime,
  };

  private startAnchor: Anchor | null = null;
  private endAnchor: Anchor | null = null;

  private readonly clickHandler: MouseEventHandler<Time>;
  private readonly moveHandler: MouseEventHandler<Time>;

  private readonly paneView: RulerPaneView;
  private readonly timeAxisPaneView: CustomTimeAxisPaneView;
  private readonly priceAxisPaneView: CustomPriceAxisPaneView;
  private readonly startTimeAxisView: CustomTimeAxisView;
  private readonly endTimeAxisView: CustomTimeAxisView;
  private readonly startPriceAxisView: CustomPriceAxisView;
  private readonly endPriceAxisView: CustomPriceAxisView;

  constructor({
    chart,
    series,
    resetTriggers = [],
    formatObservable,
    removeSelf,
    container,
    interaction,
    initialEvent,
  }: RulerParams) {
    super({
      chart,
      series,
      container,
      interaction,
    });

    this.removeSelf = removeSelf;

    this.clickHandler = (params) => this.handleClick(params);
    this.moveHandler = (params) => this.handleMove(params);

    this.paneView = new RulerPaneView(this);

    this.timeAxisPaneView = new CustomTimeAxisPaneView({
      getAxisSegments: () => this.getTimeAxisSegments(),
    });

    this.priceAxisPaneView = new CustomPriceAxisPaneView({
      getAxisSegments: () => this.getPriceAxisSegments(),
    });

    this.startTimeAxisView = new CustomTimeAxisView({
      getAxisLabel: (labelKind) => this.getTimeAxisLabel(labelKind),
      labelKind: 'start',
    });

    this.endTimeAxisView = new CustomTimeAxisView({
      getAxisLabel: (labelKind) => this.getTimeAxisLabel(labelKind),
      labelKind: 'end',
    });

    this.startPriceAxisView = new CustomPriceAxisView({
      getAxisLabel: (labelKind) => this.getPriceAxisLabel(labelKind),
      labelKind: 'start',
    });

    this.endPriceAxisView = new CustomPriceAxisView({
      getAxisLabel: (labelKind) => this.getPriceAxisLabel(labelKind),
      labelKind: 'end',
    });

    if (formatObservable) {
      this.subscriptions.add(
        formatObservable.subscribe((format) => {
          this.displayFormat = format;
          this.render();
        }),
      );
    }

    resetTriggers.forEach((trigger) => {
      this.subscriptions.add(
        trigger.pipe(skip(1)).subscribe(() => {
          this.removeSelf?.();
        }),
      );
    });

    this.series.attachPrimitive(this);

    if (initialEvent && initialEvent.sourceEvent) {
      const point = this.getEventPoint(initialEvent.sourceEvent);
      this.startDrawing(point);
    }
  }

  public isCreationPending(): boolean {
    return this.mode === 'idle' || this.mode === 'placingEnd';
  }

  public getSettingsTabs(): SettingsTab[] {
    return [];
  }

  public getState(): RulerState {
    return {
      hidden: this.hidden,
      mode: this.mode,
      startAnchor: this.startAnchor,
      endAnchor: this.endAnchor,
    };
  }

  public setState(state: unknown): void {
    const nextState = state as Partial<RulerState>;

    this.hidden = nextState.hidden ?? this.hidden;
    this.mode = nextState.mode ?? this.mode;
    this.startAnchor = nextState.startAnchor ?? this.startAnchor;
    this.endAnchor = nextState.endAnchor ?? this.endAnchor;

    this.render();
  }

  public updateAllViews(): void {
    updateViews([
      this.paneView,
      this.timeAxisPaneView,
      this.priceAxisPaneView,
      this.startTimeAxisView,
      this.endTimeAxisView,
      this.startPriceAxisView,
      this.endPriceAxisView,
    ]);
  }

  public paneViews(): readonly IPrimitivePaneView[] {
    return [this.paneView];
  }

  public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
    return [this.timeAxisPaneView];
  }

  public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
    return [this.priceAxisPaneView];
  }

  public timeAxisViews(): readonly ISeriesPrimitiveAxisView[] {
    return [this.startTimeAxisView, this.endTimeAxisView];
  }

  public priceAxisViews(): readonly ISeriesPrimitiveAxisView[] {
    return [this.startPriceAxisView, this.endPriceAxisView];
  }

  public autoscaleInfo(startTimePoint: Logical, endTimePoint: Logical): AutoscaleInfo | null {
    if (this.hidden || this.mode === 'placingEnd') {
      return null;
    }

    if (!this.startAnchor || !this.endAnchor) {
      return null;
    }

    const startCoordinate = getXCoordinateFromTime(this.chart, this.startAnchor.time, this.series);
    const endCoordinate = getXCoordinateFromTime(this.chart, this.endAnchor.time, this.series);

    if (startCoordinate === null || endCoordinate === null) {
      return null;
    }

    const startLogical = this.chart.timeScale().coordinateToLogical(startCoordinate as Coordinate);
    const endLogical = this.chart.timeScale().coordinateToLogical(endCoordinate as Coordinate);

    if (startLogical === null || endLogical === null) {
      return null;
    }

    const leftLogical = Math.min(Number(startLogical), Number(endLogical));
    const rightLogical = Math.max(Number(startLogical), Number(endLogical));

    if (endTimePoint < leftLogical || startTimePoint > rightLogical) {
      return null;
    }

    return {
      priceRange: {
        minValue: Math.min(Number(this.startAnchor.price), Number(this.endAnchor.price)),
        maxValue: Math.max(Number(this.startAnchor.price), Number(this.endAnchor.price)),
      },
    };
  }

  public getRenderData(): RulerRenderData {
    const startPoint = this.startAnchor ? this.getPoint(this.startAnchor) : null;
    const endPoint = this.endAnchor ? this.getPoint(this.endAnchor) : null;

    const startPrice = this.startAnchor ? Number(this.startAnchor.price) : 0;
    const endPrice = this.endAnchor ? Number(this.endAnchor.price) : 0;
    const priceDiff = endPrice - startPrice;
    const percentDiff = startPrice !== 0 ? (priceDiff / startPrice) * 100 : null;

    const startIndex = this.startAnchor ? this.findIndexByTime(this.startAnchor.time) : -1;
    const endIndex = this.endAnchor ? this.findIndexByTime(this.endAnchor.time) : -1;

    const barsCount = startIndex >= 0 && endIndex >= 0 ? Math.abs(endIndex - startIndex) : 0;
    const volume = this.getVolumeInRange();
    const isLong = priceDiff >= 0;

    const horizontalArrowSide =
      startPoint && endPoint && startPoint.x !== endPoint.x
        ? endPoint.x > startPoint.x
          ? Direction.Right
          : Direction.Left
        : null;

    const verticalArrowSide =
      startPoint && endPoint && startPoint.y !== endPoint.y
        ? endPoint.y > startPoint.y
          ? Direction.Bottom
          : Direction.Top
        : null;

    const { colors } = getThemeStore();

    return {
      hidden: this.hidden,
      startPoint,
      endPoint,
      lineColor: isLong ? colors.chartLineColor : colors.chartLineColorAlternative,
      fillColor: isLong ? colors.rulerPositiveFill : colors.rulerNegativeFill,
      textColor: colors.chartPriceLineText,
      infoLines: [
        `${formatPrice(Math.abs(priceDiff))} (${percentDiff === null ? '-' : `${formatPrice(Math.abs(percentDiff))}%`})`,
        `${barsCount} ${t('bars')},`,
        `${t('Vol')} ${formatVolume(volume)}`,
      ],
      horizontalArrowSide,
      verticalArrowSide,
    };
  }

  public getTimeCoordinate(kind: 'start' | 'end'): Coordinate | null {
    const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;

    if (!anchor) {
      return null;
    }

    return getXCoordinateFromTime(this.chart, anchor.time, this.series);
  }

  public getPriceCoordinate(kind: 'start' | 'end'): Coordinate | null {
    const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;

    if (!anchor) {
      return null;
    }

    return getYCoordinateFromPrice(this.series, anchor.price);
  }

  public getTimeBounds(): { left: number; right: number } | null {
    const startX = this.getTimeCoordinate('start');
    const endX = this.getTimeCoordinate('end');

    if (startX === null || endX === null) {
      return null;
    }

    return {
      left: Math.min(startX, endX),
      right: Math.max(startX, endX),
    };
  }

  public getPriceBounds(): { top: number; bottom: number } | null {
    const startY = this.getPriceCoordinate('start');
    const endY = this.getPriceCoordinate('end');

    if (startY === null || endY === null) {
      return null;
    }

    return {
      top: Math.min(startY, endY),
      bottom: Math.max(startY, endY),
    };
  }

  public getTimeText(kind: 'start' | 'end'): string {
    const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;

    if (!anchor || typeof anchor.time !== 'number') {
      return '';
    }

    return formatDate(
      anchor.time as UTCTimestamp,
      this.displayFormat.dateFormat,
      this.displayFormat.timeFormat,
      this.displayFormat.showTime,
    );
  }

  public getPriceText(kind: 'start' | 'end'): string {
    const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;

    if (!anchor) {
      return '';
    }

    return formatPrice(Number(anchor.price)) ?? '';
  }

  public shouldShowInObjectTree(): boolean {
    return false;
  }

  protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
    return null;
  }

  protected getTimeAxisSegments(): AxisSegment[] {
    const bounds = this.getTimeBounds();

    if (!bounds) {
      return [];
    }

    const { colors } = getThemeStore();

    return [
      {
        from: bounds.left,
        to: bounds.right,
        color: colors.axisMarkerAreaFill,
      },
    ];
  }

  protected getPriceAxisSegments(): AxisSegment[] {
    const bounds = this.getPriceBounds();

    if (!bounds) {
      return [];
    }

    const { colors } = getThemeStore();

    return [
      {
        from: bounds.top,
        to: bounds.bottom,
        color: colors.axisMarkerAreaFill,
      },
    ];
  }

  protected getTimeAxisLabel(kind: string): AxisLabel | null {
    if (kind !== 'start' && kind !== 'end') {
      return null;
    }

    const coordinate = this.getTimeCoordinate(kind);
    const text = this.getTimeText(kind);

    if (coordinate === null || !text) {
      return null;
    }

    const { colors } = getThemeStore();

    return {
      coordinate,
      text,
      textColor: colors.chartPriceLineText,
      backgroundColor: colors.axisMarkerLabelFill,
    };
  }

  protected getPriceAxisLabel(kind: string): AxisLabel | null {
    if (kind !== 'start' && kind !== 'end') {
      return null;
    }

    const coordinate = this.getPriceCoordinate(kind);
    const text = this.getPriceText(kind);

    if (coordinate === null || !text) {
      return null;
    }

    const { colors } = getThemeStore();

    return {
      coordinate,
      text,
      textColor: colors.chartPriceLineText,
      backgroundColor: colors.axisMarkerLabelFill,
    };
  }

  private findIndexByTime(time: Time): number {
    const data = this.series.data() ?? [];

    return data.findIndex((item) => {
      if (typeof item.time === 'number' && typeof time === 'number') {
        return item.time === time;
      }

      return false;
    });
  }

  protected bindEvents(): void {
    // todo: попробовать привести к виду базового класса
    if (this.isBound) {
      return;
    }

    this.isBound = true;
    this.chart.subscribeClick(this.clickHandler);
    this.chart.subscribeCrosshairMove(this.moveHandler);
  }

  protected getGeometry(): void {
    console.log('stub');
  }

  protected unbindEvents(): void {
    // todo: попробовать привести к виду базового класса
    if (!this.isBound) {
      return;
    }

    this.isBound = false;
    this.chart.unsubscribeClick(this.clickHandler);
    this.chart.unsubscribeCrosshairMove(this.moveHandler);
  }

  private handleClick(params: MouseEventParams<Time>): void {
    if (this.hidden || !params.point || !params.sourceEvent) {
      return;
    }

    if (this.mode === 'ready') {
      this.removeSelf?.();
      return;
    }

    if (!params.sourceEvent) {
      return;
    }

    const anchor = this.createAnchor(params);

    if (!anchor) {
      return;
    }

    const point = this.getEventPoint(params.sourceEvent);

    if (this.mode === 'idle') {
      this.startDrawing(point);

      return;
    }

    if (this.mode === 'placingEnd') {
      this.endAnchor = anchor;
      this.mode = 'ready';
      this.resolveReady?.();

      this.showCrosshair();
      this.render();
    }
  }

  private startDrawing(point: Point): void {
    const anchor = getAnchorFromPoint(this.chart, this.series, point);
    this.startAnchor = anchor;
    this.endAnchor = anchor;
    this.mode = 'placingEnd';

    this.hideCrosshair();
    this.render();
  }

  private handleMove(params: MouseEventParams<Time>): void {
    if (this.hidden || !params.point) {
      return;
    }

    if (this.mode !== 'placingEnd') {
      return;
    }

    const anchor = this.createAnchor(params);

    if (!anchor) {
      return;
    }

    this.endAnchor = anchor;
    this.render();
  }

  private createAnchor({ time, point }: MouseEventParams<Time>): Anchor | null {
    if (!point || time === undefined) {
      return null;
    }

    const price = getPriceFromYCoordinate(this.series, point.y);

    if (price === null) {
      return null;
    }

    return {
      price,
      time,
    };
  }

  private getPoint(anchor: Anchor): Point | null {
    const x = getXCoordinateFromTime(this.chart, anchor.time, this.series);
    const y = getYCoordinateFromPrice(this.series, anchor.price);

    if (x === null || y === null) {
      return null;
    }

    return {
      x,
      y,
    };
  }

  private getVolumeInRange(): number {
    if (!this.startAnchor || !this.endAnchor) {
      return 0;
    }

    const data = this.series.data() ?? [];

    if (!data.length) {
      return 0;
    }

    const startIndex = this.findIndexByTime(this.startAnchor.time);
    const endIndex = this.findIndexByTime(this.endAnchor.time);

    if (startIndex < 0 || endIndex < 0) {
      return 0;
    }

    const from = Math.min(startIndex, endIndex);
    const to = Math.max(startIndex, endIndex);

    let volume = 0;

    for (let index = from; index <= to; index += 1) {
      const item = data[index] as unknown as Record<string, unknown> | undefined;

      if (!item) {
        continue;
      }

      if (typeof item.volume === 'number') {
        volume += item.volume;
        continue;
      }

      const customValues = item.customValues as Record<string, unknown> | undefined;

      if (customValues && typeof customValues.volume === 'number') {
        volume += customValues.volume;
      }
    }

    return volume;
  }
}


import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';

import { Direction } from '@src/types';

import type { Ruler } from './ruler';

import type { Bounds, Point } from '@core/Drawings/types';

const UI = {
  lineWidth: 2,
  arrowSize: 10,
  infoFont: '12px Inter, sans-serif',
  infoPadding: 4,
  infoOffset: 8,
  infoRadius: 2,
  infoLineHeight: 14,
  infoGap: 2,
};

export class RulerPaneRenderer implements IPrimitivePaneRenderer {
  private readonly ruler: Ruler;

  constructor(ruler: Ruler) {
    this.ruler = ruler;
  }

  public draw(target: CanvasRenderingTarget2D): void {
    const data = this.ruler.getRenderData();

    if (data.hidden || !data.startPoint || !data.endPoint) {
      return;
    }

    const bounds = getBounds(data.startPoint, data.endPoint);

    target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
      const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);

      const left = bounds.left * horizontalPixelRatio;
      const right = bounds.right * horizontalPixelRatio;
      const top = bounds.top * verticalPixelRatio;
      const bottom = bounds.bottom * verticalPixelRatio;

      const centerX = (left + right) / 2;
      const centerY = (top + bottom) / 2;

      context.save();

      context.fillStyle = data.fillColor;
      context.fillRect(left, top, right - left, bottom - top);

      context.lineWidth = UI.lineWidth * pixelRatio;
      context.strokeStyle = data.lineColor;

      drawHorizontalArrow(context, left, right, centerY, UI.arrowSize * pixelRatio, data.horizontalArrowSide);
      drawVerticalArrow(context, centerX, top, bottom, UI.arrowSize * pixelRatio, data.verticalArrowSide);

      drawInfoBox(
        context,
        centerX,
        top - UI.infoOffset * pixelRatio,
        data.infoLines,
        data.fillColor,
        data.textColor,
        pixelRatio,
        verticalPixelRatio,
      );

      context.restore();
    });
  }
}

function getBounds(startPoint: Point, endPoint: Point): Bounds {
  return {
    left: Math.min(startPoint.x, endPoint.x),
    right: Math.max(startPoint.x, endPoint.x),
    top: Math.min(startPoint.y, endPoint.y),
    bottom: Math.max(startPoint.y, endPoint.y),
  };
}

function drawHorizontalArrow(
  context: CanvasRenderingContext2D,
  left: number,
  right: number,
  y: number,
  size: number,
  side: Direction.Left | Direction.Right | null,
): void {
  context.beginPath();
  context.moveTo(left, y);
  context.lineTo(right, y);
  context.stroke();

  if (!side) {
    return;
  }

  context.beginPath();

  if (side === Direction.Left) {
    context.moveTo(left, y);
    context.lineTo(left + size, y - size);
    context.moveTo(left, y);
    context.lineTo(left + size, y + size);
  }

  if (side === Direction.Right) {
    context.moveTo(right, y);
    context.lineTo(right - size, y - size);
    context.moveTo(right, y);
    context.lineTo(right - size, y + size);
  }

  context.stroke();
}

function drawVerticalArrow(
  context: CanvasRenderingContext2D,
  x: number,
  top: number,
  bottom: number,
  size: number,
  side: Direction.Top | Direction.Bottom | null,
): void {
  context.beginPath();
  context.moveTo(x, top);
  context.lineTo(x, bottom);
  context.stroke();

  if (!side) {
    return;
  }

  context.beginPath();

  if (side === Direction.Top) {
    context.moveTo(x, top);
    context.lineTo(x - size, top + size);
    context.moveTo(x, top);
    context.lineTo(x + size, top + size);
  }

  if (side === Direction.Bottom) {
    context.moveTo(x, bottom);
    context.lineTo(x - size, bottom - size);
    context.moveTo(x, bottom);
    context.lineTo(x + size, bottom - size);
  }

  context.stroke();
}

function drawInfoBox(
  context: CanvasRenderingContext2D,
  centerX: number,
  topY: number,
  lines: readonly string[],
  fillColor: string,
  textColor: string,
  pixelRatio: number,
  verticalPixelRatio: number,
): void {
  context.save();
  context.font = UI.infoFont;
  context.textAlign = 'center';

  const padding = UI.infoPadding * pixelRatio;
  const lineHeight = UI.infoLineHeight * verticalPixelRatio;
  const gap = UI.infoGap * verticalPixelRatio;

  let maxWidth = 0;

  for (const line of lines) {
    maxWidth = Math.max(maxWidth, context.measureText(line).width);
  }

  const boxWidth = maxWidth + padding * 2;
  const boxHeight = lines.length * lineHeight + (lines.length - 1) * gap + padding * 2;

  const boxX = centerX - boxWidth / 2;
  const boxY = topY - boxHeight;

  context.fillStyle = fillColor;
  context.beginPath();
  drawRoundedRect(context, boxX, boxY, boxWidth, boxHeight, UI.infoRadius * pixelRatio);
  context.fill();

  context.fillStyle = textColor;

  let textY = boxY + padding + lineHeight * 0.8;

  for (const line of lines) {
    context.fillText(line, centerX, textY);
    textY += lineHeight + gap;
  }

  context.restore();
}

function drawRoundedRect(
  context: CanvasRenderingContext2D,
  x: number,
  y: number,
  width: number,
  height: number,
  radius: number,
): void {
  const safeRadius = Math.min(radius, width / 2, height / 2);

  context.moveTo(x + safeRadius, y);
  context.arcTo(x + width, y, x + width, y + height, safeRadius);
  context.arcTo(x + width, y + height, x, y + height, safeRadius);
  context.arcTo(x, y + height, x, y, safeRadius);
  context.arcTo(x, y, x + width, y, safeRadius);
  context.closePath();
}