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


import { MouseEventParams, Point, Time } from 'lightweight-charts';

import { BehaviorSubject, combineLatest, Observable } from 'rxjs';

import { ChartMouseEvents } from '@core/ChartMouseEvents';

import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { IndicatorsIds } from '@core/Indicators';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { CompareMode, OHLCConfig } from '@src/types';
import { ensureDefined } from '@src/utils';

export interface LegendParams {
  config: OHLCConfig;
  eventManager: EventManager;
  indicators: BehaviorSubject<Map<IndicatorsIds, Indicator>>;
  subscribeChartEvent: ChartMouseEvents['subscribe'];
  mainSeries: BehaviorSubject<SeriesStrategies | null> | null;
  paneId: number;
}

export interface Ohlc {
  time: Time;
  open?: number;
  high?: number;
  low?: number;
  close?: number;
  value?: number;
  absoluteChange?: number;
  percentageChange?: number;
}

export interface CompareLegendItem {
  symbol: string;
  mode: CompareMode;
  value: string;
  color: string;
}

export interface LegendVM {
  vm: LegendModel;
}

export type LegendModel = {
  name: IndicatorsIds | string;
  isIndicator: boolean;
  values: Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>;
  remove: () => void;
}[];

export const ohlcValuesToShowForMainSerie = [
  // 'time',
  'open',
  'high',
  'low',
  'close',
  'value',
  // 'absoluteChange',
  'percentageChange',
];

export const ohlcValuesToShowForIndicators = [
  // 'time',
  'open',
  // 'high',
  // 'low',
  // 'close',
  'value',
  // 'absoluteChange',
  // 'percentageChange'
];

export class Legend {
  private eventManager: EventManager;
  private indicators: Map<IndicatorsIds, Indicator> = new Map();
  private config: OHLCConfig;

  private mainSeries!: SeriesStrategies;
  private isChartHovered = false;
  private model$ = new BehaviorSubject<LegendModel>([]);
  private tooltipVisability = new BehaviorSubject<boolean>(false);
  private tooltipPos = new BehaviorSubject<null | Point>(null);
  private paneId: number;

  constructor({ config, eventManager, indicators, subscribeChartEvent, mainSeries, paneId }: LegendParams) {
    this.config = config;
    this.eventManager = eventManager;
    this.paneId = paneId;

    if (!mainSeries) {
      indicators.subscribe((value: Map<IndicatorsIds, Indicator>) => {
        this.indicators = value;
        this.handleIndicatorSeriesDataChange();
      });
    } else {
      combineLatest([mainSeries, indicators]).subscribe(([mainSerie, inds]) => {
        if (!mainSerie) {
          return;
        }
        this.mainSeries = mainSerie;
        this.indicators = inds;
        this.handleMainSeriesDataChange();
      });
    }
    subscribeChartEvent('crosshairMove', this.handleCrosshairMove);
  }

  public subscribeCursorPosition(cb: (point: Point | null) => void) {
    this.tooltipPos.subscribe(cb);
  }

  public subscribeCursorVisability(cb: (isVisible: boolean) => void) {
    this.tooltipVisability.subscribe(cb);
  }

  private handleIndicatorSeriesDataChange = () => {
    for (const [_, indicator] of this.indicators) {
      indicator?.subscribeDataChange(this.updateWithLastCandle);
    }
  };

  private handleMainSeriesDataChange = () => {
    this.mainSeries?.subscribeDataChanged(() => {
      this.updateWithLastCandle();
    });
  };

  private updateWithLastCandle = () => {
    if (this.isChartHovered) return;

    const model: LegendModel = [];

    if (this.mainSeries) {
      const series = new Map();

      const serieData = this.mainSeries.getLegendData();

      Object.entries(serieData).forEach(([key, sd]) => {
        series.set(`${key}`, {
          ...sd,
        });
      });

      model.push({
        name: this.mainSeries.options().title,
        values: series as Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>,
        isIndicator: false,
        remove: () => {
          // serieData.remove() // todo: запретить удалять главную серию на главном пейне
        },
      });
    }

    if (!this.indicators) {
      return;
    }

    for (const [indicatorName, indicator] of this.indicators) {
      const indicatorSeries = new Map();

      for (const [serieName, serie] of indicator.getSeriesMap()) {
        if (!serie.isVisible()) {
          continue;
        }

        const serieData = serie.getLegendData();

        const value = serieData.value ?? serieData.close;

        indicatorSeries.set(serieName, { ...value });
      }

      model.push({
        name: indicatorName,
        values: indicatorSeries as Partial<
          Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>
        >,
        isIndicator: true,
        remove: () => indicator.delete(),
      });
    }

    this.model$.next(model);
  };

  private handleCrosshairMove = (param: MouseEventParams) => {
    // todo: есть одинаковый код с updateWithLastCandle
    if (param.point === undefined || !param.time || param.point.x < 0 || param.point.y < 0) {
      this.tooltipVisability.next(false);

      this.isChartHovered = false;
      this.updateWithLastCandle();
      return;
    }

    if (this.paneId === param.paneIndex) {
      this.tooltipVisability.next(true);
    } else {
      this.tooltipVisability.next(false);
    }

    this.isChartHovered = true;

    const model: LegendModel = [];

    if (this.mainSeries) {
      const series = new Map();
      const serieData = this.mainSeries.getLegendData(param);

      Object.entries(serieData).forEach(([key, sd]) => {
        series.set(`${key}`, {
          ...sd,
        });
      });

      model.push({
        name: this.mainSeries.options().title,
        values: series as Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>,
        isIndicator: false,
        remove: () => {
          // serieData.remove() // todo: запретить удалять главную серию на главном пейне
        },
      });
    }

    if (!this.indicators) {
      return;
    }

    for (const [indicatorName, indicator] of this.indicators) {
      const indicatorSeries = new Map();

      for (const [serieName, serie] of indicator.getSeriesMap()) {
        if (!serie.isVisible()) {
          continue;
        }

        const serieData = serie.getLegendData(param);

        const value = ensureDefined(serieData.value ?? serieData.close);

        indicatorSeries.set(serieName, value);
      }

      model.push({
        name: indicatorName,
        values: indicatorSeries as Partial<
          Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>
        >,
        isIndicator: true,
        remove: () => indicator.delete(),
      });
    }

    this.model$.next(model);
    this.tooltipPos.next(param.point);
  };

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

  public getLegendViewModel(): Observable<LegendModel> {
    return this.model$;
  }

  public destroy = () => {
    this.model$.complete();
    this.tooltipVisability.complete();
    this.tooltipPos.complete();
  };
}




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

import { DataSource } from '@core/DataSource';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { IndicatorManager } from '@core/IndicatorManager';
import { IndicatorConfig, IndicatorDataFormatter } from '@core/Indicators';
import { emaIndicator } from '@core/Indicators/ema';
import { PaneManager } from '@core/PaneManager';

import { MAIN_PANE_INDEX } from '@src/constants';
// import { CompareIndicator } from '@src/core/CompareIndicator';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { getThemeStore } from '@src/theme';
import { CompareItem, CompareMode, Direction } from '@src/types';
import { normalizeSymbol } from '@src/utils';

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

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

interface CompareManagerParams {
  chart: IChartApi;
  eventManager: EventManager;
  dataSource: DataSource;
  indicatorManager: IndicatorManager;
  paneManager: PaneManager;
}

const defaultCompareIndicator: IndicatorConfig = {
  newPane: true,
  series: [
    {
      name: 'Line', // todo: change with enum
      id: `compare-${Math.random()}`,
      seriesOptions: {
        visible: true,
        color: getThemeStore().colors.chartPriceLineText,
      },
    },
  ],
};

export class CompareManager {
  private readonly chart: IChartApi;
  private readonly eventManager: EventManager;
  private readonly dataSource: DataSource;
  private readonly indicatorManager: IndicatorManager;
  private readonly paneManager: PaneManager;

  private readonly entries = new Map<string, CompareEntry>();
  private readonly itemsSubject = new BehaviorSubject<CompareItem[]>([]);
  private readonly entitiesSubject = new BehaviorSubject<Indicator[]>([]);

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

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

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

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

  public snapshot(): CompareItem[] {
    return this.itemsSubject.value;
  }

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

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

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

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

    const { paneIndex, priceScaleId } = this.getPlacement(mode);
    const symbol$ = new BehaviorSubject(symbol);

    const entity = this.indicatorManager.addEntity<Indicator>(
      (zIndex, moveUp, moveDown) =>
        new Indicator({
          id: key,
          lwcChart: this.chart,
          mainSymbol$: new BehaviorSubject<string>(symbol),
          dataSource: this.dataSource,
          zIndex,
          onDelete: () => this.removeByKey(key),
          moveUp,
          moveDown,
          paneManager: this.paneManager,
          config: {
            ...defaultCompareIndicator,
            series: [
              {
                ...defaultCompareIndicator.series[0],
                seriesOptions: {
                  ...defaultCompareIndicator.series[0]?.seriesOptions,
                  priceScaleId,
                },
              },
            ],
            newPane: mode === CompareMode.NewPane,
          },
        }),
    );

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

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

    await this.dataSource.isReady(symbol);
  }

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

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

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

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

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

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

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

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

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

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

    this.entries.delete(key);

    entry.entity.destroy();
    entry.symbol$.complete();

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  private getPlacement(mode: CompareMode): { paneIndex: number; priceScaleId: Direction } {
    const priceScaleId = mode === CompareMode.NewScale ? Direction.Left : Direction.Right;
    const paneIndex = mode === CompareMode.NewPane ? this.paneManager.getPanes().size - 1 : MAIN_PANE_INDEX;
    return { paneIndex, priceScaleId };
  }
}




import { DataSource } from '@core/DataSource';
import { DrawingsManager } from '@core/DrawingsManager';
import { Pane, PaneParams } from '@core/Pane';

type PaneManagerParams = Omit<PaneParams, 'isMainPane' | 'id' | 'basedOn' | 'onDelete'>;

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

export class PaneManager {
  private mainPane: Pane;
  private paneChartInheritedParams: PaneManagerParams & { isMainPane: boolean };
  private panesMap: Map<number, Pane> = new Map<number, Pane>();

  constructor(params: PaneManagerParams) {
    this.paneChartInheritedParams = { ...params, isMainPane: false };

    this.mainPane = new Pane({ ...params, isMainPane: true, id: 0, onDelete: () => {} });

    this.panesMap.set(0, this.mainPane);
  }

  public getPanes() {
    return this.panesMap;
  }

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

  public addPane(dataSource?: DataSource): Pane {
    const panesOverallCount = this.panesMap.size;

    const pane = new Pane({
      ...this.paneChartInheritedParams,
      id: panesOverallCount,
      dataSource: dataSource ?? null,
      basedOn: dataSource ? undefined : this.mainPane,
      onDelete: () => {
        this.panesMap.delete(panesOverallCount);
      },
    });

    this.panesMap.set(panesOverallCount, pane);

    return pane;
  }

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