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


import {
  IPrimitivePaneRenderer,
  IPrimitivePaneView,
  ISeriesPrimitive,
  PrimitivePaneViewZOrder,
  SeriesAttachedParameter,
  Time,
} from 'lightweight-charts';

import { getThemeStore } from '@src/theme';

export type PriceAxisLabelStyle = 'filled' | 'outlined';

export interface PriceAxisLabel {
  id: string;
  coordinate: number | null;
  value: string;
  color: string;
  style: PriceAxisLabelStyle;
  symbol?: string;
  priority?: number;
}

interface ResolvedPriceAxisLabel extends PriceAxisLabel {
  coordinate: number;
}

const LABEL_HEIGHT = 20;
const LABEL_GAP = 2;
const HORIZONTAL_PADDING = 6;
const SYMBOL_MIN_WIDTH_RATIO = 0.3;
const SYMBOL_MAX_WIDTH_RATIO = 0.48;
const FONT_SIZE = 11;
const FONT_WEIGHT = 500;

function getContrastTextColor(color: string): string {
  const normalizedColor = color.replace('#', '').slice(0, 6);

  if (normalizedColor.length !== 6) {
    return '#FFFFFF';
  }

  const red = Number.parseInt(normalizedColor.slice(0, 2), 16);
  const green = Number.parseInt(normalizedColor.slice(2, 4), 16);
  const blue = Number.parseInt(normalizedColor.slice(4, 6), 16);

  if (Number.isNaN(red) || Number.isNaN(green) || Number.isNaN(blue)) {
    return '#FFFFFF';
  }

  const luminance = (red * 0.299 + green * 0.587 + blue * 0.114) / 255;

  return luminance > 0.6 ? '#000000' : '#FFFFFF';
}

function fitText(context: CanvasRenderingContext2D, text: string, maxWidth: number): string {
  if (maxWidth <= 0) {
    return '';
  }

  if (context.measureText(text).width <= maxWidth) {
    return text;
  }

  const ellipsis = '…';
  let result = text;

  while (result.length > 0 && context.measureText(`${result}${ellipsis}`).width > maxWidth) {
    result = result.slice(0, -1);
  }

  return result.length > 0 ? `${result}${ellipsis}` : '';
}

function resolveLabelPositions(labels: PriceAxisLabel[], height: number): ResolvedPriceAxisLabel[] {
  const halfLabelHeight = LABEL_HEIGHT / 2;
  const minCoordinate = halfLabelHeight;
  const maxCoordinate = Math.max(halfLabelHeight, height - halfLabelHeight);

  const resolvedLabels = labels
    .filter(
      (label): label is PriceAxisLabel & { coordinate: number } =>
        label.coordinate !== null && Number.isFinite(label.coordinate),
    )
    .sort((left, right) => {
      if (left.coordinate === right.coordinate) {
        return (right.priority ?? 0) - (left.priority ?? 0);
      }

      return left.coordinate - right.coordinate;
    })
    .map((label) => ({
      ...label,
      coordinate: Math.min(Math.max(label.coordinate, minCoordinate), maxCoordinate),
    }));

  for (let index = 1; index < resolvedLabels.length; index += 1) {
    const previousLabel = resolvedLabels[index - 1];
    const currentLabel = resolvedLabels[index];
    const minCurrentCoordinate = previousLabel.coordinate + LABEL_HEIGHT + LABEL_GAP;

    if (currentLabel.coordinate < minCurrentCoordinate) {
      currentLabel.coordinate = minCurrentCoordinate;
    }
  }

  const lastLabel = resolvedLabels[resolvedLabels.length - 1];

  if (lastLabel && lastLabel.coordinate > maxCoordinate) {
    const offset = lastLabel.coordinate - maxCoordinate;

    resolvedLabels.forEach((label) => {
      label.coordinate -= offset;
    });
  }

  for (let index = resolvedLabels.length - 2; index >= 0; index -= 1) {
    const currentLabel = resolvedLabels[index];
    const nextLabel = resolvedLabels[index + 1];
    const maxCurrentCoordinate = nextLabel.coordinate - LABEL_HEIGHT - LABEL_GAP;

    if (currentLabel.coordinate > maxCurrentCoordinate) {
      currentLabel.coordinate = maxCurrentCoordinate;
    }
  }

  const firstLabel = resolvedLabels[0];

  if (firstLabel && firstLabel.coordinate < minCoordinate) {
    const offset = minCoordinate - firstLabel.coordinate;

    resolvedLabels.forEach((label) => {
      label.coordinate += offset;
    });
  }

  return resolvedLabels;
}

class PriceAxisLabelsRenderer implements IPrimitivePaneRenderer {
  constructor(private readonly getLabels: () => readonly PriceAxisLabel[]) {}

  public draw(target: Parameters<IPrimitivePaneRenderer['draw']>[0]): void {
    const labels = this.getLabels();

    if (labels.length === 0) {
      return;
    }

    target.useBitmapCoordinateSpace(
      ({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }) => {
        const { width, height } = bitmapSize;

        if (width <= 0 || height <= 0) {
          return;
        }

        const resolvedLabels = resolveLabelPositions(
          [...labels],
          height / verticalPixelRatio,
        );

        if (resolvedLabels.length === 0) {
          return;
        }

        const { colors } = getThemeStore();

        context.save();
        context.font = `${FONT_WEIGHT} ${Math.round(FONT_SIZE * verticalPixelRatio)}px Inter, sans-serif`;
        context.textBaseline = 'middle';

        resolvedLabels.forEach((label) => {
          const coordinate = Math.round(label.coordinate * verticalPixelRatio);
          const labelHeight = Math.round(LABEL_HEIGHT * verticalPixelRatio);
          const top = Math.round(coordinate - labelHeight / 2);
          const horizontalPadding = Math.round(HORIZONTAL_PADDING * horizontalPixelRatio);
          const backgroundColor = label.style === 'outlined' ? colors.chartBackground : label.color;
          const textColor = label.style === 'outlined' ? label.color : getContrastTextColor(label.color);

          context.fillStyle = backgroundColor;
          context.fillRect(0, top, width, labelHeight);

          if (label.style === 'outlined') {
            const borderWidth = Math.max(1, Math.round(horizontalPixelRatio));

            context.strokeStyle = label.color;
            context.lineWidth = borderWidth;
            context.strokeRect(
              borderWidth / 2,
              top + borderWidth / 2,
              Math.max(0, width - borderWidth),
              Math.max(0, labelHeight - borderWidth),
            );
          }

          context.fillStyle = textColor;

          if (!label.symbol) {
            const maxValueWidth = width - horizontalPadding * 2;
            const value = fitText(context, label.value, maxValueWidth);

            context.textAlign = 'center';
            context.fillText(value, width / 2, coordinate);

            return;
          }

          const availableWidth = width - horizontalPadding * 2;
          const measuredSymbolWidth = context.measureText(label.symbol).width + horizontalPadding * 2;
          const symbolWidth = Math.min(
            Math.max(measuredSymbolWidth, availableWidth * SYMBOL_MIN_WIDTH_RATIO),
            availableWidth * SYMBOL_MAX_WIDTH_RATIO,
          );
          const separatorX = Math.round(horizontalPadding + symbolWidth);
          const separatorWidth = Math.max(1, Math.round(horizontalPixelRatio));
          const symbolMaxWidth = symbolWidth - horizontalPadding * 2;
          const valueAreaWidth = width - separatorX;
          const valueMaxWidth = valueAreaWidth - horizontalPadding * 2;
          const symbol = fitText(context, label.symbol, symbolMaxWidth);
          const value = fitText(context, label.value, valueMaxWidth);

          context.textAlign = 'center';
          context.fillText(
            symbol,
            horizontalPadding + symbolWidth / 2,
            coordinate,
          );

          context.globalAlpha = 0.5;
          context.fillRect(
            separatorX,
            top + Math.round(3 * verticalPixelRatio),
            separatorWidth,
            labelHeight - Math.round(6 * verticalPixelRatio),
          );
          context.globalAlpha = 1;

          context.fillText(
            value,
            separatorX + valueAreaWidth / 2,
            coordinate,
          );
        });

        context.restore();
      },
    );
  }
}

class PriceAxisLabelsPaneView implements IPrimitivePaneView {
  private readonly rendererInstance: PriceAxisLabelsRenderer;

  constructor(private readonly getLabels: () => readonly PriceAxisLabel[]) {
    this.rendererInstance = new PriceAxisLabelsRenderer(getLabels);
  }

  public renderer(): IPrimitivePaneRenderer | null {
    return this.getLabels().length > 0 ? this.rendererInstance : null;
  }

  public zOrder(): PrimitivePaneViewZOrder {
    return 'top';
  }
}

export class PriceAxisLabelsPrimitive implements ISeriesPrimitive<Time> {
  private readonly paneView: PriceAxisLabelsPaneView;
  private readonly paneViews: readonly IPrimitivePaneView[];

  private labels: readonly PriceAxisLabel[] = [];
  private requestUpdate: (() => void) | null = null;

  constructor() {
    this.paneView = new PriceAxisLabelsPaneView(() => this.labels);
    this.paneViews = [this.paneView];
  }

  public attached({ requestUpdate }: SeriesAttachedParameter<Time>): void {
    this.requestUpdate = requestUpdate;
    this.requestUpdate();
  }

  public detached(): void {
    this.requestUpdate = null;
  }

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

  public updateAllViews(): void {}

  public setLabels(labels: readonly PriceAxisLabel[]): void {
    this.labels = labels;
    this.requestUpdate?.();
  }

  public clear(): void {
    if (this.labels.length === 0) {
      return;
    }

    this.labels = [];
    this.requestUpdate?.();
  }
}





import {
  IPriceLine,
  LogicalRange,
  MismatchDirection,
  PriceScaleMode,
} from 'lightweight-charts';

import { Observable, Subscription } from 'rxjs';

import { Indicator } from '@core/Indicator';
import {
  PriceAxisLabel,
  PriceAxisLabelsPrimitive,
} from '@core/PriceLabels/PriceAxisLabelsPrimitive';

import { IndicatorsIds, MAIN_PANE_INDEX } from '@src/constants';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { getThemeStore } from '@src/theme';
import { Direction } from '@src/types';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';

type PriceLabelRole = 'main' | 'compare' | 'volume';
type PriceScaleSide = Direction.Left | Direction.Right;

interface PriceLabelsManagerParams {
  mainSeries$: Observable<SeriesStrategies | null>;
  mainSymbol$: Observable<string>;
  compareEntities$: Observable<Indicator[]>;
  indicatorEntities$: Observable<Indicator[]>;
}

interface SeriesRegistrationInput {
  id: string;
  role: PriceLabelRole;
  series: SeriesStrategies;
  symbol?: string;
}

interface RegisteredSeries extends SeriesRegistrationInput {
  defaultLastValueVisible: boolean;
  defaultPriceLineVisible: boolean;
  handleDataChanged: () => void;
}

interface AxisLabelsGroup {
  paneIndex: number;
  side: PriceScaleSide;
  labels: PriceAxisLabel[];
}

interface AxisLayer {
  host: SeriesStrategies;
  primitive: PriceAxisLabelsPrimitive;
}

function getDataPrice(data: unknown): number | null {
  if (!data || typeof data !== 'object') {
    return null;
  }

  if ('close' in data && typeof data.close === 'number') {
    return data.close;
  }

  if ('value' in data && typeof data.value === 'number') {
    return data.value;
  }

  return null;
}

function getSeriesColor(series: SeriesStrategies, data: unknown): string {
  if (
    data &&
    typeof data === 'object' &&
    'color' in data &&
    typeof data.color === 'string'
  ) {
    return removeAlphaFromHex(data.color);
  }

  const options = series.options() as unknown as Record<string, unknown>;

  if (
    data &&
    typeof data === 'object' &&
    'open' in data &&
    'close' in data &&
    typeof data.open === 'number' &&
    typeof data.close === 'number'
  ) {
    const candleColor = data.close >= data.open ? options.upColor : options.downColor;

    if (typeof candleColor === 'string') {
      return removeAlphaFromHex(candleColor);
    }
  }

  if (typeof options.color === 'string') {
    return removeAlphaFromHex(options.color);
  }

  return removeAlphaFromHex(getThemeStore().colors.chartLineColor);
}

function getContrastTextColor(color: string): string {
  const normalizedColor = removeAlphaFromHex(color).replace('#', '').slice(0, 6);

  if (normalizedColor.length !== 6) {
    return '#FFFFFF';
  }

  const red = Number.parseInt(normalizedColor.slice(0, 2), 16);
  const green = Number.parseInt(normalizedColor.slice(2, 4), 16);
  const blue = Number.parseInt(normalizedColor.slice(4, 6), 16);

  if (Number.isNaN(red) || Number.isNaN(green) || Number.isNaN(blue)) {
    return '#FFFFFF';
  }

  const luminance = (red * 0.299 + green * 0.587 + blue * 0.114) / 255;

  return luminance > 0.6 ? '#000000' : '#FFFFFF';
}

function getShortSymbol(symbol: string): string {
  const symbolParts = symbol.split(':');

  return symbolParts[symbolParts.length - 1] || symbol;
}

function getScaleSide(registration: RegisteredSeries): PriceScaleSide {
  if (registration.role === 'volume') {
    return Direction.Right;
  }

  return registration.series.options().priceScaleId === Direction.Left
    ? Direction.Left
    : Direction.Right;
}

function getLayerKey(paneIndex: number, side: PriceScaleSide): string {
  return `${paneIndex}:${side}`;
}

export class PriceLabelsManager {
  private readonly registrations = new Map<string, RegisteredSeries>();
  private readonly layers = new Map<string, AxisLayer>();
  private readonly subscriptions = new Subscription();

  private visibleLogicalRange: LogicalRange | null = null;
  private currentPriceLine: IPriceLine | null = null;
  private currentPriceLineHost: SeriesStrategies | null = null;
  private mainSymbol = '';
  private isHistoryMode = false;
  private updateFrame: number | null = null;

  constructor({
    mainSeries$,
    mainSymbol$,
    compareEntities$,
    indicatorEntities$,
  }: PriceLabelsManagerParams) {
    this.subscriptions.add(
      mainSymbol$.subscribe((symbol) => {
        this.mainSymbol = getShortSymbol(symbol);
        this.scheduleUpdate();
      }),
    );

    this.subscriptions.add(
      mainSeries$.subscribe((series) => {
        this.syncMainSeries(series);
      }),
    );

    this.subscriptions.add(
      compareEntities$.subscribe((entities) => {
        this.syncCompareSeries(entities);
      }),
    );

    this.subscriptions.add(
      indicatorEntities$.subscribe((entities) => {
        this.syncVolumeSeries(entities);
      }),
    );
  }

  public setVisibleLogicalRange(logicalRange: LogicalRange | null): void {
    this.visibleLogicalRange = logicalRange;

    const nextHistoryMode = this.getHistoryMode(logicalRange);

    if (this.isHistoryMode !== nextHistoryMode) {
      this.isHistoryMode = nextHistoryMode;
      this.applyHistoryMode();
    }

    this.scheduleUpdate();
  }

  public invalidate(): void {
    this.scheduleUpdate();
  }

  public destroy(): void {
    if (this.updateFrame !== null && typeof cancelAnimationFrame === 'function') {
      cancelAnimationFrame(this.updateFrame);
      this.updateFrame = null;
    }

    this.subscriptions.unsubscribe();

    Array.from(this.registrations.keys()).forEach((registrationId) => {
      this.unregisterSeries(registrationId);
    });

    this.layers.forEach(({ host, primitive }) => {
      try {
        host.detachPrimitive(primitive);
      } catch {
        // Серия могла быть удалена раньше менеджера.
      }
    });

    this.layers.clear();
    this.removeCurrentPriceLine();
  }

  private syncMainSeries(series: SeriesStrategies | null): void {
    const registrations: SeriesRegistrationInput[] = series
      ? [
          {
            id: 'main',
            role: 'main',
            series,
          },
        ]
      : [];

    this.syncRegistrations('main', registrations);
    this.recalculateHistoryMode();
  }

  private syncCompareSeries(entities: Indicator[]): void {
    const registrations = entities.flatMap((entity) =>
      Array.from(entity.getSeriesMap().entries()).map(([seriesId, series]) => ({
        id: `compare:${entity.getId()}:${seriesId}`,
        role: 'compare' as const,
        series,
        symbol: getShortSymbol(entity.getLabel()),
      })),
    );

    this.syncRegistrations('compare', registrations);
  }

  private syncVolumeSeries(entities: Indicator[]): void {
    const registrations = entities
      .filter((entity) => entity.getType() === IndicatorsIds.Volume)
      .flatMap((entity) =>
        Array.from(entity.getSeriesMap().entries()).map(([seriesId, series]) => ({
          id: `volume:${entity.getId()}:${seriesId}`,
          role: 'volume' as const,
          series,
        })),
      );

    this.syncRegistrations('volume', registrations);
  }

  private syncRegistrations(
    role: PriceLabelRole,
    nextRegistrations: SeriesRegistrationInput[],
  ): void {
    const nextRegistrationIds = new Set(
      nextRegistrations.map(({ id }) => id),
    );

    this.registrations.forEach((registration, registrationId) => {
      if (registration.role === role && !nextRegistrationIds.has(registrationId)) {
        this.unregisterSeries(registrationId);
      }
    });

    nextRegistrations.forEach((nextRegistration) => {
      const currentRegistration = this.registrations.get(nextRegistration.id);

      if (
        currentRegistration &&
        currentRegistration.series === nextRegistration.series
      ) {
        currentRegistration.symbol = nextRegistration.symbol;

        return;
      }

      if (currentRegistration) {
        this.unregisterSeries(nextRegistration.id);
      }

      this.registerSeries(nextRegistration);
    });

    this.scheduleUpdate();
  }

  private registerSeries(registration: SeriesRegistrationInput): void {
    const handleDataChanged = () => {
      this.scheduleUpdate();
    };

    registration.series.subscribeDataChanged(handleDataChanged);

    const registeredSeries: RegisteredSeries = {
      ...registration,
      defaultLastValueVisible: registration.series.options().lastValueVisible,
      defaultPriceLineVisible: registration.series.options().priceLineVisible,
      handleDataChanged,
    };

    this.registrations.set(registration.id, registeredSeries);

    if (registration.role === 'volume') {
      registration.series.applyOptions({
        lastValueVisible: false,
        priceLineVisible: false,
      });

      return;
    }

    if (this.isHistoryMode) {
      registration.series.applyOptions({
        lastValueVisible: false,
      });
    }
  }

  private unregisterSeries(registrationId: string): void {
    const registration = this.registrations.get(registrationId);

    if (!registration) {
      return;
    }

    registration.series.unsubscribeDataChanged(registration.handleDataChanged);

    try {
      registration.series.applyOptions({
        lastValueVisible: registration.defaultLastValueVisible,
        priceLineVisible: registration.defaultPriceLineVisible,
      });
    } catch {
      // Серия могла быть удалена раньше регистрации.
    }

    if (
      registration.role === 'main' &&
      this.currentPriceLineHost === registration.series
    ) {
      this.removeCurrentPriceLine();
    }

    this.registrations.delete(registrationId);
  }

  private getHistoryMode(logicalRange: LogicalRange | null): boolean {
    if (!logicalRange) {
      return false;
    }

    const mainRegistration = this.registrations.get('main');

    if (!mainRegistration) {
      return false;
    }

    const barsInfo = mainRegistration.series.barsInLogicalRange(logicalRange);

    return (barsInfo?.barsAfter ?? 0) > 0;
  }

  private recalculateHistoryMode(): void {
    const nextHistoryMode = this.getHistoryMode(this.visibleLogicalRange);

    if (this.isHistoryMode === nextHistoryMode) {
      this.scheduleUpdate();

      return;
    }

    this.isHistoryMode = nextHistoryMode;
    this.applyHistoryMode();
    this.scheduleUpdate();
  }

  private applyHistoryMode(): void {
    this.registrations.forEach((registration) => {
      if (registration.role === 'volume') {
        return;
      }

      registration.series.applyOptions({
        lastValueVisible: this.isHistoryMode
          ? false
          : registration.defaultLastValueVisible,
      });
    });

    if (!this.isHistoryMode) {
      this.hideCurrentPriceLine();
    }
  }

  private scheduleUpdate(): void {
    if (this.updateFrame !== null) {
      return;
    }

    if (typeof requestAnimationFrame !== 'function') {
      this.update();

      return;
    }

    this.updateFrame = requestAnimationFrame(() => {
      this.updateFrame = null;
      this.update();
    });
  }

  private update(): void {
    this.updateCurrentPriceLine();

    const labelGroups = this.collectAxisLabels();

    this.updateLayers(labelGroups);
  }

  private collectAxisLabels(): Map<string, AxisLabelsGroup> {
    const labelGroups = new Map<string, AxisLabelsGroup>();

    this.registrations.forEach((registration) => {
      if (!registration.series.isVisible()) {
        return;
      }

      if (registration.role === 'volume') {
        const volumeLabel = this.createVolumeLabel(registration);

        if (volumeLabel) {
          this.addLabelToGroup(
            labelGroups,
            registration,
            volumeLabel,
            Direction.Right,
          );
        }

        return;
      }

      if (!this.isHistoryMode) {
        return;
      }

      const historicalLabel = this.createHistoricalLabel(registration);

      if (!historicalLabel) {
        return;
      }

      this.addLabelToGroup(
        labelGroups,
        registration,
        historicalLabel,
        getScaleSide(registration),
      );
    });

    return labelGroups;
  }

  private createHistoricalLabel(
    registration: RegisteredSeries,
  ): PriceAxisLabel | null {
    const logicalRange = this.visibleLogicalRange;

    if (!logicalRange) {
      return null;
    }

    const data = registration.series.dataByIndex(
      Math.floor(logicalRange.to),
      MismatchDirection.NearestLeft,
    );
    const price = getDataPrice(data);

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

    const coordinate = registration.series.priceToCoordinate(price);

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

    return {
      id: `${registration.id}:history`,
      coordinate,
      value: this.formatValue(registration.series, price, logicalRange),
      symbol:
        registration.role === 'main'
          ? this.mainSymbol
          : registration.symbol,
      color: getSeriesColor(registration.series, data),
      style: 'outlined',
      priority: registration.role === 'main' ? 100 : 50,
    };
  }

  private createVolumeLabel(
    registration: RegisteredSeries,
  ): PriceAxisLabel | null {
    const data = this.visibleLogicalRange
      ? registration.series.dataByIndex(
          Math.floor(this.visibleLogicalRange.to),
          MismatchDirection.NearestLeft,
        )
      : registration.series.data()[registration.series.data().length - 1];

    const value = getDataPrice(data);

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

    const coordinate = registration.series.priceToCoordinate(value);

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

    return {
      id: `${registration.id}:volume`,
      coordinate,
      value: registration.series.priceFormatter().format(value),
      color: getSeriesColor(registration.series, data),
      style: 'filled',
      priority: 10,
    };
  }

  private addLabelToGroup(
    labelGroups: Map<string, AxisLabelsGroup>,
    registration: RegisteredSeries,
    label: PriceAxisLabel,
    side: PriceScaleSide,
  ): void {
    const paneIndex = registration.series.getPane().paneIndex();
    const layerKey = getLayerKey(paneIndex, side);
    const currentGroup = labelGroups.get(layerKey);

    if (currentGroup) {
      currentGroup.labels.push(label);

      return;
    }

    labelGroups.set(layerKey, {
      paneIndex,
      side,
      labels: [label],
    });
  }

  private updateLayers(labelGroups: Map<string, AxisLabelsGroup>): void {
    const activeLayerKeys = new Set<string>();

    labelGroups.forEach((labelGroup, layerKey) => {
      const layerHost = this.getLayerHost(
        labelGroup.paneIndex,
        labelGroup.side,
      );

      if (!layerHost) {
        return;
      }

      activeLayerKeys.add(layerKey);

      const layer = this.getOrCreateLayer(layerKey, layerHost);

      layer.primitive.setLabels(labelGroup.labels);
    });

    this.layers.forEach((layer, layerKey) => {
      if (activeLayerKeys.has(layerKey)) {
        return;
      }

      try {
        layer.host.detachPrimitive(layer.primitive);
      } catch {
        // Серия могла быть удалена раньше слоя.
      }

      this.layers.delete(layerKey);
    });
  }

  private getLayerHost(
    paneIndex: number,
    side: PriceScaleSide,
  ): SeriesStrategies | null {
    const mainRegistration = this.registrations.get('main');

    if (
      paneIndex === MAIN_PANE_INDEX &&
      side === Direction.Right &&
      mainRegistration
    ) {
      return mainRegistration.series;
    }

    const registration = Array.from(this.registrations.values()).find(
      (currentRegistration) =>
        currentRegistration.role !== 'volume' &&
        currentRegistration.series.getPane().paneIndex() === paneIndex &&
        getScaleSide(currentRegistration) === side,
    );

    return registration?.series ?? null;
  }

  private getOrCreateLayer(
    layerKey: string,
    layerHost: SeriesStrategies,
  ): AxisLayer {
    const currentLayer = this.layers.get(layerKey);

    if (currentLayer?.host === layerHost) {
      return currentLayer;
    }

    if (currentLayer) {
      try {
        currentLayer.host.detachPrimitive(currentLayer.primitive);
      } catch {
        // Серия могла быть удалена раньше слоя.
      }
    }

    const primitive = new PriceAxisLabelsPrimitive();

    layerHost.attachPrimitive(primitive);

    const nextLayer: AxisLayer = {
      host: layerHost,
      primitive,
    };

    this.layers.set(layerKey, nextLayer);

    return nextLayer;
  }

  private updateCurrentPriceLine(): void {
    const mainRegistration = this.registrations.get('main');

    if (
      !this.isHistoryMode ||
      !mainRegistration ||
      !mainRegistration.series.isVisible()
    ) {
      this.hideCurrentPriceLine();

      return;
    }

    const seriesData = mainRegistration.series.data();
    const data = seriesData[seriesData.length - 1];
    const price = getDataPrice(data);

    if (price === null) {
      this.hideCurrentPriceLine();

      return;
    }

    const color = getSeriesColor(mainRegistration.series, data);
    const priceLine = this.getCurrentPriceLine(
      mainRegistration.series,
      color,
    );

    priceLine.applyOptions({
      price,
      color,
      lineVisible: false,
      axisLabelVisible: true,
      axisLabelColor: color,
      axisLabelTextColor: getContrastTextColor(color),
      title: this.mainSymbol,
    });
  }

  private getCurrentPriceLine(
    series: SeriesStrategies,
    color: string,
  ): IPriceLine {
    if (
      this.currentPriceLine &&
      this.currentPriceLineHost === series
    ) {
      return this.currentPriceLine;
    }

    this.removeCurrentPriceLine();

    this.currentPriceLine = series.createPriceLine({
      price: 0,
      color,
      lineVisible: false,
      axisLabelVisible: false,
      title: '',
    });

    this.currentPriceLineHost = series;

    return this.currentPriceLine;
  }

  private hideCurrentPriceLine(): void {
    this.currentPriceLine?.applyOptions({
      lineVisible: false,
      axisLabelVisible: false,
      title: '',
    });
  }

  private removeCurrentPriceLine(): void {
    if (this.currentPriceLine && this.currentPriceLineHost) {
      try {
        this.currentPriceLineHost.removePriceLine(this.currentPriceLine);
      } catch {
        // Серия могла быть удалена раньше price line.
      }
    }

    this.currentPriceLine = null;
    this.currentPriceLineHost = null;
  }

  private formatValue(
    series: SeriesStrategies,
    price: number,
    logicalRange: LogicalRange,
  ): string {
    const priceScaleMode = series.priceScale().options().mode;

    if (
      priceScaleMode !== PriceScaleMode.Percentage &&
      priceScaleMode !== PriceScaleMode.IndexedTo100
    ) {
      return series.priceFormatter().format(price);
    }

    const firstVisibleData = series.dataByIndex(
      Math.ceil(logicalRange.from),
      MismatchDirection.NearestRight,
    );
    const firstVisiblePrice = getDataPrice(firstVisibleData);

    if (firstVisiblePrice === null || firstVisiblePrice === 0) {
      return series.priceFormatter().format(price);
    }

    if (priceScaleMode === PriceScaleMode.Percentage) {
      const percentage = ((price - firstVisiblePrice) / firstVisiblePrice) * 100;

      return `${percentage.toFixed(2)}%`;
    }

    return ((price / firstVisiblePrice) * 100).toFixed(2);
  }
}