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


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

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

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

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

export interface LegendParams {
  config: OHLCConfig;
  eventManager: EventManager;
  indicators: BehaviorSubject<Map<string, Indicator>>;
  subscribeChartEvent: ChartMouseEvents['subscribe'];
  mainSeries: BehaviorSubject<SeriesStrategies | null> | null;
  paneId: number;
  paneIndex: () => number;
  openIndicatorSettings: (id: IndicatorsIds, indicator: Indicator) => void;
}

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

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

export interface LegendVM {
  vm: LegendModel;
}

export type LegendModel = {
  id: string;
  name: IndicatorLabel | string;
  isIndicator: boolean;
  values: Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name?: string }>>;
  remove?: () => void;
  settings?: () => 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<string, Indicator> = new Map();
  private config: OHLCConfig;

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

  private openIndicatorSettings: (id: IndicatorsIds, indicator: Indicator) => void;
  private subscriptions = new Subscription();
  private indicatorSubscriptions = new Subscription();
  private mainSeriesSubscription = new Subscription();

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

    this.subscriptions.add(
      this.eventManager.symbolName().subscribe((symbolName) => {
        this.mainSymbolName = symbolName;
        this.updateWithLastCandle();
      }),
    );

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

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

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

  private handleIndicatorSeriesDataChange = () => {
    this.indicatorSubscriptions.unsubscribe();
    this.indicatorSubscriptions = new Subscription();

    for (const [_, indicator] of this.indicators) {
      this.indicatorSubscriptions.add(indicator.subscribeDataChange(this.updateWithLastCandle));
    }

    this.updateWithLastCandle();
  };

  private handleMainSeriesChange = () => {
    this.mainSeriesSubscription.unsubscribe();
    this.mainSeriesSubscription = new Subscription();

    const handler = () => this.updateWithLastCandle();

    this.mainSeries?.subscribeDataChanged(handler);

    this.mainSeriesSubscription.add(() => this.mainSeries?.unsubscribeDataChanged(handler));
  };

  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({
        id: `main-series-${this.paneId}`,
        name: this.mainSymbolName,
        values: series as Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>,
        isIndicator: false,
      });
    }

    if (!this.indicators) {
      return;
    }

    for (const [_, indicator] of this.indicators) {
      const indicatorType = indicator.getType();
      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;

        if (!value) continue;

        indicatorSeries.set(serieName, { ...value, name: indicator.getSeriesLabel(serieName) });
      }

      model.push({
        id: indicator.getId(),
        name: indicator.getLabel(),
        values: indicatorSeries as Partial<
          Record<keyof Ohlc, { value: number | string | Time; color: string; name?: string }>
        >,
        isIndicator: true,
        remove: () => indicator.delete(),
        settings:
          indicatorType && indicator.hasSettings()
            ? () => this.openIndicatorSettings(indicatorType, indicator)
            : undefined,
      });
    }

    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 (param.seriesData.size === 0) {
      this.updateWithLastCandle();
      return;
    }

    if (this.paneIndex() === 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({
        id: `main-series-${this.paneId}`,
        name: this.mainSymbolName,
        values: series as Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>,
        isIndicator: false,
      });
    }

    if (!this.indicators) {
      return;
    }

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

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

        const serieData = serie.getLegendData(param);
        const value = serieData.value ?? serieData.close;

        if (!value) continue;

        indicatorSeries.set(serieName, { ...value, name: indicator.getSeriesLabel(serieName) });
      }

      model.push({
        id: indicator.getId(),
        name: indicator.getLabel(),
        values: indicatorSeries as Partial<
          Record<keyof Ohlc, { value: number | string | Time; color: string; name?: string }>
        >,
        isIndicator: true,
        remove: () => indicator.delete(),
        settings:
          indicatorType && indicator.hasSettings()
            ? () => this.openIndicatorSettings(indicatorType, indicator)
            : undefined,
      });
    }

    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.subscriptions.unsubscribe();
    this.indicatorSubscriptions.unsubscribe();
    this.mainSeriesSubscription.unsubscribe();

    this.model$.complete();
    this.tooltipVisability.complete();
    this.tooltipPos.complete();
  };
}

















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

import { BehaviorSubject } from 'rxjs';

import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { Legend, LegendModel, LegendParams } from '@core/Legend';

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

import type { OHLCConfig } from '@src/types';

describe('Legend', () => {
  let crosshairMoveHandler: ((param: MouseEventParams) => void) | null;
  let indicators: BehaviorSubject<Map<string, Indicator>>;
  let mainSeriesSubject: BehaviorSubject<SeriesStrategies | null>;
  let mainSeries: SeriesStrategies;
  let eventManager: EventManager;
  let getLegendData: jest.Mock;
  let subscribeDataChanged: jest.Mock;
  let unsubscribeDataChanged: jest.Mock;
  let legend: Legend | null;

  const config: OHLCConfig = {
    show: true,
    showAbsoluteChange: false,
    showPercentageChange: true,
  };

  beforeEach(() => {
    crosshairMoveHandler = null;
    legend = null;

    getLegendData = jest.fn().mockReturnValue({
      open: {
        value: 100,
        color: 'white',
        name: 'Open',
      },
      absoluteChange: {
        value: 10,
        color: 'green',
        name: 'Absolute',
      },
      percentageChange: {
        value: '10%',
        color: 'green',
        name: 'Percentage',
      },
    });

    subscribeDataChanged = jest.fn();
    unsubscribeDataChanged = jest.fn();

    mainSeries = {
      getLegendData,
      subscribeDataChanged,
      unsubscribeDataChanged,
    } as unknown as SeriesStrategies;

    eventManager = {
      symbolName: jest.fn().mockReturnValue(new BehaviorSubject('AAPL')),
    } as unknown as EventManager;

    indicators = new BehaviorSubject<Map<string, Indicator>>(new Map());
    mainSeriesSubject = new BehaviorSubject<SeriesStrategies | null>(mainSeries);
  });

  afterEach(() => {
    legend?.destroy();
  });

  function createLegend(): Legend {
    const subscribeChartEvent = jest.fn((event, handler) => {
      if (event === 'crosshairMove') {
        crosshairMoveHandler = handler as (param: MouseEventParams) => void;
      }

      return () => {};
    }) as unknown as LegendParams['subscribeChartEvent'];

    legend = new Legend({
      config,
      eventManager,
      indicators,
      subscribeChartEvent,
      mainSeries: mainSeriesSubject,
      paneId: 1,
      paneIndex: () => 0,
      openIndicatorSettings: jest.fn(),
    });

    return legend;
  }

  function createCrosshairParam(paneIndex = 0): MouseEventParams {
    return {
      point: {
        x: 10,
        y: 20,
      },
      time: 100 as Time,
      paneIndex,
      seriesData: new Map([['main-series', {}]]),
    } as unknown as MouseEventParams;
  }

  function getMainSeriesValues(model: LegendModel) {
    return model[0].values as unknown as Map<
      string,
      {
        value: unknown;
        color: string;
        name?: string;
      }
    >;
  }

  it('должен добавлять абсолютное и относительное изменение в модель основной серии', () => {
    const currentLegend = createLegend();

    let model: LegendModel = [];

    currentLegend.getLegendViewModel().subscribe((value) => {
      model = value;
    });

    const values = getMainSeriesValues(model);

    expect(values.get('absoluteChange')?.value).toBe(10);
    expect(values.get('percentageChange')?.value).toBe('10%');
  });

  it('должен обновлять абсолютное и относительное изменение данными под курсором', () => {
    getLegendData.mockImplementation((param?: MouseEventParams) => {
      if (param) {
        return {
          absoluteChange: {
            value: 20,
            color: 'green',
            name: 'Absolute',
          },
          percentageChange: {
            value: '20%',
            color: 'green',
            name: 'Percentage',
          },
        };
      }

      return {
        absoluteChange: {
          value: 10,
          color: 'green',
          name: 'Absolute',
        },
        percentageChange: {
          value: '10%',
          color: 'green',
          name: 'Percentage',
        },
      };
    });

    const currentLegend = createLegend();

    crosshairMoveHandler?.(createCrosshairParam());

    let model: LegendModel = [];

    currentLegend.getLegendViewModel().subscribe((value) => {
      model = value;
    });

    const values = getMainSeriesValues(model);

    expect(values.get('absoluteChange')?.value).toBe(20);
    expect(values.get('percentageChange')?.value).toBe('20%');
  });

  it('должен отображать тултип при наведении на текущий pane', () => {
    const currentLegend = createLegend();

    let visible = false;
    let position = null;

    currentLegend.subscribeCursorVisability((value) => {
      visible = value;
    });

    currentLegend.subscribeCursorPosition((value) => {
      position = value;
    });

    crosshairMoveHandler?.(createCrosshairParam());

    expect(visible).toBe(true);
    expect(position).toEqual({
      x: 10,
      y: 20,
    });
  });

  it('должен скрывать тултип при наведении на другой pane', () => {
    const currentLegend = createLegend();

    let visible = true;

    currentLegend.subscribeCursorVisability((value) => {
      visible = value;
    });

    crosshairMoveHandler?.(createCrosshairParam(1));

    expect(visible).toBe(false);
  });

  it('должен подписываться и отписываться от изменений основной серии', () => {
    const currentLegend = createLegend();

    expect(subscribeDataChanged).toHaveBeenCalledTimes(1);

    currentLegend.destroy();
    legend = null;

    expect(unsubscribeDataChanged).toHaveBeenCalledTimes(1);
  });

  it('должен возвращать конфигурацию легенды', () => {
    const currentLegend = createLegend();

    expect(currentLegend.getConfig()).toBe(config);
  });
});