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


import dayjs from 'dayjs';

import {
  BarPrice,
  ChartOptions,
  createChart,
  CrosshairMode,
  DeepPartial,
  IChartApi,
  IRange,
  LocalizationOptionsBase,
  LogicalRange,
  Time,
  UTCTimestamp,
} from 'lightweight-charts';

import flatten from 'lodash-es/flatten';
import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
import { map, withLatestFrom } from 'rxjs/operators';

import { ChartMouseEvents } from '@core/ChartMouseEvents';
import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { DrawingsManager } from '@core/DrawingsManager';
import { EventManager } from '@core/EventManager';
import { IndicatorManager } from '@core/IndicatorManager';
import { ModalRenderer } from '@core/ModalRenderer';
import { PaneManager } from '@core/PaneManager';
import { CompareManager } from '@src/core/CompareManager';
import { PriceLabelsManager } from '@src/core/PriceLabels/PriceLabelsManager';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { getThemeStore } from '@src/theme/store';
import { ThemeKey, ThemeMode } from '@src/theme/types';
import { getLocale } from '@src/translations';
import {
  Candle,
  ChartOptionsModel,
  ChartSeriesType,
  ChartTypeOptions,
  Direction,
  OHLCConfig,
  TooltipConfig,
} from '@src/types';
import { Defaults } from '@src/types/defaults';
import { DayjsOffset, Intervals, intervalsToDayjs } from '@src/types/intervals';

import { ChartSnapshot, ISerializable, PaneSnapshot } from '@src/types/snapshot';
import { formatCompactNumber } from '@src/utils';
import { createTickMarkFormatter, formatDate } from '@src/utils/formatter';

export interface ChartConfig extends Partial<ChartOptionsModel> {
  container: HTMLElement;
  seriesTypes: ChartSeriesType[];
  theme: ThemeKey;
  mode?: ThemeMode;
  chartOptions?: ChartTypeOptions;
  localization?: LocalizationOptionsBase;
}

export enum Resize {
  Shrink,
  Expand,
}

const HISTORY_LOAD_THRESHOLD = 50;

interface ChartParams {
  params: {
    dataSource: DataSource;
    eventManager: EventManager;
    modalRenderer: ModalRenderer;
    ohlcConfig: OHLCConfig;
    tooltipConfig: TooltipConfig;
    panes: PaneSnapshot[];
  };
  lwcChartConfig: ChartConfig;
}

/**
 * Абстракция над библиотекой для построения графиков
 */
export class Chart implements ISerializable<ChartSnapshot> {
  private lwcChart!: IChartApi;
  private container: HTMLElement;
  private eventManager: EventManager;
  private paneManager!: PaneManager;
  private compareManager: CompareManager;
  private mouseEvents: ChartMouseEvents;
  private indicatorManager: IndicatorManager;
  private priceLabelsManager: PriceLabelsManager;
  private optionsSubscription: Subscription;
  private dataSource: DataSource;
  private chartConfig: Omit<ChartConfig, 'theme' | 'mode'>;
  private mainSeries: BehaviorSubject<SeriesStrategies | null> = new BehaviorSubject<SeriesStrategies | null>(null); // Main Series. Exists in a single copy
  private DOM: DOMModel;

  private isPointerDown = false;
  private didResetOnDrag = false;

  private subscriptions = new Subscription();

  private currentInterval: Intervals | null = null;

  private activeSymbols: string[] = [];

  private historyBatchRunning = false;

  constructor({ params, lwcChartConfig }: ChartParams) {
    const { eventManager, dataSource, modalRenderer, ohlcConfig, tooltipConfig, panes: panesSnapshot } = params;

    this.eventManager = eventManager;
    this.dataSource = dataSource;
    this.container = lwcChartConfig.container;
    this.chartConfig = lwcChartConfig;

    this.lwcChart = createChart(this.container, getOptions(lwcChartConfig));

    this.optionsSubscription = this.eventManager
      .getChartOptionsModel()
      .subscribe(({ dateFormat, timeFormat, showTime }) => {
        const configToApply = { ...lwcChartConfig, dateFormat, timeFormat, showTime };

        this.lwcChart.applyOptions({
          ...getOptions(configToApply),
          localization: {
            timeFormatter: (time: UTCTimestamp) => formatDate(time, dateFormat, timeFormat, showTime),
          },
        });
      });

    this.subscriptions.add(this.optionsSubscription);

    this.mouseEvents = new ChartMouseEvents({ lwcChart: this.lwcChart, container: this.container });

    this.mouseEvents.subscribe('wheel', this.onWheel);
    this.mouseEvents.subscribe('pointerDown', this.onPointerDown);
    this.mouseEvents.subscribe('pointerMove', this.onPointerMove);
    this.mouseEvents.subscribe('pointerUp', this.onPointerUp);
    this.mouseEvents.subscribe('pointerCancel', this.onPointerUp);

    this.DOM = new DOMModel({ modalRenderer });

    this.paneManager = new PaneManager({
      eventManager: this.eventManager,
      panesSnapshot,
      lwcChart: this.lwcChart,
      dataSource,
      DOM: this.DOM,
      ohlcConfig,
      subscribeChartEvent: this.subscribeChartEvent,
      chartContainer: this.container,
      tooltipConfig,
      modalRenderer,
    });

    this.indicatorManager = new IndicatorManager({
      eventManager,
      initialIndicators: flatten(
        panesSnapshot.map((pane) => pane.indicators.map((ind) => ({ ...ind, paneId: pane.id }))),
      ),
      DOM: this.DOM,
      dataSource: this.dataSource,
      lwcChart: this.lwcChart,
      paneManager: this.paneManager,
      chartOptions: lwcChartConfig.chartOptions,
    });

    this.compareManager = new CompareManager({
      chart: this.lwcChart,
      initialIndicators: flatten(
        panesSnapshot.map((pane) => pane.indicators.map((ind) => ({ ...ind, paneId: pane.id }))),
      ),
      eventManager: this.eventManager,
      dataSource: this.dataSource,
      indicatorManager: this.indicatorManager,
      paneManager: this.paneManager,
    });

    this.priceLabelsManager = new PriceLabelsManager({
      mainSeries$: this.mainSeries.asObservable(),
      mainSymbol$: this.eventManager.symbol(),
      compareEntities$: this.compareManager.entities(),
      indicatorEntities$: this.indicatorManager.entities(),
    });

    this.setupDataSourceSubs();
    this.setupHistoricalDataLoading();
  }

  public getPriceScaleWidth(direction: Direction): number {
    try {
      const priceScale = this.lwcChart.priceScale(direction);

      return priceScale ? priceScale.width() : 0;
    } catch {
      return 0;
    }
  }

  public getDrawingsManager = (): DrawingsManager => {
    return this.paneManager.getDrawingsManager();
  };

  public getIndicatorManager = (): IndicatorManager => {
    return this.indicatorManager;
  };

  private onWheel = () => {
    this.eventManager.resetInterval({ history: false });
  };

  private onPointerDown = () => {
    this.isPointerDown = true;
    this.didResetOnDrag = false;
  };

  private onPointerMove = () => {
    if (!this.isPointerDown) return;
    if (this.didResetOnDrag) return;

    this.didResetOnDrag = true;
    this.eventManager.resetInterval({ history: false });
  };

  private onPointerUp = () => {
    this.isPointerDown = false;
  };

  public getDom(): DOMModel {
    return this.DOM;
  }

  public getMainSeries(): Observable<SeriesStrategies | null> {
    return this.mainSeries.asObservable();
  }

  public getCompareManager(): CompareManager {
    return this.compareManager;
  }

  public updateTheme(theme: ThemeKey, mode: ThemeMode) {
    this.lwcChart.applyOptions(getOptions({ ...this.chartConfig, theme, mode }));
  }

  public destroy(): void {
    this.priceLabelsManager.destroy();
    this.mouseEvents.destroy();
    this.compareManager.clear();
    this.subscriptions.unsubscribe();
    this.lwcChart.remove();
  }

  public subscribeChartEvent: ChartMouseEvents['subscribe'] = (event, callback) =>
    this.mouseEvents.subscribe(event, callback);

  public unsubscribeChartEvent: ChartMouseEvents['unsubscribe'] = (event, callback) => {
    this.mouseEvents.unsubscribe(event, callback);
  };

  // todo: add/move to undo/redo model(eventManager)
  public scrollTimeScale = (direction: Direction) => {
    this.eventManager.resetInterval({ history: false });
    const diff = direction === Direction.Left ? -2 : 2;
    const currentPosition = this.lwcChart.timeScale().scrollPosition();
    this.lwcChart.timeScale().scrollToPosition(currentPosition + diff, false);
  };

  // todo: add/move to undo/redo model(eventManager)
  public zoomTimeScale = (resize: Resize) => {
    this.eventManager.resetInterval({ history: false });
    const diff = resize === Resize.Shrink ? -1 : 1;

    const currentRange = this.lwcChart.timeScale().getVisibleRange();
    if (!currentRange) return;

    const { from, to } = currentRange as IRange<number>;
    if (!from || !to) return;

    const next: IRange<Time> = {
      from: (from + (to - from) * 0.1 * diff) as Time,
      to: to as Time,
    };
    this.lwcChart.timeScale().setVisibleRange(next);
  };

  // todo: add to undo/redo model(eventManager)
  public resetZoom = () => {
    this.eventManager.resetInterval({ history: false });
    this.lwcChart.timeScale().resetTimeScale();
    this.lwcChart.priceScale(Direction.Right).setAutoScale(true);
    this.lwcChart.priceScale(Direction.Left).setAutoScale(true);
  };

  public getRealtimeApi() {
    return {
      getTimeframe: () => this.eventManager.getTimeframe(),
      getSymbols: () => this.activeSymbols,
      update: (symbol: string, candle: Candle) => {
        this.dataSource.updateRealtime(symbol, candle);
      },
    };
  }

  public getSnapshot(): ChartSnapshot {
    return {
      panes: this.paneManager.getSnapshot(),
      chartSeriesType: this.eventManager.exportChartSettings().seriesSelected,
      timeframe: this.eventManager.exportChartSettings().timeframe,
      symbol: this.activeSymbols[0],
    };
  }

  private scheduleHistoryBatch = () => {
    if (this.historyBatchRunning) return;

    this.historyBatchRunning = true;

    requestAnimationFrame(() => {
      const symbols = this.activeSymbols.slice();

      Promise.all(symbols.map((s) => this.dataSource.loadMoreHistory(s))).finally(() => {
        this.historyBatchRunning = false;

        const range = this.lwcChart.timeScale().getVisibleLogicalRange();
        if (range && range.from < HISTORY_LOAD_THRESHOLD) {
          this.scheduleHistoryBatch();
        }
      });
    });
  };

  private setupDataSourceSubs() {
    const getWarmupFrom = (): number => {
      if (this.currentInterval && this.currentInterval !== Intervals.All) {
        return getIntervalRange(this.currentInterval).from;
      }

      const range = this.lwcChart.timeScale().getVisibleRange();
      if (!range) return 0;

      const { from } = range as IRange<number>;
      return from as number;
    };

    const warmupSymbols = (symbols: string[]) => {
      if (!symbols.length) return;

      const from = getWarmupFrom();
      if (!from) return;

      Promise.all(symbols.map((symbol) => this.dataSource.loadTill(symbol, from))).catch((error) => {
        console.error('[Chart] Ошибка при прогреве символов:', error);
      });
    };
    const symbols$ = combineLatest([this.eventManager.symbol(), this.compareManager.itemsObs()]).pipe(
      map(([main, items]) => Array.from(new Set([main, ...items.map((i) => i.symbol)]))),
    );

    this.subscriptions.add(
      this.eventManager
        .getInterval()
        .pipe(withLatestFrom(symbols$))
        .subscribe(([interval, symbols]) => {
          this.currentInterval = interval;

          if (!interval) return;

          if (interval === Intervals.All) {
            Promise.all(symbols.map((s) => this.dataSource.loadAllHistory(s)))
              .then(() => {
                requestAnimationFrame(() => this.lwcChart.timeScale().fitContent());
              })
              .catch((error) => console.error('[Chart] Ошибка при загрузке всей истории:', error));

            return;
          }

          const { from, to } = getIntervalRange(interval);

          Promise.all(symbols.map((s) => this.dataSource.loadTill(s, from)))
            .then(() => {
              this.lwcChart.timeScale().setVisibleRange({ from: from as Time, to: to as Time });
            })
            .catch((error) => {
              console.error('[Chart] Ошибка при применении интервала:', error);
            });
        }),
    );

    this.subscriptions.add(
      symbols$.subscribe((symbols) => {
        const prevSymbols = this.activeSymbols;
        this.activeSymbols = symbols;

        this.dataSource.setSymbols(symbols);

        const prevSet = new Set(prevSymbols);
        const added: string[] = [];

        for (let i = 0; i < symbols.length; i += 1) {
          const s = symbols[i];
          if (!s) continue;
          if (prevSet.has(s)) continue;
          added.push(s);
        }

        if (added.length) {
          warmupSymbols(added);
        }
      }),
    );

    this.subscriptions.add(
      combineLatest([
        this.eventManager.symbol(),
        this.compareManager.itemsObs(),
        this.mainSeries.asObservable(),
      ]).subscribe(([main, items, serie]) => {
        if (!serie) return;

        const title = items.length ? main : '';
        serie.getLwcSeries().applyOptions({ title });
      }),
    );
  }

  private setupHistoricalDataLoading(): void {
    // todo (не)вызвать loadMoreHistory после проверки на необходимость дозагрузки после смены таймфрейма
    this.mouseEvents.subscribe('visibleLogicalRangeChange', (logicalRange: LogicalRange | null) => {
      this.priceLabelsManager.setVisibleLogicalRange(logicalRange);

      if (!logicalRange) return;
      if (this.currentInterval === Intervals.All) return;

      const needsMoreData = logicalRange.from < HISTORY_LOAD_THRESHOLD;
      if (!needsMoreData) return;

      this.scheduleHistoryBatch();
    });
  }
}

function getIntervalRange(interval: Intervals): { from: number; to: number } {
  const { value, unit } = intervalsToDayjs[interval] as DayjsOffset;

  const from = Math.floor(dayjs().subtract(value, unit).valueOf() / 1000);
  const to = Math.floor(dayjs().valueOf() / 1000);

  return { from, to };
}

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,
    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,
    },
    rightPriceScale: {
      textColor: colors.chartTextPrimary,
      borderVisible: false,
    },
    localization,
  };
}



import { IChartApi, PriceScaleMode, SeriesType } from 'lightweight-charts';
import { flatten } from 'lodash-es';
import { BehaviorSubject, distinctUntilChanged, map, Observable } 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 { MAIN_PANE_INDEX } from '@src/constants';
import { CompareItem, CompareMode, Direction, IndicatorConfig } from '@src/types';
import { IndicatorSnapshot } from '@src/types/snapshot';
import { getPaletteColorFromIndex, normalizeColor, normalizeSymbol } from '@src/utils';

interface CompareEntry {
  key: string;
  symbol: string;
  mode: CompareMode;
  paneIndex: number;
  symbol$: BehaviorSubject<string>;
  entity: Indicator;
}

function makeKey(symbol: string, mode: CompareMode): string {
  return `${symbol}|${mode}`;
}

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

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

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

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[]>([]);

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

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

    if (!initialIndicators) {
      return;
    }

    this.setup(initialIndicators);
  }

  private async setup(initialIndicators: IndicatorSnapshot[]) {
    for (const indicator of initialIndicators) {
      if (indicator.config && indicator.config.label) {
        // условие проверки compare ли это
        const serie = indicator.config.series[0];

        const compareMode =
          serie.seriesOptions?.priceScaleId === Direction.Left
            ? CompareMode.NewScale
            : indicator.config.newPane
              ? CompareMode.NewPane
              : CompareMode.Percentage;

        // eslint-disable-next-line no-await-in-loop
        await this.setSymbolMode(serie.name, indicator.config.label, compareMode, indicator.paneId);
      }
    }
  }

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

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

  public clear(): void {
    const keys = Array.from(this.entries.keys());
    for (let i = 0; i < keys.length; i += 1) {
      this.removeByKey(keys[i]);
    }
    this.applyPolicy();
    this.publish();
  }

  public async setSymbolMode(
    seriesType: SeriesType,
    symbolRaw: string,
    mode: CompareMode,
    paneId?: number,
  ): Promise<void> {
    const symbol = normalizeSymbol(symbolRaw);
    if (!symbol) return;

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

    const key = makeKey(symbol, mode);
    if (this.entries.has(key)) return;

    const symbol$ = new BehaviorSubject(symbol);

    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
        (ind) => ind.getConfig().series?.[0]?.seriesOptions?.color,
      );
      const existIndicators = Array.from(this.indicatorManager.getIndicators().value.values());
      const usedColorsByIndicatorsRaw = existIndicators.map((ind) =>
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        ind.config?.series?.map((serie) => serie.seriesOptions?.color),
      );
      const usedColorsByIndicators = flatten(usedColorsByIndicatorsRaw).filter((color) => color !== undefined);
      const usedColors = usedColorsByCompare.concat(usedColorsByIndicators);

      const config = getDefaultCompareIndicatorConfig(symbol, 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,
        mainSymbol$: symbol$,
        dataSource: this.dataSource,
        associatedPane,
        config: {
          ...config,
          series: [
            {
              ...config.series[0],
              seriesOptions: {
                ...config.series[0]?.seriesOptions,
                priceScaleId: mode === CompareMode.NewScale ? Direction.Left : Direction.Right,
              },
            },
          ],
          newPane: mode === CompareMode.NewPane,
        },
        zIndex,
        onDelete: () => this.removeByKey(key),
        moveUp,
        moveDown,
        paneId: associatedPane.getId(),
      });
    });

    this.entries.set(key, { key, symbol, mode, paneIndex: entity.getPane().getId(), symbol$, entity });

    this.applyPolicy();
    this.publish();

    await this.dataSource.isReady(symbol);
  }

  public removeSymbolMode(symbolRaw: string, mode: CompareMode): void {
    const symbol = normalizeSymbol(symbolRaw);
    if (!symbol) return;

    this.removeByKey(makeKey(symbol, mode));
    this.applyPolicy();
    this.publish();
  }

  public removeSymbol(symbolRaw: string): void {
    const symbol = normalizeSymbol(symbolRaw);
    if (!symbol) return;

    const all = Array.from(this.entries.entries());
    for (let i = 0; i < all.length; i += 1) {
      const [key, entry] = all[i];
      if (entry.symbol === symbol) this.removeByKey(key);
    }

    this.applyPolicy();
    this.publish();
  }

  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(({ symbol, entity, mode }) => ({
      symbol,
      entity,
      mode,
    }));
  }

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

  private removeByKey(key: string): void {
    const entry = this.entries.get(key);
    if (!entry) return;

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

    this.applyPolicy();
    this.publish();
  }

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

    const items: CompareItem[] = [];
    const entities: Indicator[] = [];

    for (let i = 0; i < values.length; i += 1) {
      items.push({ symbol: values[i].symbol, mode: values[i].mode });
      entities.push(values[i].entity);
    }

    this.itemsSubject.next(items);
    this.entitiesSubject.next(entities);
  }

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

    let percentEnabled = false;
    for (let i = 0; i < list.length; i += 1) {
      if (list[i].mode === CompareMode.Percentage) {
        percentEnabled = true;
        break;
      }
    }

    let hasMainNewScale = false;
    for (let i = 0; i < list.length; i += 1) {
      // [0 - в индикаторах compare сущности может быть только одна серия] [1 - entry]
      const paneIndex = Array.from(list[i].entity.getSeriesMap())[0][1].getPane().paneIndex();
      if (paneIndex === MAIN_PANE_INDEX && list[i].mode === CompareMode.NewScale) {
        hasMainNewScale = true;
        break;
      }
    }

    const paneSet = new Set<number>();
    for (let i = 0; i < list.length; i += 1) {
      // [0 - в индикаторах compare сущности может быть только одна серия] [1 - entry]
      const paneIndex = Array.from(list[i].entity.getSeriesMap())[0][1].getPane().paneIndex();

      if (paneIndex !== MAIN_PANE_INDEX) paneSet.add(paneIndex);
    }

    this.chart.applyOptions({
      leftPriceScale: { visible: hasMainNewScale, borderVisible: false },
    });

    this.chart.priceScale(Direction.Right, MAIN_PANE_INDEX).applyOptions({
      visible: true,
      mode: percentEnabled ? PriceScaleMode.Percentage : PriceScaleMode.Normal,
    });

    this.chart.priceScale(Direction.Left, MAIN_PANE_INDEX).applyOptions({
      visible: hasMainNewScale,
      mode: PriceScaleMode.Normal,
      borderVisible: false,
    });

    const panes = Array.from(paneSet.values());
    for (let i = 0; i < panes.length; i += 1) {
      const paneIndex = panes[i];

      this.chart.priceScale(Direction.Right, paneIndex).applyOptions({
        visible: true,
        mode: PriceScaleMode.Normal,
      });

      if (hasMainNewScale) {
        this.chart.priceScale(Direction.Left, paneIndex).applyOptions({
          visible: true,
          mode: PriceScaleMode.Normal,
          borderVisible: false,
        });
      }
    }

    for (let i = 0; i < list.length; i += 1) {
      const entry = list[i];

      // [0 - в индикаторах compare сущности может быть только одна серия] [1 - entry]
      const serie = Array.from(entry.entity.getSeriesMap())[0][1];
      const paneIndex = serie.getPane().paneIndex();

      if (paneIndex !== MAIN_PANE_INDEX) {
        serie.applyOptions({ priceScaleId: Direction.Right });
        continue;
      }

      serie.applyOptions({
        priceScaleId: entry.mode === CompareMode.NewScale ? Direction.Left : Direction.Right,
      });
    }
  }
}



import { IChartApi } from 'lightweight-charts';
import { BehaviorSubject, map, Observable } from 'rxjs';

import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { PaneManager } from '@core/PaneManager';
import { DOMObject } from '@src/core/DOMObject';
import { indicatorsMap as indicatorsConfigMap } from '@src/core/Indicators';
import { ChartTypeOptions, IndicatorConfig } from '@src/types';
import { IndicatorSnapshot } from '@src/types/snapshot';
import { applyNextIndicatorColors, getIndicatorColors } from '@src/utils';

interface SeriesParams {
  eventManager: EventManager;
  dataSource: DataSource;
  lwcChart: IChartApi;
  paneManager: PaneManager;
  DOM: DOMModel;
  initialIndicators?: IndicatorSnapshot[];
  chartOptions?: ChartTypeOptions;
}

export class IndicatorManager {
  private eventManager: EventManager;
  private lwcChart: IChartApi;
  private chartOptions?: ChartTypeOptions;

  private entities$: BehaviorSubject<Indicator[]> = new BehaviorSubject<Indicator[]>([]);
  private indicatorsMap$: BehaviorSubject<Map<string, Indicator>> = new BehaviorSubject(new Map()); // todo: заменить IndicatorsIds ключ на уникальный id индикатора
  private DOM: DOMModel;
  private dataSource: DataSource;
  private paneManager: PaneManager;

  constructor({ eventManager, dataSource, lwcChart, DOM, chartOptions, initialIndicators, paneManager }: SeriesParams) {
    this.eventManager = eventManager;
    this.lwcChart = lwcChart;
    this.chartOptions = chartOptions;
    this.DOM = DOM;
    this.dataSource = dataSource;
    this.paneManager = paneManager;

    this.indicatorsMap$ = new BehaviorSubject<Map<string, Indicator>>(new Map());

    initialIndicators?.forEach((ind) => {
      this.addIndicator(ind);
    });
  }

  public addEntity<T extends Indicator>(
    factory: (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) => T,
  ): T {
    return this.DOM.setEntity(factory);
  }

  public addIndicator(snap: Partial<IndicatorSnapshot>): void {
    if (!snap.indicatorType) {
      console.error('[IndicatorManager] Не был получен тип индиктора');
      return;
    }
    const indicatorsMap = new Map(this.indicatorsMap$.value);

    const id = snap.id ?? `${snap.indicatorType}-${crypto.randomUUID()}`;

    const baseConfig = snap.config ?? (indicatorsConfigMap()[snap.indicatorType] as IndicatorConfig);

    const config = snap.config
      ? baseConfig
      : applyNextIndicatorColors(baseConfig, this.getUsedIndicatorColorsByType(snap.indicatorType));

    const associatedPane =
      snap.paneId !== undefined
        ? (this.paneManager.getPaneById(snap.paneId) ?? this.paneManager.addPane())
        : config?.newPane
          ? this.paneManager.addPane()
          : this.paneManager.getMainPane();

    const indicatorToSet = this.addEntity<Indicator>(
      (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) => {
        return new Indicator({
          id,
          paneId: associatedPane.getId(),
          zIndex,
          onDelete: this.deleteIndicator,
          moveUp,
          moveDown,

          mainSymbol$: this.eventManager.getSymbol(),
          lwcChart: this.lwcChart,
          dataSource: this.dataSource,
          associatedPane,
          config,
          type: snap.indicatorType,
          chartOptions: this.chartOptions,
        });
      },
    );

    indicatorsMap.set(id, indicatorToSet);

    this.indicatorsMap$.next(indicatorsMap);
    this.entities$.next(Array.from(indicatorsMap.values()));
  }

  private getUsedIndicatorColorsByType(indicatorType: IndicatorSnapshot['indicatorType']): string[] {
    const colors: string[] = [];

    this.indicatorsMap$.value.forEach((indicator) => {
      if (indicator.getIndicatorType() !== indicatorType) {
        return;
      }

      colors.push(...getIndicatorColors(indicator.getConfig()));
    });

    return colors;
  }

  public getIndicators() {
    return this.indicatorsMap$;
  }

  public removeEntity(entity: DOMObject): void {
    this.DOM.removeEntity(entity);
  }

  public deleteIndicator = (id: string) => {
    const indicatorsMap = new Map(this.indicatorsMap$.value);
    const entity = indicatorsMap.get(id);

    if (!entity) {
      return;
    }

    this.removeEntity(entity);
    entity.destroy();
    indicatorsMap.delete(id);

    this.indicatorsMap$.next(indicatorsMap);
    this.entities$.next(Array.from(indicatorsMap.values()));
  };

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



import { IChartApi } from 'lightweight-charts';
import { Observable, Subscription } from 'rxjs';

import { DataSource } from '@core/DataSource';
import { DOMObject, DOMObjectParams } from '@core/DOMObject';
import { Pane } from '@core/Pane';

import { indicatorLabelById, indicatorSeriesLabelById, IndicatorsIds } from '@src/constants';
import { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ChartTypeOptions, IndicatorConfig, SettingsValues } from '@src/types';
import { DOMObjectSnapshot, IndicatorSnapshot, ISerializable } from '@src/types/snapshot';

type IIndicator = DOMObject;

export interface IndicatorParams extends DOMObjectParams {
  mainSymbol$: Observable<string>;
  lwcChart: IChartApi;
  dataSource: DataSource;
  associatedPane: Pane;
  config: IndicatorConfig;
  type?: IndicatorsIds;
  chartOptions?: ChartTypeOptions;
}

export class Indicator extends DOMObject implements ISerializable<IndicatorSnapshot> {
  private indicatorType?: IndicatorsIds;
  private series: SeriesStrategies[] = [];
  private seriesMap: Map<string, SeriesStrategies> = new Map();
  private lwcChart: IChartApi;
  private dataSource: DataSource;
  private mainSymbol$: Observable<string>;
  private associatedPane: Pane;
  private config: IndicatorConfig;
  private settings: SettingsValues = {};

  private dataChangeHandlers = new Set<() => void>();
  private seriesSubscriptions = new Subscription();

  constructor({
    id,
    type,
    lwcChart,
    dataSource,
    zIndex,
    onDelete,
    moveUp,
    moveDown,
    mainSymbol$,
    associatedPane,
    paneId,
    config,
  }: IndicatorParams) {
    super({ id, name: config?.label ?? id, zIndex, onDelete, moveUp, moveDown, paneId });
    this.lwcChart = lwcChart;
    this.dataSource = dataSource;
    this.mainSymbol$ = mainSymbol$;
    this.indicatorType = type;
    this.config = config;
    this.name = this.getLabel();

    this.settings = this.getDefaultSettings();

    this.associatedPane = associatedPane;

    this.createSeries();

    this.associatedPane.setIndicator(this.id, this);
  }

  public subscribeDataChange(handler: () => void): Subscription {
    this.dataChangeHandlers.add(handler);

    return new Subscription(() => {
      this.dataChangeHandlers.delete(handler);
    });
  }

  public deletable = (): boolean => {
    return this.associatedPane.isMainPane() || this.associatedPane.isLast();
  };

  public getLabel = () => {
    if (this.config.label) {
      return this.config.label;
    }

    if (this.indicatorType) {
      return indicatorLabelById()[this.indicatorType];
    }

    return this.id;
  };

  public getSeriesLabel(serieName: string): string | undefined {
    if (this.config.seriesLabels?.[serieName]) {
      return this.config.seriesLabels[serieName];
    }

    if (this.indicatorType) {
      return indicatorSeriesLabelById[this.indicatorType]?.[serieName];
    }

    return undefined;
  }

  public getId(): string {
    return this.id;
  }

  public getType(): IndicatorsIds | undefined {
    return this.indicatorType;
  }

  public getPane(): Pane {
    return this.associatedPane;
  }

  public getSeriesMap(): Map<string, SeriesStrategies> {
    return this.seriesMap;
  }

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

  public getIndicatorType(): IndicatorSnapshot['indicatorType'] {
    return this.indicatorType;
  }

  public getSettings(): SettingsValues {
    return { ...this.settings };
  }

  public getSettingsConfig() {
    return this.config.settings ?? [];
  }

  public hasSettings(): boolean {
    return Boolean(this.config.settings?.length);
  }

  public updateSettings(settings: SettingsValues): void {
    this.settings = settings;

    // todo: обновлять данные серий без удаления и повторного создания
    this.destroySeries();
    this.createSeries();

    this.notifyDataChanged();
  }

  public show() {
    this.series.forEach((s) => {
      s.show();
    });
    super.show();
  }

  public hide() {
    this.series.forEach((s) => {
      s.hide();
    });
    super.hide();
  }

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

    return {
      ...domSnap,
      dataSource: this.dataSource,
      indicatorType: this.indicatorType,
      config: this.config,
    };
  }

  public setSnapshot(snap: IndicatorSnapshot): void {}

  // destroy и delete принципиально отличаются!
  // delete вызовет destroy в конце концов. По сути - это destroy с сайд-эффектом в eventManager
  public delete() {
    super.delete();
  }

  // destroy и delete принципиально отличаются!
  public destroy() {
    this.destroySeries();
    this.dataChangeHandlers.clear();
    this.associatedPane.removeIndicator(this.id);
  }

  private createSeries(): void {
    this.config.series.forEach(({ name, id: serieId, dataFormatter, seriesOptions, priceScaleOptions }) => {
      const serie = SeriesFactory.create(name!)({
        lwcChart: this.lwcChart,
        dataSource: this.dataSource,
        customFormatter: dataFormatter
          ? (params) =>
              dataFormatter({
                ...params,
                settings: this.settings,
                indicatorReference: this,
              })
          : undefined,
        seriesOptions,
        priceScaleOptions,
        mainSymbol$: this.mainSymbol$,
        mainSerie$: this.associatedPane.getMainSerie(),
        showSymbolLabel: false,
        paneIndex: this.associatedPane.getId(),
        indicatorReference: this,
      });

      const handleDataChanged = () => {
        this.notifyDataChanged();
      };

      serie.subscribeDataChanged(handleDataChanged);

      this.seriesSubscriptions.add(() => {
        serie.unsubscribeDataChanged(handleDataChanged);
      });

      this.seriesMap.set(serieId, serie);

      this.series.push(serie);
    });
  }

  private destroySeries(): void {
    this.seriesSubscriptions.unsubscribe();
    this.seriesSubscriptions = new Subscription();

    this.series.forEach((s) => {
      s.destroy();
    });

    this.series = [];
    this.seriesMap.clear();
  }

  private notifyDataChanged(): void {
    this.dataChangeHandlers.forEach((handler) => {
      handler();
    });
  }

  private getDefaultSettings(): SettingsValues {
    const settings: SettingsValues = {};

    this.config.settings?.forEach((field) => {
      settings[field.key] = field.defaultValue;
    });

    return settings;
  }
}




import {
  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';

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

import { DataSource } from '@core/DataSource';
import { Indicator } from '@core/Indicator';
import { ChartTypeToCandleData, IndicatorDataFormatter } from '@core/Indicators';
import { Ohlc } from '@core/Legend';
import { MAIN_PANE_INDEX } from '@src/constants';

import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { Candle, Direction } from '@src/types';
import { formatPrice, getPricePrecisionStep, isBarData, isLineData, normalizeSeriesData } from '@src/utils';

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;
  mainSymbol$: Observable<string>;
  mainSerie$: BehaviorSubject<SeriesStrategies | null>;
  customFormatter?: (params: IndicatorDataFormatter<TSeries>) => SeriesDataItemTypeMap<Time>[TSeries][];
  seriesOptions?: SeriesPartialOptionsMap[TSeries];
  priceScaleOptions?: DeepPartial<PriceScaleOptions>;
  showSymbolLabel?: boolean;
  paneIndex?: number;
  indicatorReference?: Indicator;
}

function applyMoscowTimezone(candles: Candle[]): ChartTypeToCandleData['Candlestick'][] {
  // todo this approach is too slow, for timezones impl we should shift timeScale instead of mutating the data
  const offsetMinutes = -180; // utc.time - moscow.time in minutes
  const secondsInMinute = 60;

  return candles
    .filter((c) => typeof c.time === 'number' && Number.isFinite(c.time))
    .map((c) => ({
      ...c,
      time: (c.time as number) - offsetMinutes * secondsInMinute,
    }));
}

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

  protected lwcChart: IChartApi;
  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;

  constructor({
    lwcChart,
    mainSymbol$,
    mainSerie$,
    customFormatter,
    seriesOptions,
    priceScaleOptions,
    showSymbolLabel = true,
    paneIndex,
    indicatorReference,
  }: BaseSeriesParams<TSeries>) {
    this.lwcSeries = this.createSeries({
      chart: lwcChart,
      seriesOptions,
      paneIndex,
      priceScaleOptions,
    });
    this.lwcChart = lwcChart;
    this.customFormatter = customFormatter;
    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();
      if (seriesData.length < 1) return {};

      const dataToFormat = seriesData[seriesData.length - 1];
      const prevBarData = seriesData.length > 1 ? seriesData[seriesData.length - 2] : null;
      return this.formatLegendValues(dataToFormat, prevBarData);
    }

    const dataToFormat = param?.seriesData.get(this.getLwcSeries()) ?? null;
    const prevBarData = this.dataByIndex(param.logical! - 1) ?? null;

    return this.formatLegendValues(dataToFormat, prevBarData);
  };

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

  public hide(): void {
    this.lwcSeries.applyOptions({ ...this.lwcSeries.options(), 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 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 setData(data: SeriesDataItemTypeMap<Time>[TSeries][]): void {
    const normalizedData = normalizeSeriesData(data);
    this.lwcSeries.setData(normalizedData);
  }

  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 last = data.length ? data[data.length - 1] : null;

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

    const lastTime = last.time;
    const nextTime = bar.time;

    const isHist =
      historicalUpdate ?? (typeof lastTime === 'number' && typeof nextTime === 'number' && nextTime < lastTime);

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

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

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

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

    chart.priceScale(mergedOptions.priceScaleId ?? Direction.Right, paneIndex).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: null | BarData | LineData | HistogramData | CustomData,
    prevBar: null | BarData | LineData | HistogramData | CustomData,
  ): Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>;

  protected applyTimezone(data: Candle[]): Candle[] {
    return applyMoscowTimezone(data);
  }

  protected formatData(inputData: Candle[]): SeriesDataItemTypeMap<Time>[TSeries][] {
    const formatter = this.customFormatter;

    const data = this.applyTimezone(inputData);

    if (formatter) {
      const mainSeriesData = (this.mainSerie$.value?.data() ?? []) as unknown as SerieData[];

      if (data.length === 1) {
        return formatter({
          mainSeriesData,
          selfData: this.data() as unknown as ChartTypeToCandleData[TSeries][],
          candle: this.formatMainSerie(data)[0] as unknown as SerieData,
          indicatorReference: this.indicatorReference ?? undefined,
        });
      }

      return formatter({
        mainSeriesData,
        indicatorReference: this.indicatorReference ?? undefined,
        selfData: this.data() as unknown as ChartTypeToCandleData[TSeries][],
      });
    }

    return this.formatMainSerie(data);
  }

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

    this.subscriptions.add(
      this.mainSymbol$.pipe(distinctUntilChanged()).subscribe((symbol) => {
        this.lwcSeries.applyOptions({
          // todo: на каждый апдейт dataSource сеттим options. Не оптимально
          title: this.showSymbolLabel ? symbol : '',
          priceFormat: {
            type: 'custom',
            minMove,
            formatter: (price: number) => formatPrice(price),
          },
        });

        this.dataSub?.unsubscribe();
        this.realtimeSub?.unsubscribe();

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

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

export function calcCandleChange(
  prev: BarData | LineData | HistogramData | CustomData | null,
  current: BarData | LineData | HistogramData | CustomData | null,
): (Ohlc & { customValues?: Record<string, any> }) | 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,
    };
  }

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

    return {
      time: current.time,
      value: current.value,
      high: current.customValues?.high as number,
      low: current.customValues?.low as number,
      absoluteChange,
      percentageChange,
      customValues: current.customValues,
    };
  }

  return null;
}



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 priceAxisPaneView: PriceAxisLabelsPaneView;

  private readonly priceAxisPaneViewList: readonly IPrimitivePaneView[];

  private labels: readonly PriceAxisLabel[] = [];

  private requestUpdate: (() => void) | null = null;

  constructor() {
    this.priceAxisPaneView = new PriceAxisLabelsPaneView(() => this.labels);

    this.priceAxisPaneViewList = [this.priceAxisPaneView];
  }

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

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

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

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



{
  "name": "moex-chart",
  "version": "0.1.0",
  "description": "",
  "type": "module",
  "main": "dist/index.cjs",
  "types": "types/index.d.ts",
  "files": [
    "dist",
    "types"
  ],
  "sideEffects": [
    "**/*css",
    "**/*.scss"
  ],
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "types": "./types/index.d.ts"
    },
    "./package.json": "./package.json",
    "./dist/styles.css": {
      "import": "./dist/styles.css",
      "require": "./dist/styles.css"
    }
  },
  "scripts": {
    "postbuild:storybook": "sed -i 's#<head>#<head>\\n<base href='/storybook/'>#' ./storybook-static/index.html ./storybook-static/iframe.html",
    "clean:dist": "powershell -NoProfile -Command \"if (Test-Path dist) { Remove-Item -Recurse -Force dist }\"",
    "clean:types": "powershell -NoProfile -Command \"if (Test-Path types) { Remove-Item -Recurse -Force types }\"",
    "build:lib:js": "webpack --config webpack.lib.config.cjs",
    "build:lib:types": "tsc --project tsconfig.types.json && tsc-alias -p tsconfig.types.json",
    "build:lib": "npm run clean:dist && npm run clean:types && npm run build:lib:js && npm run build:lib:types",
    "test": "jest",
    "coverage": "jest --coverage",
    "lint:fix": "npx prettier src --write && npx eslint src --fix",
    "lint": "npx eslint src",
    "prettier-check": "npx prettier src --check",
    "prettier-fix": "npx prettier src --write",
    "prepare": "husky",
    "storybook": "storybook dev -p 6006",
    "storybook-local": "cross-env buildType=local storybook dev -p 6006",
    "start": "npm run storybook-local",
    "build-storybook": "storybook build",
    "build-storybook-local": "cross-env buildType=local storybook build",
    "typecheck": "npx tsc -p tsconfig.json --noEmit",
    "typecheck:watch": "npx tsc -p tsconfig.json --noEmit -w"
  },
  "keywords": [],
  "author": "MOEX",
  "license": "UNLICENSED",
  "devDependencies": {
    "@babel/core": "^7.21.0",
    "@babel/preset-env": "^7.28.3",
    "@babel/preset-react": "^7.27.1",
    "@babel/preset-typescript": "^7.27.1",
    "@pmmmwh/react-refresh-webpack-plugin": "^0.5.10",
    "@storybook/addon-actions": "^7.6.13",
    "@storybook/addon-controls": "^7.6.13",
    "@storybook/addon-docs": "^7.6.13",
    "@storybook/addon-essentials": "^7.6.13",
    "@storybook/addon-interactions": "^7.6.13",
    "@storybook/addon-links": "^7.6.13",
    "@storybook/builder-webpack5": "^7.6.13",
    "@storybook/components": "^7.6.13",
    "@storybook/manager-webpack5": "^6.5.16",
    "@storybook/preset-scss": "^1.0.3",
    "@storybook/react": "^7.6.13",
    "@storybook/react-webpack5": "^7.6.13",
    "@storybook/testing-library": "^0.0.13",
    "@testing-library/dom": "^9.3.4",
    "@testing-library/jest-dom": "^5.17.0",
    "@testing-library/react": "^13.4.0",
    "@types/jest": "^27.5.2",
    "@types/lodash-es": "^4.17.12",
    "@types/markdown-it": "^14.0.1",
    "@types/markdown-it-link-attributes": "^3.0.5",
    "@types/react": "^18.0.28",
    "@types/react-dom": "^18.0.10",
    "@types/react-window": "^1.8.5",
    "@types/sanitize-html": "^2.11.0",
    "@typescript-eslint/eslint-plugin": "^5.52.0",
    "@typescript-eslint/parser": "^5.52.0",
    "autoprefixer": "^10.4.21",
    "babel-loader": "^8.3.0",
    "copy-webpack-plugin": "^11.0.0",
    "cross-env": "^7.0.3",
    "css-loader": "^6.7.3",
    "css-minimizer-webpack-plugin": "^3.4.1",
    "dotenv-webpack": "^8.0.1",
    "eslint": "^8.34.0",
    "eslint-config-airbnb": "^19.0.4",
    "eslint-config-prettier": "^8.6.0",
    "eslint-import-resolver-typescript": "^3.10.1",
    "eslint-import-resolver-webpack": "^0.13.2",
    "eslint-plugin-eslint-comments": "^3.2.0",
    "eslint-plugin-import": "^2.31.0",
    "eslint-plugin-jest": "^25.7.0",
    "eslint-plugin-jsx-a11y": "^6.7.1",
    "eslint-plugin-prettier": "^4.2.1",
    "eslint-plugin-react": "^7.32.2",
    "eslint-plugin-react-hooks": "^4.6.0",
    "file-loader": "^6.2.0",
    "html-webpack-plugin": "^5.5.0",
    "husky": "^9.1.5",
    "jest": "^29.7.0",
    "jest-environment-jsdom": "^29.5.0",
    "json5": "^2.2.3",
    "mini-css-extract-plugin": "^2.7.2",
    "postcss": "^8.5.6",
    "postcss-loader": "^4.3.0",
    "prettier": "^3.3.3",
    "react": "^18.2.0",
    "react-docgen-typescript-plugin": "^0.5.1",
    "react-dom": "^18.2.0",
    "react-refresh": "^0.14.0",
    "react-refresh-typescript": "^2.0.8",
    "sass": "^1.58.1",
    "sass-loader": "^13.2.0",
    "storybook": "^7.6.13",
    "storybook-addon-sass-postcss": "^0.1.3",
    "style-loader": "^3.3.1",
    "terser-webpack-plugin": "^5.3.6",
    "ts-jest": "^29.1.5",
    "ts-loader": "^9.4.2",
    "ts-node": "10.9.1",
    "tsc-alias": "^1.8.16",
    "type-fest": "^3.6.1",
    "typescript": "^5.5.3",
    "typescript-plugin-css-modules": "^4.1.1",
    "webpack": "^5.75.0",
    "webpack-cli": "^5.1.4",
    "webpack-dev-server": "^4.11.1"
  },
  "dependencies": {
    "@dnd-kit/core": "^6.1.0",
    "@dnd-kit/modifiers": "^7.0.0",
    "@dnd-kit/sortable": "^8.0.0",
    "@dnd-kit/utilities": "^3.2.2",
    "@stomp/stompjs": "^7.1.1",
    "classnames": "^2.3.2",
    "dayjs": "^1.11.7",
    "dotenv": "^16.4.7",
    "exchange-elements": "^0.0.257",
    "fancy-canvas": "2.1.0",
    "lightweight-charts": "^5.0.8",
    "lodash-es": "^4.17.21",
    "rxjs": "^7.8.2",
    "uuid": "^11.0.3"
  },
  "peerDependencies": {
    "react": "18.2.0",
    "react-dom": "18.2.0"
  },
  "overrides": {
    "@storybook/mdx2-csf": "1.0.0"
  },
  "lint-staged": {
    "*.{js,jsx,ts,tsx,css,scss,md,html}": [
      "npm run lint:fix"
    ]
  }
}



import { CandlestickSeriesStrategy, LineSeriesStrategy } from '@core';

import { BaseSeriesParams } from '@core/Series/BaseSeries';
import { BarSeriesStrategy } from '@src/core/Series/BarSeriesStrategy';

import { HistogramSeriesStrategy } from '@src/core/Series/HistogramSeriesStrategy';
import { ISeries } from '@src/modules/series-strategies/ISeries';
import { ChartSeriesType } from '@src/types';

export type SeriesStrategies =
  | CandlestickSeriesStrategy
  | LineSeriesStrategy
  | HistogramSeriesStrategy
  | BarSeriesStrategy
  | ISeries<'Baseline'>
  | ISeries<'Area'>
  | ISeries<'Custom'>;
/**
x * Фабрика для создания стратегий серий
 * Реализует паттерн Factory для создания нужной стратегии по типу графика
 */

export class SeriesFactory {
  static create(
    type: ChartSeriesType,
  ):
    | ((params: BaseSeriesParams<'Candlestick'>) => CandlestickSeriesStrategy)
    | ((params: BaseSeriesParams<'Histogram'>) => HistogramSeriesStrategy)
    | ((params: BaseSeriesParams<'Line'>) => LineSeriesStrategy)
    | ((params: BaseSeriesParams<'Bar'>) => BarSeriesStrategy) {
    if (type === 'Candlestick') {
      return ((params) => new CandlestickSeriesStrategy(params)) as (
        params: BaseSeriesParams<'Candlestick'>,
      ) => CandlestickSeriesStrategy;
    }
    if (type === 'Histogram') {
      return ((params) => new HistogramSeriesStrategy(params)) as (
        params: BaseSeriesParams<'Histogram'>,
      ) => HistogramSeriesStrategy;
    }
    if (type === 'Line') {
      return ((params) => new LineSeriesStrategy(params)) as (params: BaseSeriesParams<'Line'>) => LineSeriesStrategy;
    }
    if (type === 'Bar') {
      return ((params) => new BarSeriesStrategy(params)) as (params: BaseSeriesParams<'Bar'>) => BarSeriesStrategy;
    }
    throw new Error(`Unsupported chart type: ${type}`);
  }
}