Загрузка данных
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 { indicatorLabelById } from '@src/core/Indicators';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { CompareMode, IndicatorLabel, IndicatorsIds, OHLCConfig } from '@src/types';
import { getIndicatorSeriesLabel } 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;
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 {
symbol: 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<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;
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,
openIndicatorSettings,
}: LegendParams) {
this.config = config;
this.eventManager = eventManager;
this.paneId = paneId;
this.openIndicatorSettings = openIndicatorSettings;
if (!mainSeries) {
this.subscriptions.add(
indicators.subscribe((value: Map<IndicatorsIds, 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.mainSeries.options().title,
values: series as Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>,
isIndicator: false,
});
}
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;
if (!value) continue;
indicatorSeries.set(serieName, { ...value, name: getIndicatorSeriesLabel(indicatorName, serieName) });
}
model.push({
id: indicator.getId(),
name: indicatorLabelById[indicatorName] ?? indicatorName,
values: indicatorSeries as Partial<
Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>
>,
isIndicator: true,
remove: () => indicator.delete(),
settings: indicator.hasSettings() ? () => this.openIndicatorSettings(indicatorName, 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 (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({
id: `main-series-${this.paneId}`,
name: this.mainSeries.options().title,
values: series as Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>,
isIndicator: false,
});
}
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 = serieData.value ?? serieData.close;
if (!value) continue;
indicatorSeries.set(serieName, { ...value, name: getIndicatorSeriesLabel(indicatorName, serieName) });
}
model.push({
id: indicator.getId(),
name: indicatorLabelById[indicatorName] ?? indicatorName,
values: indicatorSeries as Partial<
Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>
>,
isIndicator: true,
remove: () => indicator.delete(),
settings: indicator.hasSettings() ? () => this.openIndicatorSettings(indicatorName, 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 { IChartApi, IPaneApi, 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, DrawingsNames } from '@core/DrawingsManager';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { Legend } from '@core/Legend';
import { ReactRenderer } from '@core/ReactRenderer';
import { TooltipService } from '@core/Tooltip';
import { UIRenderer } from '@core/UIRenderer';
import { IndicatorSettingsModal } from '@src/components/IndicatorSettingsModal';
import { indicatorLabelById } from '@src/core/Indicators';
import { ModalRenderer } from '@src/core/ModalRenderer';
import { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { IndicatorsIds, OHLCConfig, TooltipConfig } from '@src/types';
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;
}
// todo: Pane, ему должна принадлежать mainSerie, а также IndicatorManager и drawingsManager, mouseEvents. Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
// todo: Учитывать, что есть линейка, которая рисуется одна для всех пейнов
// todo: в CompareManage, при создании нового пейна для сравнения - инициализируем новый dataSource, принадлежащий только конкретному пейну. Убираем возможность добавлять индикаторы на такие пейны
// todo: на каждый символ свой DataSource (учитывать что есть MainPane и "главный" DataSource, который инициализиурется во время старта moexChart)
// todo: сделать два разных представления для compare, в зависимости от отображения на главном пейне или на второстепенном
export class Pane {
private readonly id: number;
private isMain: boolean;
private mainSeries: BehaviorSubject<SeriesStrategies | null> = new BehaviorSubject<SeriesStrategies | null>(null); // Main Series. Exists in a single copy
private legend!: Legend;
private tooltip: TooltipService | undefined;
private indicatorsMap: BehaviorSubject<Map<IndicatorsIds, Indicator>> = new BehaviorSubject<
Map<IndicatorsIds, Indicator>
>(new Map());
private lwcPane: IPaneApi<Time>;
private lwcChart: IChartApi;
private eventManager: EventManager;
private drawingsManager: DrawingsManager;
private legendContainer!: HTMLElement;
private paneOverlayContainer!: HTMLElement;
private legendRenderer!: UIRenderer;
private tooltipRenderer: UIRenderer | undefined;
private modalRenderer: ModalRenderer;
private mainSerieSub!: Subscription;
private subscribeChartEvent: ChartMouseEvents['subscribe'];
private onDelete: () => void;
private subscriptions = new Subscription();
constructor({
lwcChart,
eventManager,
dataSource,
DOM,
isMainPane,
ohlcConfig,
id,
basedOn,
subscribeChartEvent,
tooltipConfig,
onDelete,
chartContainer,
modalRenderer,
}: PaneParams) {
this.onDelete = onDelete;
this.eventManager = eventManager;
this.lwcChart = lwcChart;
this.modalRenderer = modalRenderer;
this.subscribeChartEvent = subscribeChartEvent;
this.isMain = isMainPane ?? false;
this.id = id;
this.initializeLegend({ ohlcConfig });
if (isMainPane) {
this.lwcPane = this.lwcChart.panes()[this.id];
} else {
this.lwcPane = this.lwcChart.addPane(true);
}
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,
});
this.subscriptions.add(
// todo: переедет в пейн
this.drawingsManager.entities().subscribe((drawings) => {
const hasRuler = drawings.some((drawing) => drawing.id.startsWith(DrawingsNames.ruler));
this.legendContainer.style.display = hasRuler ? 'none' : '';
}),
);
}
public getMainSerie = () => {
return this.mainSeries;
};
public getId = () => {
return this.id;
};
public setIndicator(indicatorName: IndicatorsIds, indicator: Indicator): void {
const map = this.indicatorsMap.value;
map.set(indicatorName, indicator);
this.indicatorsMap.next(map);
}
public removeIndicator(indicatorName: IndicatorsIds): void {
const map = this.indicatorsMap.value;
map.delete(indicatorName);
this.indicatorsMap.next(map);
if (map.size === 0 && !this.isMain) {
this.destroy();
}
}
public getDrawingManager(): DrawingsManager {
return this.drawingsManager;
}
private initializeLegend({ ohlcConfig }: { ohlcConfig: OHLCConfig }) {
const { legendContainer, paneOverlayContainer } = ContainerManager.createPaneContainers();
this.legendContainer = legendContainer;
this.paneOverlayContainer = paneOverlayContainer;
this.legendRenderer = new ReactRenderer(legendContainer);
requestAnimationFrame(() => {
setTimeout(() => {
const lwcPaneElement = this.lwcPane.getHTMLElement();
if (!lwcPaneElement) return;
lwcPaneElement.style.position = 'relative';
lwcPaneElement.appendChild(legendContainer);
lwcPaneElement.appendChild(paneOverlayContainer);
}, 0);
});
// 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,
openIndicatorSettings: (indicatorId, indicator) => {
let settings = indicator.getSettings();
this.modalRenderer.renderComponent(
<IndicatorSettingsModal
fields={indicator.getSettingsConfig()}
values={settings}
onChange={(nextSettings) => {
settings = nextSettings;
}}
/>,
{
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 }) {
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);
});
}
public destroy() {
this.subscriptions.unsubscribe();
this.tooltip?.destroy();
this.legend?.destroy();
this.legendRenderer.destroy();
this.tooltipRenderer?.destroy();
this.indicatorsMap.complete();
this.mainSerieSub?.unsubscribe();
try {
this.lwcChart.removePane(this.id);
} catch (e) {
console.log(e);
}
this.onDelete();
}
}