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


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

import { flatten } from 'lodash-es';
import { BehaviorSubject, distinctUntilChanged, map, Observable, 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 } from '@src/types';
import { IndicatorSnapshot } from '@src/types/snapshot';
import { createFallbackColor, normalizeColor, normalizeSymbol } from '@src/utils';

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

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

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 hasPercentageComparison = false;
  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 {
    const keys = Array.from(this.entries.keys());

    for (let index = 0; index < keys.length; index += 1) {
      this.removeEntry(keys[index]);
    }

    this.commitEntriesChange();
  }

  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() && !this.restoringInitialIndicators) {
      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
        (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(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: () => {
          if (this.removeEntry(key)) {
            this.commitEntriesChange();
          }
        },
        moveUp,
        moveDown,
        paneId: associatedPane.getId(),
      });
    });

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

    this.commitEntriesChange();

    await this.dataSource.isReady(symbol);
  }

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

    if (!symbol) {
      return;
    }

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

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

    if (!symbol) {
      return;
    }

    const entries = Array.from(this.entries.entries());
    let removed = false;

    for (let index = 0; index < entries.length; index += 1) {
      const [key, entry] = entries[index];

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

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

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

    try {
      for (const indicator of initialIndicators) {
        if (indicator.indicatorType !== undefined) {
          continue;
        }

        if (!indicator.config?.label) {
          continue;
        }

        const series = indicator.config.series[0];

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

        // eslint-disable-next-line no-await-in-loop
        await this.setSymbolMode(series.name, indicator.config.label, compareMode, indicator.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();
    entry.symbol$.complete();

    return true;
  }

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

  private publish(): void {
    const values = Array.from(this.entries.values());
    const items: CompareItem[] = [];
    const entities: Indicator[] = [];

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

      entities.push(values[index].entity);
    }

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

  private syncPercentageMode(priceScale: PriceScale, hasPercentageComparison: boolean): void {
    if (this.restoringInitialIndicators) {
      this.hasPercentageComparison = hasPercentageComparison;
      return;
    }

    if (this.hasPercentageComparison === hasPercentageComparison) {
      return;
    }

    const hadPercentageComparison = this.hasPercentageComparison;

    this.hasPercentageComparison = hasPercentageComparison;

    if (hasPercentageComparison && !hadPercentageComparison) {
      priceScale.setMode(PriceScaleMode.Percentage);
      return;
    }

    if (!hasPercentageComparison && hadPercentageComparison && priceScale.getMode() === PriceScaleMode.Percentage) {
      priceScale.setMode(PriceScaleMode.Normal);
    }
  }

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

    let hasPercentageComparison = false;

    const leftScalePaneIds = new Set<number>();
    const additionalPaneIds = new Set<number>();

    for (let index = 0; index < entries.length; index += 1) {
      const entry = entries[index];
      const pane = entry.entity.getPane();

      if (entry.mode === CompareMode.Percentage) {
        hasPercentageComparison = true;
      }

      if (entry.mode === CompareMode.NewScale) {
        leftScalePaneIds.add(pane.getId());
      }

      if (!pane.isMainPane()) {
        additionalPaneIds.add(pane.getId());
      }
    }

    for (let index = 0; index < entries.length; index += 1) {
      const entry = entries[index];
      const pane = entry.entity.getPane();

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

      if (!series) {
        continue;
      }

      series.applyOptions({
        priceScaleId: pane.isMainPane() && entry.mode === CompareMode.NewScale ? Direction.Left : Direction.Right,
      });
    }

    const mainLeftPriceScale = mainPane.getPriceScale(Direction.Left);
    const mainRightPriceScale = mainPane.getPriceScale(Direction.Right);

    mainLeftPriceScale.setVisible(leftScalePaneIds.has(mainPane.getId()));
    mainRightPriceScale.setVisible(true);

    this.syncPercentageMode(mainRightPriceScale, hasPercentageComparison);

    const additionalPaneIdsList = Array.from(additionalPaneIds);

    for (let index = 0; index < additionalPaneIdsList.length; index += 1) {
      const pane = this.paneManager.getPaneById(additionalPaneIdsList[index]);

      if (!pane) {
        continue;
      }

      pane.getPriceScale(Direction.Right).setVisible(true);
      pane.getPriceScale(Direction.Left).setVisible(leftScalePaneIds.has(pane.getId()));
    }

    this.paneManager.refreshPriceScaleControls();
    this.paneManager.invalidatePriceAxisLabels();
  }
}

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

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

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




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 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.paneIndex(),
        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 { IChartApi, IPaneApi, IPriceScaleApi, PriceScaleMode, Time } from 'lightweight-charts';

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

import type { PriceScaleSide, PriceScaleSnapshot } from '@src/types/snapshot';

interface PriceScaleParams {
  paneId: number;
  side: PriceScaleSide;
  chart: IChartApi;
  pane: IPaneApi<Time>;
  initialMode?: PriceScaleMode;
  hasVisibleSeriesData: () => boolean;
}

const PRICE_SCALE_MINIMUM_WIDTH = 72;

export class PriceScale {
  public readonly paneId: number;
  public readonly side: PriceScaleSide;

  private readonly chart: IChartApi;
  private readonly pane: IPaneApi<Time>;
  private readonly hasVisibleSeriesDataCallback: () => boolean;

  private mode: PriceScaleMode;
  private autoScaleEnabled = true;
  private visible: boolean;
  private initialized = false;

  constructor({
    paneId,
    side,
    chart,
    pane,
    initialMode = PriceScaleMode.Normal,
    hasVisibleSeriesData,
  }: PriceScaleParams) {
    this.paneId = paneId;
    this.side = side;
    this.chart = chart;
    this.pane = pane;
    this.mode = initialMode;
    this.visible = side === Direction.Right;
    this.hasVisibleSeriesDataCallback = hasVisibleSeriesData;
  }

  public initialize(): void {
    if (this.initialized) {
      return;
    }

    const priceScale = this.getPriceScaleApi();
    const options = priceScale.options();

    this.autoScaleEnabled = options.autoScale ?? true;
    this.initialized = true;

    priceScale.applyOptions({
      mode: this.mode,
      autoScale: this.autoScaleEnabled,
      visible: this.visible,
      borderVisible: false,
      minimumWidth: Math.max(options.minimumWidth ?? 0, PRICE_SCALE_MINIMUM_WIDTH),
    });
  }

  public getMode(): PriceScaleMode {
    return this.mode;
  }

  public setMode(mode: PriceScaleMode): void {
    if (this.mode === mode) {
      return;
    }

    this.mode = mode;

    if (!this.initialized) {
      return;
    }

    this.getPriceScaleApi().applyOptions({
      mode,
      autoScale: this.autoScaleEnabled,
    });
  }

  public toggleLogarithmic(): void {
    const nextMode = this.mode === PriceScaleMode.Logarithmic ? PriceScaleMode.Normal : PriceScaleMode.Logarithmic;

    this.setMode(nextMode);
  }

  public isAutoScaleEnabled(): boolean {
    return this.autoScaleEnabled;
  }

  public toggleAutoScale(): void {
    this.autoScaleEnabled = !this.autoScaleEnabled;

    if (this.initialized) {
      this.getPriceScaleApi().setAutoScale(this.autoScaleEnabled);
    }
  }

  public enableAutoScale(): void {
    if (this.autoScaleEnabled) {
      return;
    }

    this.autoScaleEnabled = true;

    if (this.initialized) {
      this.getPriceScaleApi().setAutoScale(true);
    }
  }

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

  public setVisible(visible: boolean): void {
    const visibilityChanged = this.visible !== visible;

    this.visible = visible;

    if (!this.initialized || !visibilityChanged) {
      return;
    }

    this.getPriceScaleApi().applyOptions({
      visible,
      borderVisible: false,
    });
  }

  public getWidth(): number {
    if (!this.initialized || !this.visible || this.pane.paneIndex() < 0) {
      return 0;
    }

    try {
      return this.getPriceScaleApi().width();
    } catch {
      return 0;
    }
  }

  public hasVisibleSeriesData(): boolean {
    return this.hasVisibleSeriesDataCallback();
  }

  public getSnapshot(): PriceScaleSnapshot {
    return {
      side: this.side,
      mode: this.mode,
    };
  }

  private getPriceScaleApi(): IPriceScaleApi {
    return this.chart.priceScale(this.side, this.pane.paneIndex());
  }
}



import { PriceScaleMode } from 'lightweight-charts';

import { PriceScaleControls as PriceScaleControlsView } from '@components/PriceScaleControls';
import { ReactRenderer } from '@core/ReactRenderer';
import { Direction } from '@src/types';

import { PriceScale } from './PriceScale';

interface PriceScaleControlsParams {
  chartContainer: HTMLElement;
  leftPriceScale: PriceScale;
  rightPriceScale: PriceScale;
  onPriceScaleChange: () => void;
}

export class PriceScaleControls {
  private readonly chartContainer: HTMLElement;
  private readonly leftPriceScale: PriceScale;
  private readonly rightPriceScale: PriceScale;
  private readonly onPriceScaleChange: () => void;
  private readonly container: HTMLElement;
  private readonly renderer: ReactRenderer;

  private paneElement: HTMLElement | null = null;
  private hoveredPriceScale: PriceScale | null = null;

  constructor({ chartContainer, leftPriceScale, rightPriceScale, onPriceScaleChange }: PriceScaleControlsParams) {
    this.chartContainer = chartContainer;
    this.leftPriceScale = leftPriceScale;
    this.rightPriceScale = rightPriceScale;
    this.onPriceScaleChange = onPriceScaleChange;

    this.container = document.createElement('div');
    this.container.className = 'moex-price-scale-controls';
    this.container.style.position = 'absolute';
    this.container.style.zIndex = '10';
    this.container.style.pointerEvents = 'none';

    this.renderer = new ReactRenderer(this.container);
  }

  public mount(paneElement: HTMLElement): void {
    if (this.paneElement === paneElement && this.container.parentElement === this.chartContainer) {
      this.refresh();
      return;
    }

    this.unmount();

    this.paneElement = paneElement;

    this.chartContainer.appendChild(this.container);
    this.chartContainer.addEventListener('pointermove', this.handlePointerMove);
    this.chartContainer.addEventListener('pointerleave', this.handlePointerLeave);

    this.refresh();
  }

  public refresh(): void {
    if (!this.hoveredPriceScale) {
      this.hide();
      return;
    }

    this.render(this.hoveredPriceScale);
  }

  public destroy(): void {
    this.unmount();
    this.renderer.destroy();
    this.container.remove();
  }

  private handlePointerMove = (event: PointerEvent): void => {
    if (!this.paneElement) {
      return;
    }

    const chartRect = this.chartContainer.getBoundingClientRect();
    const paneRect = this.paneElement.getBoundingClientRect();

    const isInsidePane = event.clientY >= paneRect.top && event.clientY < paneRect.bottom;

    const nextPriceScale = isInsidePane
      ? this.getHoveredPriceScale(event.clientX - chartRect.left, chartRect.width)
      : null;

    if (this.hoveredPriceScale === nextPriceScale) {
      if (nextPriceScale) {
        this.updatePosition(nextPriceScale);
      }

      return;
    }

    this.hoveredPriceScale = nextPriceScale;
    this.refresh();
  };

  private handlePointerLeave = (): void => {
    this.hoveredPriceScale = null;
    this.refresh();
  };

  private render(priceScale: PriceScale): void {
    const width = this.getAvailableWidth(priceScale);

    if (width === 0) {
      this.hide();
      return;
    }

    this.updatePosition(priceScale, width);

    this.renderer.renderComponent(
      <PriceScaleControlsView
        isAutoScale={priceScale.isAutoScaleEnabled()}
        isLogarithmic={priceScale.getMode() === PriceScaleMode.Logarithmic}
        onToggleAutoScale={() => {
          priceScale.toggleAutoScale();
          this.refresh();
          this.onPriceScaleChange();
        }}
        onToggleLogarithmic={() => {
          priceScale.toggleLogarithmic();
          this.refresh();
          this.onPriceScaleChange();
        }}
      />,
    );

    this.container.classList.add('moex-price-scale-controls_visible');
  }

  private hide(): void {
    this.container.classList.remove('moex-price-scale-controls_visible');
  }

  private getHoveredPriceScale(pointerX: number, chartWidth: number): PriceScale | null {
    const leftWidth = this.getAvailableWidth(this.leftPriceScale);

    if (leftWidth > 0 && pointerX >= 0 && pointerX <= leftWidth) {
      return this.leftPriceScale;
    }

    const rightWidth = this.getAvailableWidth(this.rightPriceScale);

    if (rightWidth > 0 && pointerX >= chartWidth - rightWidth && pointerX <= chartWidth) {
      return this.rightPriceScale;
    }

    return null;
  }

  private getAvailableWidth(priceScale: PriceScale): number {
    if (!priceScale.isVisible() || !priceScale.hasVisibleSeriesData()) {
      return 0;
    }

    return priceScale.getWidth();
  }

  private updatePosition(priceScale: PriceScale, width = priceScale.getWidth()): void {
    if (!this.paneElement || width === 0) {
      return;
    }

    const chartRect = this.chartContainer.getBoundingClientRect();
    const paneRect = this.paneElement.getBoundingClientRect();

    const bottomOffset = Math.max(0, chartRect.bottom - paneRect.bottom);

    this.container.style.width = `${width}px`;
    this.container.style.bottom = `${bottomOffset}px`;

    if (priceScale.side === Direction.Left) {
      this.container.style.left = '0';
      this.container.style.right = '';
      return;
    }

    this.container.style.left = '';
    this.container.style.right = '0';
  }

  private unmount(): void {
    this.chartContainer.removeEventListener('pointermove', this.handlePointerMove);
    this.chartContainer.removeEventListener('pointerleave', this.handlePointerLeave);

    this.paneElement = null;
    this.hoveredPriceScale = null;

    this.hide();
  }
}



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

import { BehaviorSubject, Subscription } from 'rxjs';

import { ChartTooltip } from '@components/ChartTooltip';
import { LegendComponent } from '@components/Legend';
import { ChartMouseEvents } from '@core/ChartMouseEvents';
import { ContainerManager } from '@core/ContainerManager';
import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { DrawingsManager, DrawingsManagerSnapshot } from '@core/DrawingsManager';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { Legend } from '@core/Legend';
import { PriceScale, PriceScaleControls } from '@core/PriceScale';
import { ReactRenderer } from '@core/ReactRenderer';
import { TooltipService } from '@core/Tooltip';
import { UIRenderer } from '@core/UIRenderer';
import { EntitySettingsModal } from '@src/components/EntitySettingsModal';
import { DrawingsNames, indicatorLabelById, MAIN_PANE_INDEX } from '@src/constants';
import { ModalRenderer } from '@src/core/ModalRenderer';
import { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { t } from '@src/translations';
import { Direction, OHLCConfig, TooltipConfig } from '@src/types';
import {
  DOMObjectSnapshot,
  IndicatorSnapshot,
  ISerializable,
  PaneSnapshot,
  PriceScaleSide,
  PriceScaleSnapshot,
} from '@src/types/snapshot';
import { ensureDefined } from '@src/utils';

export interface PaneParams {
  id: number;
  lwcChart: IChartApi;
  eventManager: EventManager;
  DOM: DOMModel;
  isMainPane: boolean;
  ohlcConfig: OHLCConfig;
  dataSource: DataSource | null; // todo: deal with dataSource. На каких то пейнах он нужен, на каких то нет
  basedOn?: Pane; // Pane на котором находится главная серия, или серия, по которой строятся серии на текущем пейне
  subscribeChartEvent: ChartMouseEvents['subscribe'];
  tooltipConfig: TooltipConfig;
  onDelete: () => void;
  chartContainer: HTMLElement;
  modalRenderer: ModalRenderer;
  initialPriceScales?: PriceScaleSnapshot[];
  onPriceScaleStateChange: () => void;
}

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

export class Pane implements ISerializable<PaneSnapshot> {
  private readonly id: number;
  private readonly isMain: boolean;
  private mainSeries = new BehaviorSubject<SeriesStrategies | null>(null); // Main Series. Exists in a single copy
  private legend!: Legend;
  private tooltip: TooltipService | undefined;
  private readonly indicatorsMap = new BehaviorSubject<Map<string, Indicator>>(new Map());
  private readonly lwcPane: IPaneApi<Time>;
  private readonly lwcChart: IChartApi;
  private readonly eventManager: EventManager;
  private readonly drawingsManager: DrawingsManager;
  private legendContainer!: HTMLElement;
  private paneOverlayContainer!: HTMLElement;
  private legendRenderer!: UIRenderer;
  private tooltipRenderer: UIRenderer | undefined;
  private readonly modalRenderer: ModalRenderer;
  private readonly leftPriceScale: PriceScale;
  private readonly rightPriceScale: PriceScale;
  private readonly priceScaleControls: PriceScaleControls;
  private mainSerieSub?: Subscription;
  private readonly subscribeChartEvent: ChartMouseEvents['subscribe'];
  private readonly onDelete: () => void;
  private readonly onPriceScaleStateChange: () => void;
  private readonly subscriptions = new Subscription();
  private paneContainerSyncFrameId: number | null = null;

  constructor({
    lwcChart,
    eventManager,
    dataSource,
    DOM,
    isMainPane,
    ohlcConfig,
    id,
    basedOn,
    subscribeChartEvent,
    tooltipConfig,
    onDelete,
    chartContainer,
    modalRenderer,
    initialPriceScales = [],
    onPriceScaleStateChange,
  }: PaneParams) {
    this.onDelete = onDelete;
    this.onPriceScaleStateChange = onPriceScaleStateChange;
    this.eventManager = eventManager;
    this.lwcChart = lwcChart;
    this.modalRenderer = modalRenderer;
    this.subscribeChartEvent = subscribeChartEvent;
    this.isMain = isMainPane;
    this.id = id;

    if (isMainPane) {
      this.lwcPane = this.lwcChart.panes()[MAIN_PANE_INDEX];
    } else {
      this.lwcPane = this.lwcChart.addPane(true);
    }

    this.leftPriceScale = this.createPriceScale(Direction.Left, initialPriceScales);
    this.rightPriceScale = this.createPriceScale(Direction.Right, initialPriceScales);

    this.priceScaleControls = new PriceScaleControls({
      chartContainer,
      leftPriceScale: this.leftPriceScale,
      rightPriceScale: this.rightPriceScale,
      onPriceScaleChange: this.handlePriceScaleStateChange,
    });

    this.initializeLegend({ ohlcConfig });

    this.tooltip = new TooltipService({
      config: tooltipConfig,
      legend: this.legend,
      paneOverlayContainer: this.paneOverlayContainer,
    });

    this.tooltipRenderer = new ReactRenderer(this.paneOverlayContainer);

    this.tooltipRenderer.renderComponent(
      <ChartTooltip
        formatObs={this.eventManager.getChartOptionsModel()}
        timeframeObs={this.eventManager.getTimeframeObs()}
        viewModel={this.tooltip.getTooltipViewModel()}
        // ohlcConfig={this.legend.getConfig()}
        ohlcConfig={ohlcConfig}
        tooltipConfig={this.tooltip.getConfig()}
      />,
    );

    if (dataSource) {
      this.initializeMainSerie({ lwcChart, dataSource });
    } else if (basedOn) {
      this.mainSeries = basedOn.getMainSerie();
    } else {
      console.error('[Pane]: There is no any mainSerie for new pane');
    }

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

    this.subscriptions.add(
      this.drawingsManager.entities().subscribe((drawings) => {
        const hasRuler = drawings.some((drawing) => drawing.getDrawingName() === DrawingsNames.ruler);

        this.legendContainer.style.display = hasRuler ? 'none' : '';
      }),
    );
  }

  public isMainPane = () => {
    return this.isMain;
  };

  public getDrawingsSnapshot(): DrawingsManagerSnapshot {
    return this.drawingsManager.getSnapshot();
  }

  public setDrawingsSnapshot(snapshot: DrawingsManagerSnapshot): void {
    this.drawingsManager.setSnapshot(snapshot);
  }

  public getMainSerie = () => {
    return this.mainSeries;
  };

  public getId = () => {
    return this.id;
  };

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

  public getPriceScale(side: PriceScaleSide): PriceScale {
    return side === Direction.Left ? this.leftPriceScale : this.rightPriceScale;
  }

  public setIndicator(indicatorId: string, indicator: Indicator): void {
    const map = this.indicatorsMap.value;

    map.set(indicatorId, indicator);
    this.indicatorsMap.next(map);
    this.priceScaleControls.refresh();
  }

  public removeIndicator(indicatorId: string): void {
    const map = this.indicatorsMap.value;

    map.delete(indicatorId);
    this.indicatorsMap.next(map);
    this.priceScaleControls.refresh();

    if (map.size === 0 && !this.isMain) {
      this.onDelete();
    }
  }

  public getDrawingManager(): DrawingsManager {
    return this.drawingsManager;
  }

  public schedulePaneContainerSync(): void {
    if (this.paneContainerSyncFrameId !== null) {
      return;
    }

    this.paneContainerSyncFrameId = requestAnimationFrame(() => {
      this.paneContainerSyncFrameId = null;
      this.syncPaneContainers();
    });
  }

  public refreshPriceScaleControls(): void {
    this.priceScaleControls.refresh();
  }

  public resetPriceScalesAutoScale(): void {
    this.leftPriceScale.enableAutoScale();
    this.rightPriceScale.enableAutoScale();
    this.handlePriceScaleStateChange();
  }

  public getSnapshot(): PaneSnapshot {
    const indicators: (DOMObjectSnapshot & IndicatorSnapshot)[] = [];

    this.indicatorsMap.value.forEach((indicator) => {
      indicators.push(indicator.getSnapshot());
    });

    return {
      isMain: this.isMain,
      id: this.id,
      indicators,
      drawings: this.getDrawingsSnapshot(),
      priceScales: [this.leftPriceScale.getSnapshot(), this.rightPriceScale.getSnapshot()],
    };
  }

  public destroy(): void {
    if (this.paneContainerSyncFrameId !== null) {
      cancelAnimationFrame(this.paneContainerSyncFrameId);
      this.paneContainerSyncFrameId = null;
    }

    this.subscriptions.unsubscribe();
    this.tooltip?.destroy();
    this.legend?.destroy();
    this.legendRenderer.destroy();
    this.tooltipRenderer?.destroy();
    this.priceScaleControls.destroy();
    this.legendContainer.remove();
    this.paneOverlayContainer.remove();
    this.indicatorsMap.complete();
    this.mainSerieSub?.unsubscribe();

    if (this.isMain) {
      this.mainSeries.value?.destroy();
      this.mainSeries.complete();
    }
  }

  private createPriceScale(side: PriceScaleSide, initialPriceScales: PriceScaleSnapshot[]): PriceScale {
    const initialMode =
      initialPriceScales.find((priceScaleSnapshot) => priceScaleSnapshot.side === side)?.mode ?? PriceScaleMode.Normal;

    return new PriceScale({
      paneId: this.id,
      side,
      chart: this.lwcChart,
      pane: this.lwcPane,
      initialMode,
      hasVisibleSeriesData: () => this.hasVisibleSeriesData(side),
    });
  }

  private hasVisibleSeriesData(side: PriceScaleSide): boolean {
    const mainSeries = this.mainSeries.value;

    if (this.isMain && side === Direction.Right && mainSeries?.isVisible() && mainSeries.data().length > 0) {
      return true;
    }

    const indicators = Array.from(this.indicatorsMap.value.values());

    for (let indicatorIndex = 0; indicatorIndex < indicators.length; indicatorIndex += 1) {
      const series = Array.from(indicators[indicatorIndex].getSeriesMap().values());

      for (let seriesIndex = 0; seriesIndex < series.length; seriesIndex += 1) {
        const currentSeries = series[seriesIndex];
        const options = currentSeries.options();
        const seriesPriceScaleSide = options.priceScaleId ?? Direction.Right;

        if (currentSeries.isVisible() && currentSeries.data().length > 0 && seriesPriceScaleSide === side) {
          return true;
        }
      }
    }

    return false;
  }

  private handlePriceScaleStateChange = (): void => {
    this.priceScaleControls.refresh();
    this.onPriceScaleStateChange();
  };

  private initializeLegend({ ohlcConfig }: { ohlcConfig: OHLCConfig }): void {
    const { legendContainer, paneOverlayContainer } = ContainerManager.createPaneContainers();

    this.legendContainer = legendContainer;
    this.paneOverlayContainer = paneOverlayContainer;
    this.legendRenderer = new ReactRenderer(legendContainer);

    this.schedulePaneContainerSync();

    // todo: переписать код ниже под логику пейнов
    // /*
    //   Внутри lightweight-chart DOM построен как таблица из 3 td
    //   [0] left priceScale, [1] center chart, [2] right priceScale
    //   Кладём легенду в td[1] и тогда легенда сама будет адаптироваться при изменении ширины шкал
    // */
    // requestAnimationFrame(() => {
    //   const root = chartAreaContainer.querySelector('.tv-lightweight-charts');
    //   console.log(root)
    //   const table = root?.querySelector('table');
    //   console.log(table)
    //
    //   const htmlCollectionOfPanes = table?.getElementsByTagName('td')
    //   console.log(htmlCollectionOfPanes)
    //
    //   const centerId = htmlCollectionOfPanes?.[1];
    //   console.log(centerId)
    //
    //   if (centerId && legendContainer && legendContainer.parentElement !== centerId) {
    //     centerId.appendChild(legendContainer);
    //   }
    // });
    // /*
    //   Внутри lightweight-chart DOM построен как таблица из 3 td
    //   [0] left priceScale, [1] center chart, [2] right priceScale
    //   Кладём легенду в td[1] и тогда легенда сама будет адаптироваться при изменении ширины шкал
    // */
    // requestAnimationFrame(() => {
    //   const root = chartAreaContainer.querySelector('.tv-lightweight-charts');
    //   const table = root?.querySelector('table');
    //   const centerId = table?.getElementsByTagName('td')?.[1];
    //
    //   if (centerId && legendContainer && legendContainer.parentElement !== centerId) {
    //     centerId.appendChild(legendContainer);
    //   }
    // });

    this.legend = new Legend({
      config: ohlcConfig,
      indicators: this.indicatorsMap,
      eventManager: this.eventManager,
      subscribeChartEvent: this.subscribeChartEvent,
      mainSeries: this.isMain ? this.mainSeries : null,
      paneId: this.id,
      paneIndex: this.paneIndex,
      openIndicatorSettings: (indicatorId, indicator) => {
        let settings = indicator.getSettings();

        this.modalRenderer.renderComponent(
          <EntitySettingsModal
            tabs={[
              {
                key: 'arguments',
                label: t('Arguments'),
                fields: indicator.getSettingsConfig(),
              },
            ]}
            values={settings}
            onChange={(nextSettings) => {
              settings = nextSettings;
            }}
            initialTabKey="arguments"
          />,
          {
            size: 'sm',
            title: indicatorLabelById()[indicatorId],
            onSave: () => indicator.updateSettings(settings),
          },
        );
      },
      // todo: throw isMainPane
    });

    this.legendRenderer.renderComponent(
      <LegendComponent
        ohlcConfig={this.legend.getConfig()}
        viewModel={this.legend.getLegendViewModel()}
      />,
    );
  }

  private initializeMainSerie({ lwcChart, dataSource }: { lwcChart: IChartApi; dataSource: DataSource }): void {
    this.mainSerieSub = this.eventManager.subscribeSeriesSelected((nextSeries) => {
      this.mainSeries.value?.destroy();

      const next = ensureDefined(SeriesFactory.create(nextSeries))({
        lwcChart,
        dataSource,
        mainSymbol$: this.eventManager.getSymbol(),
        mainSerie$: this.mainSeries,
      });

      this.mainSeries.next(next);
      this.priceScaleControls.refresh();
    });
  }

  private syncPaneContainers(): void {
    const lwcPaneElement = this.lwcPane.getHTMLElement();

    if (!lwcPaneElement) {
      this.schedulePaneContainerSync();
      return;
    }

    this.leftPriceScale.initialize();
    this.rightPriceScale.initialize();

    lwcPaneElement.style.position = 'relative';
    lwcPaneElement.appendChild(this.legendContainer);
    lwcPaneElement.appendChild(this.paneOverlayContainer);

    this.priceScaleControls.mount(lwcPaneElement);
  }
}



import { DataSource } from '@core/DataSource';
import { DrawingsManager, DrawingsManagerSnapshot } from '@core/DrawingsManager';
import { Pane, PaneParams } from '@core/Pane';
import { PriceAxisLabels } from '@core/PriceAxisLabels';
import { ISerializable, PaneSnapshot, PriceScaleSnapshot } from '@src/types/snapshot';

import type { Indicator } from '@core/Indicator';
import type { LogicalRange } from 'lightweight-charts';
import type { Observable } from 'rxjs';

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

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

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

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

export class PaneManager implements ISerializable<PaneSnapshot[]> {
  private readonly sharedPaneParams: SharedPaneParams;
  private readonly panesMap = new Map<number, Pane>();
  private mainPane: Pane;
  private nextPaneId: number;
  private priceAxisLabels: PriceAxisLabels | null = null;

  constructor({ panesSnapshot, ...sharedPaneParams }: PaneManagerParams) {
    this.sharedPaneParams = sharedPaneParams;

    const mainPaneSnapshot = panesSnapshot.find((paneSnapshot) => paneSnapshot.isMain);
    const mainPaneId = mainPaneSnapshot?.id ?? 0;

    this.mainPane = new Pane({
      ...this.sharedPaneParams,
      id: mainPaneId,
      isMainPane: true,
      onDelete: () => {},
      initialPriceScales: mainPaneSnapshot?.priceScales,
      onPriceScaleStateChange: this.handlePriceScaleStateChange,
    });

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

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

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

    this.nextPaneId = greatestPaneId + 1;

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

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

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

    this.syncPaneContainers();
  }

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

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

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

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

  public getPaneById(id: number): Pane | undefined {
    return this.panesMap.get(id);
  }

  public getDrawingsSnapshot(): DrawingsManagerSnapshot {
    return this.mainPane.getDrawingsSnapshot();
  }

  public setDrawingsSnapshot(snapshot: DrawingsManagerSnapshot): void {
    this.mainPane.setDrawingsSnapshot(snapshot);
  }

  public getPanes(): Map<number, Pane> {
    return this.panesMap;
  }

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

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

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

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

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

    return pane;
  }

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

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

  public getDrawingsManager(): DrawingsManager {
    // todo: temp
    return this.mainPane.getDrawingManager();
  }

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

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

    return snapshot;
  }

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

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

    this.panesMap.clear();
  }

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

    if (!pane) {
      return;
    }

    const paneIndex = pane.paneIndex();

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

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

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

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

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



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

import { Subscription } from 'rxjs';

import { Indicator } from '@core/Indicator';
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 { formatCompactNumber, formatPercent, formatPrice } from '@src/utils';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';

import { PriceAxisLabelsPrimitive } from './PriceAxisLabelsPrimitive';
import { getAxisSideBySeries, getContrastTextColor } from './utils';

import type { PriceAxisLabel } from './types';

import type { IPriceLine, LogicalRange } from 'lightweight-charts';
import type { Observable } from 'rxjs';

type EntityCollection = 'compare' | 'indicator';
type SourceRole = 'main' | 'compare' | 'indicator' | 'volume';
type PriceAxisSide = Direction.Left | Direction.Right;

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

interface PriceLabelSource {
  id: string;
  role: SourceRole;
  series: SeriesStrategies;
  priority: number;
}

interface SourceDefaults {
  lastValueVisible: boolean;
  priceLineVisible: boolean;
  title: string;
}

interface AxisLabelsGroup {
  paneIndex: number;
  side: PriceAxisSide;
  labels: PriceAxisLabel[];
  reservedCoordinate: number | null;
}

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

interface EntitySubscription {
  entity: Indicator;
  subscription: Subscription;
}

const SOURCE_PRIORITY: Record<SourceRole, number> = {
  main: 100,
  compare: 80,
  indicator: 60,
  volume: 40,
};

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

  if (
    data &&
    typeof data === 'object' &&
    'open' in data &&
    'close' in data &&
    typeof data.open === 'number' &&
    typeof data.close === 'number'
  ) {
    if (data.close >= data.open && 'upColor' in options && typeof options.upColor === 'string') {
      return removeAlphaFromHex(options.upColor);
    }

    if (data.close < data.open && 'downColor' in options && typeof options.downColor === 'string') {
      return removeAlphaFromHex(options.downColor);
    }
  }

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

  if ('lineColor' in options && typeof options.lineColor === 'string') {
    return removeAlphaFromHex(options.lineColor);
  }

  if ('topLineColor' in options && typeof options.topLineColor === 'string') {
    return removeAlphaFromHex(options.topLineColor);
  }

  if ('bottomLineColor' in options && typeof options.bottomLineColor === 'string') {
    return removeAlphaFromHex(options.bottomLineColor);
  }

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

function getAxisSide(source: PriceLabelSource): PriceAxisSide {
  if (source.role === 'volume') {
    return Direction.Right;
  }

  return getAxisSideBySeries(source.series);
}

export class PriceAxisLabels {
  private subscriptions = new Subscription();
  private entitySubscriptions = new Map<string, EntitySubscription>();
  private sourceDefaults = new WeakMap<SeriesStrategies, SourceDefaults>();
  private layers = new Map<string, AxisLayer>();

  private mainSeries: SeriesStrategies | null = null;
  private mainSeriesDataHandler: (() => void) | null = null;
  private compareEntities: Indicator[] = [];
  private indicatorEntities: Indicator[] = [];
  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$ }: PriceAxisLabelsParams) {
    this.subscriptions.add(
      mainSymbol$.subscribe((symbol) => {
        const symbolParts = symbol.split(':');

        this.mainSymbol = symbolParts[symbolParts.length - 1] || symbol;

        this.applyDisplayMode();
        this.scheduleUpdate();
      }),
    );

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

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

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

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

    this.refreshHistoryMode();
    this.scheduleUpdate();
  }

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

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

    this.unsubscribeMainSeries();
    this.subscriptions.unsubscribe();

    this.entitySubscriptions.forEach(({ subscription }) => {
      subscription.unsubscribe();
    });

    this.getSources().forEach(({ series }) => {
      this.restoreSourceOptions(series);
    });

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

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

  private setMainSeries(series: SeriesStrategies | null): void {
    if (this.mainSeries === series) {
      return;
    }

    const previousSeries = this.mainSeries;

    this.unsubscribeMainSeries();

    if (previousSeries) {
      this.restoreSourceOptions(previousSeries);
    }

    this.mainSeries = series;

    if (series) {
      this.ensureSourceDefaults(series);

      this.mainSeriesDataHandler = () => {
        this.refreshHistoryMode();
        this.scheduleUpdate();
      };

      series.subscribeDataChanged(this.mainSeriesDataHandler);
    }

    if (this.currentPriceLineHost && this.currentPriceLineHost !== series) {
      this.removeCurrentPriceLine();
    }

    this.refreshHistoryMode();
    this.applyDisplayMode();
    this.scheduleUpdate();
  }

  private unsubscribeMainSeries(): void {
    if (!this.mainSeries || !this.mainSeriesDataHandler) {
      this.mainSeriesDataHandler = null;
      return;
    }

    try {
      this.mainSeries.unsubscribeDataChanged(this.mainSeriesDataHandler);
    } catch {
      // Серия могла быть удалена раньше объекта PriceAxisLabels.
    }

    this.mainSeriesDataHandler = null;
  }

  private setEntities(collection: EntityCollection, entities: Indicator[]): void {
    if (collection === 'compare') {
      this.compareEntities = entities;
    } else {
      this.indicatorEntities = entities;
    }

    const activeKeys = new Set(entities.map((entity) => `${collection}:${entity.getId()}`));

    this.entitySubscriptions.forEach((entry, key) => {
      if (!key.startsWith(`${collection}:`) || activeKeys.has(key)) {
        return;
      }

      entry.entity.getSeriesMap().forEach((series) => {
        this.restoreSourceOptions(series);
      });

      entry.subscription.unsubscribe();
      this.entitySubscriptions.delete(key);
    });

    entities.forEach((entity) => {
      const key = `${collection}:${entity.getId()}`;
      const current = this.entitySubscriptions.get(key);

      if (current?.entity === entity) {
        return;
      }

      if (current) {
        current.entity.getSeriesMap().forEach((series) => {
          this.restoreSourceOptions(series);
        });

        current.subscription.unsubscribe();
      }

      const subscription = entity.subscribeDataChange(() => {
        this.applyDisplayModeToSources(this.getEntitySources(collection, entity));

        this.scheduleUpdate();
      });

      this.entitySubscriptions.set(key, {
        entity,
        subscription,
      });

      this.applyDisplayModeToSources(this.getEntitySources(collection, entity));
    });

    this.applyDisplayMode();
    this.scheduleUpdate();
  }

  private getSources(): PriceLabelSource[] {
    const sources: PriceLabelSource[] = [];

    if (this.mainSeries) {
      sources.push({
        id: 'main',
        role: 'main',
        series: this.mainSeries,
        priority: SOURCE_PRIORITY.main,
      });
    }

    this.compareEntities.forEach((entity) => {
      sources.push(...this.getEntitySources('compare', entity));
    });

    this.indicatorEntities.forEach((entity) => {
      sources.push(...this.getEntitySources('indicator', entity));
    });

    return sources;
  }

  private getEntitySources(collection: EntityCollection, entity: Indicator): PriceLabelSource[] {
    let role: SourceRole = 'indicator';

    if (collection === 'compare') {
      role = 'compare';
    } else if (entity.getType() === IndicatorsIds.Volume) {
      role = 'volume';
    }

    return Array.from(entity.getSeriesMap().entries(), ([seriesId, series]) => ({
      id: `${collection}:${entity.getId()}:${seriesId}`,
      role,
      series,
      priority: SOURCE_PRIORITY[role],
    }));
  }

  private ensureSourceDefaults(series: SeriesStrategies): SourceDefaults {
    const savedDefaults = this.sourceDefaults.get(series);

    if (savedDefaults) {
      return savedDefaults;
    }

    const options = series.options();

    const defaults = {
      lastValueVisible: options.lastValueVisible,
      priceLineVisible: options.priceLineVisible,
      title: options.title ?? '',
    };

    this.sourceDefaults.set(series, defaults);

    return defaults;
  }

  private restoreSourceOptions(series: SeriesStrategies): void {
    const defaults = this.sourceDefaults.get(series);

    if (!defaults) {
      return;
    }

    try {
      series.applyOptions(defaults);
    } catch {
      // Серия могла быть удалена раньше объекта PriceAxisLabels.
    }
  }

  private applyDisplayMode(): void {
    this.applyDisplayModeToSources(this.getSources());

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

  private applyDisplayModeToSources(sources: PriceLabelSource[]): void {
    sources.forEach((source) => {
      const defaults = this.ensureSourceDefaults(source.series);

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

          return;
        }

        if (source.role === 'main') {
          source.series.applyOptions({
            lastValueVisible: this.isHistoryMode ? false : defaults.lastValueVisible,
            title: this.isHistoryMode ? '' : this.mainSymbol || defaults.title,
          });

          return;
        }

        source.series.applyOptions({
          lastValueVisible: this.isHistoryMode ? false : defaults.lastValueVisible,
        });
      } catch {
        // Серия могла быть удалена раньше объекта PriceAxisLabels.
      }
    });
  }

  private refreshHistoryMode(): void {
    const barsInfo =
      this.visibleLogicalRange && this.mainSeries ? this.mainSeries.barsInLogicalRange(this.visibleLogicalRange) : null;

    const nextHistoryMode = (barsInfo?.barsAfter ?? 0) > 0;

    if (nextHistoryMode === this.isHistoryMode) {
      return;
    }

    this.isHistoryMode = nextHistoryMode;
    this.applyDisplayMode();
  }

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

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

  private update(): void {
    const sources = this.getSources();

    this.updateCurrentPriceLine(sources);
    this.updateAxisLayers(this.collectAxisGroups(sources), sources);
  }

  private collectAxisGroups(sources: PriceLabelSource[]): Map<string, AxisLabelsGroup> {
    const groups = new Map<string, AxisLabelsGroup>();

    sources.forEach((source) => {
      if (!source.series.isVisible() || (source.role !== 'volume' && !this.isHistoryMode)) {
        return;
      }

      const label = this.createLabel(source);

      if (!label) {
        return;
      }

      const paneIndex = source.series.getPane().paneIndex();

      const side = getAxisSide(source);
      const key = `${paneIndex}:${side}`;
      const group = groups.get(key);

      if (group) {
        group.labels.push(label);
        return;
      }

      groups.set(key, {
        paneIndex,
        side,
        labels: [label],
        reservedCoordinate: null,
      });
    });

    const mainSource = sources.find((source) => source.role === 'main');

    if (!mainSource) {
      return groups;
    }

    const showRealtimeLabel = this.isHistoryMode || this.ensureSourceDefaults(mainSource.series).lastValueVisible;

    if (!showRealtimeLabel) {
      return groups;
    }

    const reservedCoordinate = this.getCurrentMainPriceCoordinate();

    if (reservedCoordinate === null) {
      return groups;
    }

    const paneIndex = mainSource.series.getPane().paneIndex();

    const side = getAxisSide(mainSource);
    const group = groups.get(`${paneIndex}:${side}`);

    if (group) {
      group.reservedCoordinate = reservedCoordinate;
    }

    return groups;
  }

  private createLabel(source: PriceLabelSource): PriceAxisLabel | null {
    const data = this.getSourceData(source);
    const price = getDataPrice(data);

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

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

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

    return {
      id: source.id,
      desiredCoordinate: coordinate,
      text: source.role === 'volume' ? formatCompactNumber(price) : this.formatValue(source.series, price),
      color: getSeriesColor(source.series, data),
      style: this.isHistoryMode ? 'outlined' : 'filled',
      priority: source.priority,
    };
  }

  private getSourceData(source: PriceLabelSource): unknown {
    if (this.isHistoryMode && this.visibleLogicalRange) {
      return source.series.dataByIndex(Math.floor(this.visibleLogicalRange.to), MismatchDirection.NearestLeft);
    }

    const data = source.series.data();

    return data[data.length - 1] ?? null;
  }

  private updateAxisLayers(groups: Map<string, AxisLabelsGroup>, sources: PriceLabelSource[]): void {
    const activeKeys = new Set<string>();

    groups.forEach((group, key) => {
      const host = this.getAxisHost(group.paneIndex, group.side, sources);

      if (!host) {
        return;
      }

      activeKeys.add(key);

      this.getOrCreateLayer(key, host).primitive.setLabels(group.labels, group.reservedCoordinate);
    });

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

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

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

  private getAxisHost(paneIndex: number, side: PriceAxisSide, sources: PriceLabelSource[]): SeriesStrategies | null {
    if (paneIndex === MAIN_PANE_INDEX && side === Direction.Right && this.mainSeries) {
      return this.mainSeries;
    }

    const regularSource = sources.find(
      (source) =>
        source.role !== 'volume' && source.series.getPane().paneIndex() === paneIndex && getAxisSide(source) === side,
    );

    if (regularSource) {
      return regularSource.series;
    }

    return (
      sources.find((source) => source.series.getPane().paneIndex() === paneIndex && getAxisSide(source) === side)
        ?.series ?? null
    );
  }

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

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

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

    const layer = {
      host,
      primitive: new PriceAxisLabelsPrimitive(),
    };

    host.attachPrimitive(layer.primitive);
    this.layers.set(key, layer);

    return layer;
  }

  private updateCurrentPriceLine(sources: PriceLabelSource[]): void {
    const mainSource = sources.find((source) => source.role === 'main');

    if (!this.isHistoryMode || !mainSource || !mainSource.series.isVisible()) {
      this.hideCurrentPriceLine();
      return;
    }

    const data = mainSource.series.data();
    const lastData = data[data.length - 1];
    const price = getDataPrice(lastData);

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

    const color = getSeriesColor(mainSource.series, lastData);

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

  private getCurrentMainPriceCoordinate(): number | null {
    if (!this.mainSeries) {
      return null;
    }

    const data = this.mainSeries.data();
    const price = getDataPrice(data[data.length - 1]);

    return price === null ? null : this.mainSeries.priceToCoordinate(price);
  }

  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 {
        // Серия могла быть удалена раньше объекта PriceAxisLabels.
      }
    }

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

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

    const formattedPrice = formatPrice(price) ?? series.priceFormatter().format(price);

    if (mode !== PriceScaleMode.Percentage && mode !== PriceScaleMode.IndexedTo100) {
      return formattedPrice;
    }

    if (!this.visibleLogicalRange) {
      return formattedPrice;
    }

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

    if (firstVisiblePrice === null || firstVisiblePrice === 0) {
      return formattedPrice;
    }

    if (mode === PriceScaleMode.Percentage) {
      return formatPercent(((price - firstVisiblePrice) / firstVisiblePrice) * 100);
    }

    const indexedValue = (price / firstVisiblePrice) * 100;

    return formatPrice(indexedValue) ?? String(indexedValue);
  }
}



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

import { layoutPriceAxisLabels } from './PriceAxisLabelsLayout';
import { getAxisSideBySeries, getContrastTextColor } from './utils';

import type { CanvasRenderingTarget2D } from 'fancy-canvas';
import type {
  ChartOptions,
  IPrimitivePaneRenderer,
  IPrimitivePaneView,
  ISeriesPrimitive,
  PrimitivePaneViewZOrder,
  SeriesAttachedParameter,
  Time,
} from 'lightweight-charts';
import type { LaidOutPriceAxisLabel, MeasuredPriceAxisLabel, PriceAxisLabel, PriceAxisSide } from './types';

type ChartLayoutOptions = Readonly<ChartOptions['layout']>;

interface PriceAxisLabelsRenderState {
  labels: PriceAxisLabel[];
  reservedCoordinate: number | null;
  chart: SeriesAttachedParameter<Time>['chart'] | null;
  series: SeriesAttachedParameter<Time>['series'] | null;
}

interface LabelGeometry {
  label: LaidOutPriceAxisLabel;
  left: number;
  top: number;
  textX: number;
  textY: number;
}

const HORIZONTAL_PADDING_RATIO = 7.25 / 12;
const VERTICAL_PADDING_RATIO = 3.75 / 12;
const TEXT_VERTICAL_OFFSET_RATIO = 0.5 / 12;
const LABEL_EDGE_GAP = 1;

function getFont(layout: ChartLayoutOptions): string {
  return `${layout.fontSize}px ${layout.fontFamily}`;
}

function getLabelTextColor(label: PriceAxisLabel): string {
  if (label.style === 'outlined') {
    return label.color;
  }

  return getContrastTextColor(label.color);
}

function areLabelsEqual(currentLabels: PriceAxisLabel[], nextLabels: PriceAxisLabel[]): boolean {
  return (
    currentLabels.length === nextLabels.length &&
    currentLabels.every((currentLabel, index) => {
      const nextLabel = nextLabels[index];

      return (
        currentLabel.id === nextLabel.id &&
        currentLabel.desiredCoordinate === nextLabel.desiredCoordinate &&
        currentLabel.text === nextLabel.text &&
        currentLabel.color === nextLabel.color &&
        currentLabel.style === nextLabel.style &&
        currentLabel.priority === nextLabel.priority
      );
    })
  );
}

function measureLabels(
  context: CanvasRenderingContext2D,
  labels: PriceAxisLabel[],
  layout: ChartLayoutOptions,
): MeasuredPriceAxisLabel[] {
  const horizontalPadding = layout.fontSize * HORIZONTAL_PADDING_RATIO;
  const verticalPadding = layout.fontSize * VERTICAL_PADDING_RATIO;

  return labels.map((label) => {
    const textMetrics = context.measureText(label.text);
    const textHeight = textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent || layout.fontSize;

    return {
      ...label,
      width: Math.ceil(textMetrics.width) + horizontalPadding * 2,
      height: textHeight + verticalPadding * 2,
    };
  });
}

function createLabelGeometries(
  labels: MeasuredPriceAxisLabel[],
  axisWidth: number,
  axisHeight: number,
  side: PriceAxisSide,
  layout: ChartLayoutOptions,
  reservedCoordinate: number | null,
): LabelGeometry[] {
  const reservedHeight = labels.reduce(
    (maximumHeight, label) => Math.max(maximumHeight, label.height),
    layout.fontSize + layout.fontSize * VERTICAL_PADDING_RATIO * 2,
  );

  const laidOutLabels =
    reservedCoordinate === null
      ? layoutPriceAxisLabels(labels, axisHeight)
      : layoutPriceAxisLabels(labels, axisHeight, {
          reservedCoordinate,
          reservedHeight,
        });

  return laidOutLabels.map((label) => {
    const left =
      side === Direction.Right ? LABEL_EDGE_GAP : Math.max(LABEL_EDGE_GAP, axisWidth - label.width - LABEL_EDGE_GAP);

    return {
      label,
      left,
      top: label.coordinate - label.height / 2,
      textX: left + label.width / 2,
      textY: label.coordinate + layout.fontSize * TEXT_VERTICAL_OFFSET_RATIO,
    };
  });
}

function drawLabelBackgrounds(target: CanvasRenderingTarget2D, geometries: LabelGeometry[]): void {
  target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
    const { colors } = getThemeStore();

    context.save();

    geometries.forEach(({ label, left, top }) => {
      const bitmapLeft = Math.round(left * horizontalPixelRatio);
      const bitmapTop = Math.round(top * verticalPixelRatio);
      const bitmapWidth = Math.round(label.width * horizontalPixelRatio);
      const bitmapHeight = Math.round(label.height * verticalPixelRatio);

      context.fillStyle = label.style === 'outlined' ? colors.chartBackground : label.color;
      context.fillRect(bitmapLeft, bitmapTop, bitmapWidth, bitmapHeight);

      if (label.style !== 'outlined') {
        return;
      }

      const borderWidth = Math.max(1, Math.floor(Math.min(horizontalPixelRatio, verticalPixelRatio)));

      context.strokeStyle = label.color;
      context.lineWidth = borderWidth;
      context.strokeRect(
        bitmapLeft + borderWidth / 2,
        bitmapTop + borderWidth / 2,
        Math.max(0, bitmapWidth - borderWidth),
        Math.max(0, bitmapHeight - borderWidth),
      );
    });

    context.restore();
  });
}

function drawLabelTexts(
  target: CanvasRenderingTarget2D,
  geometries: LabelGeometry[],
  layout: ChartLayoutOptions,
): void {
  target.useMediaCoordinateSpace(({ context }) => {
    context.save();
    context.font = getFont(layout);
    context.textAlign = 'center';
    context.textBaseline = 'middle';

    geometries.forEach(({ label, textX, textY }) => {
      context.fillStyle = getLabelTextColor(label);
      context.fillText(label.text, textX, textY);
    });

    context.restore();
  });
}

class PriceAxisLabelsRenderer implements IPrimitivePaneRenderer {
  constructor(private readonly state: PriceAxisLabelsRenderState) {}

  public draw(target: CanvasRenderingTarget2D): void {
    const { labels, chart, series, reservedCoordinate } = this.state;
    const layout = chart?.options().layout;

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

    let geometries: LabelGeometry[] = [];

    target.useMediaCoordinateSpace(({ context, mediaSize }) => {
      if (mediaSize.width <= 0 || mediaSize.height <= 0) {
        return;
      }

      context.save();
      context.font = getFont(layout);

      const measuredLabels = measureLabels(context, labels, layout);

      geometries = createLabelGeometries(
        measuredLabels,
        mediaSize.width,
        mediaSize.height,
        getAxisSideBySeries(series),
        layout,
        reservedCoordinate,
      );

      context.restore();
    });

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

    drawLabelBackgrounds(target, geometries);
    drawLabelTexts(target, geometries, layout);
  }
}

class PriceAxisLabelsPaneView implements IPrimitivePaneView {
  private readonly rendererInstance: PriceAxisLabelsRenderer;

  constructor(private readonly state: PriceAxisLabelsRenderState) {
    this.rendererInstance = new PriceAxisLabelsRenderer(state);
  }

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

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

export class PriceAxisLabelsPrimitive implements ISeriesPrimitive<Time> {
  private readonly state: PriceAxisLabelsRenderState = {
    labels: [],
    reservedCoordinate: null,
    chart: null,
    series: null,
  };

  private readonly priceAxisPaneView = new PriceAxisLabelsPaneView(this.state);
  private readonly priceAxisPaneViewList: IPrimitivePaneView[] = [this.priceAxisPaneView];
  private requestUpdate: (() => void) | null = null;

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

  public detached(): void {
    this.state.chart = null;
    this.state.series = null;
    this.state.labels = [];
    this.state.reservedCoordinate = null;
    this.requestUpdate = null;
  }

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

  public updateAllViews(): void {}

  public setLabels(labels: PriceAxisLabel[], reservedCoordinate: number | null = null): void {
    if (areLabelsEqual(this.state.labels, labels) && this.state.reservedCoordinate === reservedCoordinate) {
      return;
    }

    this.state.labels = labels;
    this.state.reservedCoordinate = reservedCoordinate;
    this.requestUpdate?.();
  }

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

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



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 { 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 optionsSubscription: Subscription;
  private dataSource: DataSource;
  private chartConfig: Omit<ChartConfig, 'theme' | 'mode'>;
  private mainSeries: BehaviorSubject<SeriesStrategies | 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.mainSeries = this.paneManager.getMainPane().getMainSerie();

    this.indicatorManager = new IndicatorManager({
      eventManager,
      initialIndicators: flatten(
        panesSnapshot.map((pane) =>
          pane.indicators.map((indicator) => ({
            ...indicator,
            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((indicator) => ({
            ...indicator,
            paneId: pane.id,
          })),
        ),
      ),
      eventManager: this.eventManager,
      dataSource: this.dataSource,
      indicatorManager: this.indicatorManager,
      paneManager: this.paneManager,
    });

    this.paneManager.initializePriceAxisLabels({
      compareEntities$: this.compareManager.entities(),
      indicatorEntities$: this.indicatorManager.entities(),
    });

    this.paneManager.setVisibleLogicalRange(this.lwcChart.timeScale().getVisibleLogicalRange());
    this.paneManager.refreshPriceScaleControls();

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

    this.paneManager.invalidatePriceAxisLabels();
  }

  public destroy(): void {
    this.mouseEvents.destroy();
    this.subscriptions.unsubscribe();
    this.compareManager.destroy();
    this.paneManager.destroy();
    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.paneManager.resetPriceScalesAutoScale();
  };

  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((symbol) => this.dataSource.loadMoreHistory(symbol))).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((item) => item.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((symbol) => this.dataSource.loadAllHistory(symbol)))
              .then(() => {
                requestAnimationFrame(() => this.lwcChart.timeScale().fitContent());
              })
              .catch((error) => console.error('[Chart] Ошибка при загрузке всей истории:', error));

            return;
          }

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

          Promise.all(symbols.map((symbol) => this.dataSource.loadTill(symbol, 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 previousSymbols = this.activeSymbols;

        this.activeSymbols = symbols;

        this.dataSource.setSymbols(symbols);

        const previousSymbolsSet = new Set(previousSymbols);
        const addedSymbols: string[] = [];

        for (let index = 0; index < symbols.length; index += 1) {
          const symbol = symbols[index];

          if (!symbol) continue;
          if (previousSymbolsSet.has(symbol)) {
            continue;
          }

          addedSymbols.push(symbol);
        }

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

  private setupHistoricalDataLoading(): void {
    // todo (не)вызвать loadMoreHistory после проверки на необходимость дозагрузки после смены таймфрейма
    this.mouseEvents.subscribe('visibleLogicalRangeChange', (logicalRange: LogicalRange | null) => {
      this.paneManager.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 { SeriesType, Time } from 'lightweight-charts';

import { DateFormat } from '@src/utils/formatter';

import { TimeFormat } from './timeScale';
/**
 * Типы графиков, поддерживаемые библиотекой
 */
export type ChartSeriesType = SeriesType;

/**
 * Универсальный формат данных для всех типов графиков
 */
export interface ChartDataPoint {
  time?: Time;
  value?: number;
  open?: number;
  high?: number;
  low?: number;
  close?: number;
  volume?: number;
}

interface CandlestickChartOptions {
  upColor?: string;
  downColor?: string;
  wickUpColor?: string;
  wickDownColor?: string;
  borderVisible?: boolean;
}

interface LineChartOptions {
  lineStyle?: 'solid' | 'dotted' | 'dashed';
}

interface AreaChartOptions {
  topColor?: string;
  bottomColor?: string;
}

interface BaselineChartOptions {
  baseValue?: number;
  topLineColor?: string;
  bottomLineColor?: string;
  topFillColor1?: string;
  topFillColor2?: string;
  bottomFillColor1?: string;
  bottomFillColor2?: string;
}

interface TimescaleOptions {
  timeVisible?: boolean;
  secondsVisible?: boolean;
  timeFormat?: TimeFormat;
  dateFormat?: DateFormat;
}

interface CrosshairOptions {
  crosshairLineXColor?: string;
  crosshairLineYColor?: string;
  crosshairLabelXBgColor?: string;
  crosshairLabelYBgColor?: string;
}

interface HistogramOptions {
  scaleMargins?: {
    top: number;
    bottom: number;
  };
}

/**
 * @deprecated
 * Опции для разных типов графиков
 */
export interface ChartTypeOptions // todo: неверный интерфейс
  extends HistogramOptions,
    CrosshairOptions,
    TimescaleOptions,
    BaselineChartOptions,
    AreaChartOptions,
    LineChartOptions,
    CandlestickChartOptions {
  // Общие опции
  color?: string;
  lineWidth?: number;
}

export interface LineCandle {
  time: number;
  value: number;
  volume?: number;
}

export interface Candle {
  time: number;
  open: number;
  high: number;
  low: number;
  close: number;
  volume?: number; // todo: volume & future metaData to customFields
}

export type Destroyable = {
  destroy(): void;
};

export enum Direction {
  Top = 'top',
  Bottom = 'bottom',
  Left = 'left',
  Right = 'right',
}

// Настройки графика, которые могут изменяться в рантайме
export interface ChartOptionsModel {
  dateFormat: DateFormat;
  timeFormat: TimeFormat;
  showTime: boolean;
}



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



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

import { MAIN_PANE_INDEX } from '@src/constants';
import { type Candle, Direction } 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;
  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.flatMap((candle) => {
    if (typeof candle.time !== 'number' || !Number.isFinite(candle.time)) {
      return [];
    }

    return [
      {
        ...candle,
        time: candle.time - offsetMinutes * secondsInMinute,
      },
    ];
  });
}

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 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();
      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 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 {
    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);

    chart.priceScale(options.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: BarData | LineData | HistogramData | CustomData | null,
    prevBar: BarData | LineData | HistogramData | CustomData | null,
  ): 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 data = this.applyTimezone(inputData);

    if (!this.customFormatter) {
      return this.formatMainSerie(data);
    }

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

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

    const candle = this.formatMainSerie(data)[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.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) ?? String(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, 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,
    };
  }

  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,
      customValues: current.customValues,
    };
  }

  return null;
}



import {
  BarData,
  CustomData,
  HistogramData,
  LineData,
  LineSeries,
  LineStyle,
  SeriesDataItemTypeMap,
  SeriesDefinition,
  SeriesPartialOptionsMap,
  Time,
} from 'lightweight-charts';

import { Ohlc } from '@core/Legend';

import { BaseSeries, BaseSeriesParams, calcCandleChange } from '@core/Series/BaseSeries';

import { ISeries } from '@src/modules/series-strategies';

import { getThemeStore } from '@src/theme/store';
import { t } from '@src/translations';
import { Candle, LineCandle } from '@src/types';
import { ensureDefined, formatCompactNumber, isLineData } from '@src/utils';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';

export class LineSeriesStrategy extends BaseSeries<'Line'> implements ISeries<'Line'> {
  constructor(params: BaseSeriesParams<'Line'>) {
    super(params);
    this.subscribeDataSource(params.dataSource);
  }

  protected seriesDefinition(): SeriesDefinition<'Line'> {
    return LineSeries;
  }

  protected getDefaultOptions(): SeriesPartialOptionsMap['Line'] {
    const { colors } = getThemeStore();

    return {
      color: colors.chartLineColor,
      lineWidth: 2,
      lineStyle: LineStyle.Solid,
    };
  }

  public validateData(data: (Partial<Candle> & Partial<LineCandle>)[]): boolean {
    if (!Array.isArray(data)) {
      return false;
    }

    return data.every(
      (point) => typeof point.time === 'number' && typeof point.close === 'number' && !Number.isNaN(point.close),
    );
  }

  public getTypeName(): string {
    return 'Line';
  }

  protected dataSourceSubscription = (dataToSet: Candle[]): void => {
    if (!this.validateData(dataToSet)) {
      console.error(`LightweightAPI: Invalid data format for ${this.getTypeName()} chart`);
      return;
    }

    this.setData(this.formatData(dataToSet));
  };

  protected dataSourceRealtimeSubscription = (dataToSet: Candle): void => {
    if (!this.validateData([dataToSet])) {
      console.error(`LightweightAPI: Invalid data format for ${this.getTypeName()} chart`);
      return;
    }

    const formattedData = this.formatData([dataToSet]);
    this.update(formattedData[0]);
  };

  protected formatMainSerie(inputData: Candle[]): SeriesDataItemTypeMap<Time>['Line'][] {
    return inputData.map((point) => ({
      time: point.time as Time,
      value: point.close, // Для line графика используем close как value
      customValues: point as unknown as Record<string, unknown>,
    }));
  }

  protected 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 }>> {
    if (!currentBar || !isLineData(currentBar)) {
      return {};
    }

    const { absoluteChange, percentageChange, time } = ensureDefined(calcCandleChange(prevBar, currentBar));

    const color = currentBar.color ? removeAlphaFromHex(currentBar.color) : this.options().color;

    return {
      value: {
        value: formatCompactNumber(currentBar.value) ?? '',
        name: '',
        color,
      },
      absoluteChange: {
        value: formatCompactNumber(absoluteChange ?? 0),
        name: t('Change'),
        color,
      },
      percentageChange: {
        value: percentageChange !== undefined ? `${formatCompactNumber(percentageChange)}%` : '',
        name: t('Change'),
        color,
      },
      time: {
        value: time,
        name: t('Time'),
        color,
      },
    };
  }
}