Загрузка данных
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.tooltipVisability.next(false);
this.isChartHovered = false;
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 dataChangedHandler: (() => 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;
dataChangedHandler = 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((handler: () => void) => {
dataChangedHandler = handler;
});
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 createEmptyAreaCrosshairParam(): MouseEventParams {
return {
...createCrosshairParam(),
seriesData: new Map(),
} 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('должен отображать последние данные при переходе курсора в область без данных', () => {
let lastOpen = 100;
getLegendData.mockImplementation((param?: MouseEventParams) => {
if (param) {
return {
open: {
value: 50,
color: 'white',
name: 'Open',
},
};
}
return {
open: {
value: lastOpen,
color: 'white',
name: 'Open',
},
};
});
const currentLegend = createLegend();
let model: LegendModel = [];
let visible = false;
currentLegend.getLegendViewModel().subscribe((value) => {
model = value;
});
currentLegend.subscribeCursorVisability((value) => {
visible = value;
});
crosshairMoveHandler?.(createCrosshairParam());
expect(getMainSeriesValues(model).get('open')?.value).toBe(50);
expect(visible).toBe(true);
crosshairMoveHandler?.(createEmptyAreaCrosshairParam());
expect(getMainSeriesValues(model).get('open')?.value).toBe(100);
expect(visible).toBe(false);
lastOpen = 110;
dataChangedHandler?.();
expect(getMainSeriesValues(model).get('open')?.value).toBe(110);
});
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);
});
});