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


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

import { MAIN_PANE_INDEX } from '@src/constants';
import { type Candle } from '@src/types';
import { formatPrice, getPricePrecisionStep, isBarData, isLineData, normalizeSeriesData } from '@src/utils';

import type { DataSource } from '@core/DataSource';
import type { Indicator } from '@core/Indicator';
import type { ChartTypeToCandleData, IndicatorDataFormatter } from '@core/Indicators';
import type { Ohlc } from '@core/Legend';
import type { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import type {
  BarData,
  BarPrice,
  BarsInfo,
  Coordinate,
  CreatePriceLineOptions,
  CustomData,
  DataChangedHandler,
  DeepPartial,
  HistogramData,
  IChartApi,
  IPaneApi,
  IPriceFormatter,
  IPriceLine,
  IPriceScaleApi,
  IRange,
  ISeriesApi,
  ISeriesPrimitive,
  LineData,
  MismatchDirection,
  MouseEventParams,
  PriceScaleOptions,
  SeriesDataItemTypeMap,
  SeriesDefinition,
  SeriesOptionsMap,
  SeriesPartialOptionsMap,
  SeriesType,
  Time,
} from 'lightweight-charts';

export interface SerieData {
  time: Time;
  customValues: Candle;
}

export interface CreateSeriesParams<TSeries extends SeriesType> {
  chart: IChartApi;
  seriesOptions?: SeriesPartialOptionsMap[TSeries];
  paneIndex?: number;
  priceScaleOptions?: DeepPartial<PriceScaleOptions>;
}

export interface IBaseSeries<TSeries extends SeriesType> extends ISeriesApi<TSeries> {
  getLwcSeries: () => ISeriesApi<TSeries>;
  getLegendData: (param?: MouseEventParams) => Partial<
    Record<
      keyof Ohlc,
      {
        value: number | string | Time;
        color: string;
        name: string;
      }
    >
  >;
}

export interface BaseSeriesParams<TSeries extends SeriesType = SeriesType> {
  lwcChart: IChartApi;
  dataSource: DataSource;
  mainSymbolId$: Observable<string>;
  mainSymbol$: Observable<string>;
  mainSerie$: BehaviorSubject<SeriesStrategies | null>;
  actLikeMainSerie?: boolean;
  customFormatter?: (params: IndicatorDataFormatter<TSeries>) => SeriesDataItemTypeMap<Time>[TSeries][];
  seriesOptions?: SeriesPartialOptionsMap[TSeries];
  priceScaleOptions?: DeepPartial<PriceScaleOptions>;
  showSymbolLabel?: boolean;
  paneIndex?: number;
  indicatorReference?: Indicator;
}

export abstract class BaseSeries<TSeries extends SeriesType> implements IBaseSeries<TSeries> {
  protected lwcSeries: ISeriesApi<TSeries>;
  protected customFormatter?: (params: IndicatorDataFormatter<TSeries>) => SeriesDataItemTypeMap<Time>[TSeries][];

  protected lwcChart: IChartApi;
  protected mainSymbolId$: Observable<string>;
  protected mainSymbol$: Observable<string>;
  protected mainSerie$: BehaviorSubject<SeriesStrategies | null>;
  protected paneIndex: number | null = null;
  protected indicatorReference: Indicator | null = null;
  protected showSymbolLabel: boolean;

  private subscriptions = new Subscription();
  private dataSub: Subscription | null = null;
  private realtimeSub: Subscription | null = null;
  public actLikeMainSerie = false;

  constructor({
    lwcChart,
    mainSymbolId$,
    mainSymbol$,
    mainSerie$,
    customFormatter,
    seriesOptions,
    priceScaleOptions,
    showSymbolLabel = true,
    paneIndex,
    indicatorReference,
    actLikeMainSerie,
  }: BaseSeriesParams<TSeries>) {
    this.lwcSeries = this.createSeries({
      chart: lwcChart,
      seriesOptions,
      paneIndex,
      priceScaleOptions,
    });
    this.actLikeMainSerie = actLikeMainSerie ?? false;

    this.lwcChart = lwcChart;
    this.customFormatter = customFormatter;
    this.mainSymbolId$ = mainSymbolId$;
    this.mainSymbol$ = mainSymbol$;
    this.mainSerie$ = mainSerie$;
    this.showSymbolLabel = showSymbolLabel;
    this.indicatorReference = indicatorReference ?? null;
  }

  public getLegendData = (
    param?: MouseEventParams,
  ): Partial<
    Record<
      keyof Ohlc,
      {
        value: number | string | Time;
        color: string;
        name: string;
      }
    >
  > => {
    if (!param) {
      const seriesData = this.data();
      const currentBar = seriesData[seriesData.length - 1];

      if (!currentBar) {
        return {};
      }

      return this.formatLegendValues(currentBar, seriesData[seriesData.length - 2] ?? null);
    }

    const currentBar = param.seriesData.get(this.lwcSeries) ?? null;
    const previousBar = param.logical === null ? null : this.dataByIndex(param.logical! - 1);

    return this.formatLegendValues(currentBar, previousBar);
  };

  public show(): void {
    this.lwcSeries.applyOptions({
      visible: true,
    });
  }

  public hide(): void {
    this.lwcSeries.applyOptions({
      visible: false,
    });
  }

  public isVisible(): boolean {
    return this.lwcSeries.options().visible;
  }

  public destroy(): void {
    this.dataSub?.unsubscribe();
    this.realtimeSub?.unsubscribe();
    this.subscriptions.unsubscribe();
    this.lwcChart.removeSeries(this.lwcSeries);
  }

  public getLwcSeries(): ISeriesApi<TSeries> {
    return this.lwcSeries;
  }

  public applyOptions(options: SeriesPartialOptionsMap[TSeries]): void {
    this.lwcSeries.applyOptions(options);
  }

  public attachPrimitive(primitive: ISeriesPrimitive<Time>): void {
    this.lwcSeries.attachPrimitive(primitive);
  }

  public barsInLogicalRange(range: IRange<number>): BarsInfo<Time> | null {
    return this.lwcSeries.barsInLogicalRange(range);
  }

  public coordinateToPrice(coordinate: number): BarPrice | null {
    return this.lwcSeries.coordinateToPrice(coordinate);
  }

  public createPriceLine(options: CreatePriceLineOptions): IPriceLine {
    return this.lwcSeries.createPriceLine(options);
  }

  public data(): readonly SeriesDataItemTypeMap<Time>[TSeries][] {
    return this.lwcSeries.data();
  }

  public pop(count: number): SeriesDataItemTypeMap<Time>[TSeries][] {
    return this.lwcSeries.pop(count);
  }

  public dataByIndex(
    logicalIndex: number,
    mismatchDirection?: MismatchDirection,
  ): SeriesDataItemTypeMap<Time>[TSeries] | null {
    return this.lwcSeries.dataByIndex(logicalIndex, mismatchDirection);
  }

  public detachPrimitive(primitive: ISeriesPrimitive<Time>): void {
    this.lwcSeries.detachPrimitive(primitive);
  }

  public getPane(): IPaneApi<Time> {
    return this.lwcSeries.getPane();
  }

  public moveToPane(paneIndex: number): void {
    this.lwcSeries.moveToPane(paneIndex);
  }

  public options(): Readonly<SeriesOptionsMap[TSeries]> {
    return this.lwcSeries.options();
  }

  public priceFormatter(): IPriceFormatter {
    return this.lwcSeries.priceFormatter();
  }

  public priceLines(): IPriceLine[] {
    return this.lwcSeries.priceLines();
  }

  public priceScale(): IPriceScaleApi {
    return this.lwcSeries.priceScale();
  }

  public priceToCoordinate(price: number): Coordinate | null {
    return this.lwcSeries.priceToCoordinate(price);
  }

  public removePriceLine(line: IPriceLine): void {
    this.lwcSeries.removePriceLine(line);
  }

  public seriesOrder(): number {
    return this.lwcSeries.seriesOrder();
  }

  public seriesType(): TSeries {
    return this.lwcSeries.seriesType();
  }

  public lastValueData(globalLast: boolean): ReturnType<ISeriesApi<TSeries>['lastValueData']> {
    return this.lwcSeries.lastValueData(globalLast);
  }

  public setData(data: SeriesDataItemTypeMap<Time>[TSeries][]): void {
    this.lwcSeries.setData(normalizeSeriesData(data));
  }

  public setSeriesOrder(order: number): void {
    this.lwcSeries.setSeriesOrder(order);
  }

  public subscribeDataChanged(handler: DataChangedHandler): void {
    this.lwcSeries.subscribeDataChanged(handler);
  }

  public unsubscribeDataChanged(handler: DataChangedHandler): void {
    this.lwcSeries.unsubscribeDataChanged(handler);
  }

  public update(bar: SeriesDataItemTypeMap<Time>[TSeries], historicalUpdate?: boolean): void {
    const data = this.lwcSeries.data();
    const lastBar = data[data.length - 1];

    if (!lastBar) {
      this.lwcSeries.update(bar, false);
      return;
    }

    const isHistoricalUpdate =
      historicalUpdate ?? (typeof lastBar.time === 'number' && typeof bar.time === 'number' && bar.time < lastBar.time);

    this.lwcSeries.update(bar, isHistoricalUpdate);
  }

  protected createSeries({
    chart,
    seriesOptions,
    paneIndex = MAIN_PANE_INDEX,
    priceScaleOptions = {},
  }: CreateSeriesParams<TSeries>): ISeriesApi<TSeries> {
    this.paneIndex = paneIndex;

    const options = {
      ...this.getDefaultOptions(),
      ...seriesOptions,
    };

    const series = chart.addSeries<TSeries>(this.seriesDefinition(), options, paneIndex);

    series.priceScale().applyOptions(priceScaleOptions);

    return series;
  }

  protected abstract dataSourceSubscription(next: Candle[]): void;

  protected abstract seriesDefinition(): SeriesDefinition<TSeries>;

  protected abstract dataSourceRealtimeSubscription(next: Candle): void;

  protected abstract getDefaultOptions(): SeriesPartialOptionsMap[TSeries];

  protected abstract formatMainSerie(inputData: Candle[]): SeriesDataItemTypeMap<Time>[TSeries][];

  protected abstract formatLegendValues(
    currentBar: BarData | LineData | HistogramData | CustomData | null,
    prevBar: BarData | LineData | HistogramData | CustomData | null,
  ): Partial<
    Record<
      keyof Ohlc,
      {
        value: number | string | Time;
        color: string;
        name: string;
      }
    >
  >;

  protected formatData(inputData: Candle[]): SeriesDataItemTypeMap<Time>[TSeries][] {
    if (!this.customFormatter) {
      return this.formatMainSerie(inputData);
    }

    const mainSeriesData = (this.mainSerie$.value?.data() ?? []) as unknown as SerieData[];
    const selfData = this.data() as unknown as ChartTypeToCandleData[TSeries][];

    if (inputData.length !== 1) {
      return this.customFormatter({
        mainSeriesData,
        selfData,
        indicatorReference: this.indicatorReference ?? undefined,
      });
    }

    const candle = this.formatMainSerie(inputData)[0];

    if (!candle) {
      return [];
    }

    return this.customFormatter({
      mainSeriesData,
      selfData,
      candle: candle as unknown as SerieData,
      indicatorReference: this.indicatorReference ?? undefined,
    });
  }

  protected subscribeDataSource = (dataSource: DataSource): void => {
    const minMove = getPricePrecisionStep();

    this.lwcSeries.applyOptions({
      priceFormat: {
        type: 'custom',
        minMove,
        formatter: (price: number) => formatPrice(price) ?? String(price),
      },
    });

    this.subscriptions.add(
      this.mainSymbol$.pipe(distinctUntilChanged()).subscribe((symbol) => {
        this.lwcSeries.applyOptions({
          title: this.showSymbolLabel ? symbol : '',
        });
      }),
    );

    this.subscriptions.add(
      this.mainSymbolId$.pipe(distinctUntilChanged()).subscribe((symbolId) => {
        this.dataSub?.unsubscribe();
        this.realtimeSub?.unsubscribe();

        this.dataSub = dataSource.subscribe(symbolId, (next) => {
          this.dataSourceSubscription(next);
        });

        this.realtimeSub = dataSource.subscribeRealtime(symbolId, (next: Candle) => {
          this.dataSourceRealtimeSubscription(next);
        });
      }),
    );
  };
}

export function calcCandleChange(
  prev: BarData | LineData | HistogramData | CustomData | null,
  current: BarData | LineData | HistogramData | CustomData | null,
):
  | (Ohlc & {
      customValues?: Record<string, unknown>;
    })
  | null {
  if (!current) {
    return null;
  }

  if (!prev) {
    return current;
  }

  if (isBarData(prev) && isBarData(current)) {
    const absoluteChange = current.close - prev.close;
    const percentageChange = ((current.close - prev.close) / prev.close) * 100;

    return {
      ...current,
      absoluteChange,
      percentageChange: Number.isNaN(percentageChange) ? 0 : percentageChange,
    };
  }

  if (isLineData(prev) && isLineData(current)) {
    const absoluteChange = current.value - prev.value;
    const percentageChange = ((current.value - prev.value) / prev.value) * 100;
    const high = current.customValues?.high;
    const low = current.customValues?.low;

    return {
      time: current.time,
      value: current.value,
      high: typeof high === 'number' ? high : current.value,
      low: typeof low === 'number' ? low : current.value,
      absoluteChange,
      percentageChange: Number.isNaN(percentageChange) ? 0 : percentageChange,
      customValues: current.customValues,
    };
  }

  return null;
}


import { SymbolInfo } from '@src/types/symbol';

export enum CompareMode {
  Absolute = 'ABS',
  Percentage = 'PCT',
  NewScale = 'SCALE',
  NewPane = 'PANE',
}

export interface CompareItem extends SymbolInfo {
  mode: CompareMode;
}

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

import { flatten } from 'lodash-es';
import { BehaviorSubject, distinctUntilChanged, map, Observable, of, Subscription } from 'rxjs';

import { DataSource } from '@core/DataSource';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { IndicatorManager } from '@core/IndicatorManager';
import { PaneManager } from '@core/PaneManager';
import { PriceScale } from '@core/PriceScale';
import { COMPARE_COLOR_PALETTE } from '@src/theme';
import { CompareItem, CompareMode, Direction, IndicatorConfig, SymbolInfo, SymbolInfoInput } from '@src/types';
import { CompareSnapshot } from '@src/types/snapshot';
import { createFallbackColor, normalizeColor, normalizeSymbol, normalizeSymbolInfo } from '@src/utils';

type MainScaleCompareMode = CompareMode.Absolute | CompareMode.Percentage;

interface CompareEntry extends CompareItem {
  key: string;
  entity: Indicator;
}

interface CompareManagerParams {
  chart: IChartApi;
  eventManager: EventManager;
  dataSource: DataSource;
  indicatorManager: IndicatorManager;
  paneManager: PaneManager;
  initialIndicators?: CompareSnapshot[];
}

export class CompareManager {
  private readonly chart: IChartApi;
  private readonly eventManager: EventManager;
  private readonly dataSource: DataSource;
  private readonly indicatorManager: IndicatorManager;
  private readonly paneManager: PaneManager;
  private readonly entries = new Map<string, CompareEntry>();
  private readonly itemsSubject = new BehaviorSubject<CompareItem[]>([]);
  private readonly entitiesSubject = new BehaviorSubject<Indicator[]>([]);
  private readonly subscriptions = new Subscription();

  private restoringInitialIndicators = false;

  constructor({
    chart,
    eventManager,
    dataSource,
    indicatorManager,
    paneManager,
    initialIndicators = [],
  }: CompareManagerParams) {
    this.chart = chart;
    this.eventManager = eventManager;
    this.dataSource = dataSource;
    this.indicatorManager = indicatorManager;
    this.paneManager = paneManager;

    this.subscriptions.add(
      this.eventManager.timeframe().subscribe(() => {
        this.applyPolicy();
      }),
    );

    this.setup(initialIndicators);
  }

  public itemsObs(): Observable<CompareItem[]> {
    return this.itemsSubject.asObservable();
  }

  public entities(): Observable<Indicator[]> {
    return this.entitiesSubject.asObservable();
  }

  public clear(): void {
    for (const key of this.entries.keys()) {
      this.removeEntry(key);
    }

    this.commitEntriesChange();
  }

  public async setSymbolMode(
    seriesType: SeriesType,
    symbolInfoInput: SymbolInfoInput,
    mode: CompareMode,
    paneId?: number,
  ): Promise<void> {
    const symbolInfo = normalizeSymbolInfo(symbolInfoInput);

    if (!symbolInfo) {
      return;
    }

    const { symbolId, symbol, symbolName } = symbolInfo;

    if (mode === CompareMode.NewScale && this.isNewScaleDisabled() && !this.restoringInitialIndicators) {
      return;
    }

    const isMainScaleMode = isMainScaleCompareMode(mode);

    if (isMainScaleMode) {
      for (const entry of this.entries.values()) {
        if (isMainScaleCompareMode(entry.mode)) {
          entry.mode = mode;
        }
      }
    }

    const key = makeKey(symbolId, mode);

    if (this.entries.has(key)) {
      if (isMainScaleMode) {
        this.commitEntriesChange();
      }

      return;
    }

    const entity = this.indicatorManager.addEntity<Indicator>((zIndex, moveUp, moveDown) => {
      const usedColorsByCompare = this.entitiesSubject.value.map(
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        (indicator) => indicator.getConfig().series?.[0]?.seriesOptions?.color,
      );

      const existingIndicators = Array.from(this.indicatorManager.getIndicators().value.values());

      const usedColorsByIndicatorsRaw = existingIndicators.map((indicator) =>
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        indicator.config?.series?.map((series) => series.seriesOptions?.color),
      );

      const usedColorsByIndicators = flatten(usedColorsByIndicatorsRaw).filter((color) => color !== undefined);
      const usedColors = usedColorsByCompare.concat(usedColorsByIndicators);

      const config = getDefaultCompareIndicatorConfig(symbolInfo, usedColors);

      const associatedPane =
        mode === CompareMode.NewPane
          ? paneId !== undefined
            ? (this.paneManager.getPaneById(paneId) ?? this.paneManager.addPane())
            : this.paneManager.addPane()
          : this.paneManager.getMainPane();

      return new Indicator({
        id: key,
        lwcChart: this.chart,
        mainSymbolId$: of(symbolId),
        mainSymbol$: of(symbol),
        dataSource: this.dataSource,
        associatedPane,
        config: {
          ...config,
          series: [
            {
              ...config.series[0],
              actLikeMainSerie: true,
              seriesOptions: {
                ...config.series[0]?.seriesOptions,
                ...(mode === CompareMode.NewScale ? { priceScaleId: Direction.Left } : {}),
              },
            },
          ],
          newPane: mode === CompareMode.NewPane,
        },
        zIndex,
        onDelete: () => {
          if (this.removeEntry(key)) {
            this.commitEntriesChange();
          }
        },
        moveUp,
        moveDown,
        paneId: associatedPane.getId(),
      });
    });

    this.entries.set(key, {
      key,
      symbolId,
      symbol,
      symbolName,
      mode,
      entity,
    });

    this.commitEntriesChange();

    await this.dataSource.isReady(symbolId);
  }

  public removeSymbolMode(symbolIdRaw: string, mode: CompareMode): void {
    const symbolId = normalizeSymbol(symbolIdRaw);

    if (!symbolId) {
      return;
    }

    if (this.removeEntry(makeKey(symbolId, mode))) {
      this.commitEntriesChange();
    }
  }

  public removeSymbol(symbolIdRaw: string): void {
    const symbolId = normalizeSymbol(symbolIdRaw);

    if (!symbolId) {
      return;
    }

    let removed = false;

    for (const [key, entry] of this.entries) {
      if (entry.symbolId !== symbolId) {
        continue;
      }

      removed = this.removeEntry(key) || removed;
    }

    if (removed) {
      this.commitEntriesChange();
    }
  }

  public isNewScaleDisabled(): boolean {
    return this.itemsSubject.value.length > 0;
  }

  public isNewScaleDisabledObservable(): Observable<boolean> {
    return this.itemsSubject.pipe(
      map((items) => items.length > 0),
      distinctUntilChanged(),
    );
  }

  public getAllEntities() {
    return Array.from(this.entries.values()).map(({ symbolId, symbol, symbolName, entity, mode }) => ({
      symbolId,
      symbol,
      symbolName,
      entity,
      mode,
    }));
  }

  public destroy(): void {
    this.subscriptions.unsubscribe();
    this.clear();
    this.itemsSubject.complete();
    this.entitiesSubject.complete();
  }

  private setup = async (compareIndicators: CompareSnapshot[]): Promise<void> => {
    this.restoringInitialIndicators = true;

    const mainPane = this.paneManager.getMainPane();
    const mainPaneId = mainPane.getId();
    const mainScaleMode =
      mainPane.getPriceScale(Direction.Right).getMode() === PriceScaleMode.Percentage
        ? CompareMode.Percentage
        : CompareMode.Absolute;

    try {
      for (const compareIndicator of compareIndicators) {
        const { scale, symbolInfo, seriesName, paneId } = compareIndicator;

        const symbolInfoNormalized = normalizeSymbolInfo(symbolInfo);

        if (!symbolInfoNormalized) {
          continue;
        }

        if (mainPaneId !== paneId && scale === Direction.Left) {
          throw new Error('[CompareManager]: несколько шкал на второстеменном пейне не поддерживаются');
        }

        const compareMode =
          scale === Direction.Left
            ? CompareMode.NewScale
            : mainPaneId === paneId
              ? mainScaleMode
              : CompareMode.NewPane;

        // eslint-disable-next-line no-await-in-loop
        await this.setSymbolMode(seriesName, symbolInfoNormalized, compareMode, paneId!);
      }
    } finally {
      this.restoringInitialIndicators = false;
    }
  };

  private removeEntry(key: string): boolean {
    const entry = this.entries.get(key);

    if (!entry) {
      return false;
    }

    this.entries.delete(key);
    this.indicatorManager.removeEntity(entry.entity);
    entry.entity.destroy();

    return true;
  }

  private commitEntriesChange(): void {
    this.applyPolicy();
    this.publish();
  }

  private publish(): void {
    const entries = Array.from(this.entries.values());

    this.itemsSubject.next(
      entries.map(({ symbolId, symbol, symbolName, mode }) => ({
        symbolId,
        symbol,
        symbolName,
        mode,
      })),
    );

    this.entitiesSubject.next(entries.map(({ entity }) => entity));
  }

  private syncPercentageMode(priceScale: PriceScale, shouldEnablePercentageMode: boolean): void {
    if (shouldEnablePercentageMode) {
      priceScale.setMode(PriceScaleMode.Percentage);
      return;
    }

    if (priceScale.getMode() === PriceScaleMode.Percentage) {
      priceScale.setMode(PriceScaleMode.Normal);
    }
  }

  private applyPolicy(): void {
    let percentageComparisonActive = false;
    let newScaleComparisonActive = false;

    for (const { mode } of this.entries.values()) {
      if (mode === CompareMode.Percentage) {
        percentageComparisonActive = true;
      }

      if (mode === CompareMode.NewScale) {
        newScaleComparisonActive = true;
      }
    }

    this.paneManager.setPriceScaleSideVisible(Direction.Left, newScaleComparisonActive);
    this.paneManager.setPriceScaleSideVisible(Direction.Right, true);

    const mainRightPriceScale = this.paneManager.getMainPane().getPriceScale(Direction.Right);

    this.syncPercentageMode(mainRightPriceScale, percentageComparisonActive);

    this.paneManager.invalidate();
  }
}

function isMainScaleCompareMode(mode: CompareMode): mode is MainScaleCompareMode {
  return mode === CompareMode.Absolute || mode === CompareMode.Percentage;
}

function makeKey(symbolId: string, mode: CompareMode): string {
  const keyMode = isMainScaleCompareMode(mode) ? 'MAIN' : mode;

  return `${symbolId}|${keyMode}`;
}

function getPaletteColorFromIndex(usedColors: Set<string>, startIndex: number): string {
  for (let offset = 0; offset < COMPARE_COLOR_PALETTE.length; offset += 1) {
    const color = COMPARE_COLOR_PALETTE[(startIndex + offset) % COMPARE_COLOR_PALETTE.length];

    if (!usedColors.has(normalizeColor(color))) {
      return color;
    }
  }

  return createFallbackColor(usedColors.size);
}

function getDefaultCompareIndicatorConfig(symbolInfo: SymbolInfo, usedColors: string[]): IndicatorConfig {
  const reservedColors = new Set(usedColors.map(normalizeColor));

  return {
    symbolInfo,
    newPane: true,
    label: symbolInfo.symbolName,
    series: [
      {
        name: 'Line', // todo: change with enum
        id: `compare-${crypto.randomUUID()}`,
        seriesOptions: {
          visible: true,
          color: getPaletteColorFromIndex(reservedColors, 0),
        },
      },
    ],
  };
}


public override getSnapshot(): DOMObjectSnapshot & (IndicatorSnapshot | CompareSnapshot) {
  const domSnap = super.getSnapshot();

  if (this.indicatorType) {
    return {
      ...domSnap,
      indicatorType: this.indicatorType,
      settings: this.getSettingsValues(),
    };
  }

  const seriesName = this.config.series[0]?.name;

  if (!seriesName) {
    throw new Error('[Indicator]: невозможно сохранить состояние compare индикатора');
  }

  const scale = (
    this.series[0]?.options().priceScaleId ?? this.lwcChart.options().defaultVisiblePriceScaleId
  ) as Direction;

  return {
    ...domSnap,
    symbolInfo: this.config.symbolInfo!,
    seriesName,
    scale,
  };
}

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

function getOptions(config: ChartConfig): DeepPartial<ChartOptions> {
  const timeFormat = config.timeFormat ?? Defaults.timeFormat;
  const showTime = config.showTime ?? Defaults.showTime;

  const use12HourFormat = timeFormat === '12h';
  const timeFormatString = use12HourFormat ? 'h:mm A' : 'HH:mm';

  const { colors } = getThemeStore();

  const localization: LocalizationOptionsBase = {
    locale: getLocale(),
    priceFormatter: (priceValue: BarPrice) => {
      return formatCompactNumber(priceValue);
    },
  };

  return {
    width: config.container.clientWidth,
    height: config.container.clientHeight,
    autoSize: true,
    defaultVisiblePriceScaleId: Direction.Right,
    layout: {
      background: {
        color: colors.chartBackground,
      },
      textColor: colors.chartTextPrimary,
    },
    grid: {
      vertLines: {
        color: colors.chartGridLine,
      },
      horzLines: {
        color: colors.chartGridLine,
      },
    },
    crosshair: {
      mode: CrosshairMode.Normal,
      vertLine: {
        color: colors.chartCrosshairLine,
        labelBackgroundColor: colors.chartCrosshairLabel,
        style: 0,
      },
      horzLine: {
        color: colors.chartCrosshairLine,
        labelBackgroundColor: colors.chartCrosshairLabel,
        style: 2,
      },
    },
    timeScale: {
      timeVisible: showTime,
      secondsVisible: false,
      tickMarkFormatter: createTickMarkFormatter(timeFormatString),
      borderVisible: false,
      allowBoldLabels: false,
      rightOffset: 25,
      shiftVisibleRangeOnNewBar: true,
      allowShiftVisibleRangeOnWhitespaceReplacement: true,
    },
    rightPriceScale: {
      textColor: colors.chartTextPrimary,
      borderVisible: false,
    },
    localization,
  };
}


const Modal = ({ onClose, compareManager }: { onClose: () => void; compareManager: CompareManager | null }) => {
  const [isNewScaleDisabled, setIsNewScaleDisabled] = useState(false);

  useEffect(() => {
    if (!compareManager) {
      setIsNewScaleDisabled(false);
      return;
    }

    setIsNewScaleDisabled(compareManager.isNewScaleDisabled());

    const subscription = compareManager.isNewScaleDisabledObservable().subscribe(setIsNewScaleDisabled);

    return () => subscription.unsubscribe();
  }, [compareManager]);

  return (
    <div
      onClick={onClose}
      style={{
        width: '100%',
        height: '100%',
        display: 'flex',
        justifyContent: 'center',
        alignItems: 'center',
        backgroundColor: '#0000004D',
        position: 'absolute',
        top: '50%',
        left: '50%',
        transform: 'translate(-50%, -50%)',
        zIndex: 999,
        cursor: 'pointer',
      }}
    >
      <div
        onClick={(e) => e.stopPropagation()}
        style={{
          display: 'grid',
          gap: 16,
          padding: 16,
          backgroundColor: 'white',
        }}
      >
        {COMPARE_ITEMS.map((symbolInfo) => (
          <div
            key={symbolInfo.symbolId}
            style={{ display: 'flex', justifyContent: 'space-between', gap: 16 }}
          >
            <span>{symbolInfo.symbolName ?? symbolInfo.symbol ?? symbolInfo.symbolId}</span>
            <div style={{ display: 'flex', gap: 8 }}>
              <button
                onClick={() => compareManager?.setSymbolMode('Line', symbolInfo, CompareMode.Absolute)}
                style={{ backgroundColor: 'lightgray', padding: '2px 8px' }}
                type="button"
              >
                Абсолютная шкала
              </button>
              <button
                onClick={() => compareManager?.setSymbolMode('Line', symbolInfo, CompareMode.Percentage)}
                style={{ backgroundColor: 'lightgray', padding: '2px 8px' }}
                type="button"
              >
                %
              </button>
              <button
                onClick={() => compareManager?.setSymbolMode('Line', symbolInfo, CompareMode.NewScale)}
                style={{
                  backgroundColor: isNewScaleDisabled ? 'darkgray' : 'lightgray',
                  padding: '2px 8px',
                  cursor: isNewScaleDisabled ? 'not-allowed' : 'cursor',
                }}
                disabled={isNewScaleDisabled}
                type="button"
              >
                Новая шкала
              </button>
              <button
                onClick={() => compareManager?.setSymbolMode('Line', symbolInfo, CompareMode.NewPane)}
                style={{ backgroundColor: 'lightgray', padding: '2px 8px' }}
                type="button"
              >
                Новая панель
              </button>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
};