Загрузка данных
import { PriceScaleMode, SeriesType } from 'lightweight-charts';
import { Indicator } from '@core/Indicator';
import { emaIndicator } from '@core/Indicators/ema';
import { macdHist, macdLine, macdSignal } from '@core/Indicators/macd';
import { smaIndicator } from '@core/Indicators/sma';
import { volume } from '@core/Indicators/volume';
import { SerieData } from '@core/Series/BaseSeries';
import { Candle } from '@lib';
import { getThemeStore } from '@src/theme';
import { Direction, IndicatorConfig, IndicatorSettings, IndicatorsIds, LineCandle } from '@src/types';
export type ChartTypeToCandleData = {
['Bar']: Candle;
['Candlestick']: Candle;
['Area']: LineCandle;
['Baseline']: LineCandle;
['Line']: LineCandle;
['Histogram']: LineCandle;
['Custom']: Candle;
};
export interface IndicatorDataFormatter<T extends SeriesType> {
mainSeriesData: SerieData[];
selfData: ChartTypeToCandleData[T][];
candle?: SerieData;
indicatorReference?: Indicator;
settings?: IndicatorSettings;
}
export const indicatorLabelById = {
[IndicatorsIds.Volume]: 'Объём',
[IndicatorsIds.SMA]: 'SMA',
[IndicatorsIds.EMA]: 'EMA',
[IndicatorsIds.MACD]: 'MACD',
} as const satisfies Record<IndicatorsIds, string>;
export const indicatorsMap: Partial<Record<IndicatorsIds, IndicatorConfig>> = {
[IndicatorsIds.Volume]: {
series: [
{
name: 'Histogram',
id: 'volume',
priceScaleOptions: {
scaleMargins: { top: 0.7, bottom: 0 },
},
seriesOptions: {
priceScaleId: 'vol',
priceFormat: {
type: 'volume',
},
},
dataFormatter: (params) => volume(params as IndicatorDataFormatter<'Histogram'>),
},
],
},
[IndicatorsIds.SMA]: {
series: [
{
name: 'Line', // todo: change with enum
id: 'sma',
seriesOptions: {
color: getThemeStore().colors.indicatorLineSma,
},
dataFormatter: (params) => smaIndicator(params as IndicatorDataFormatter<'Line'>, 10),
},
],
settings: [
{ type: 'number', key: 'length', label: 'Длина', defaultValue: 10, min: 1 },
{
type: 'select',
key: 'source',
label: 'Данные',
defaultValue: 'close',
options: [
{ label: 'Цена открытия', value: 'open' },
{ label: 'Максимум', value: 'high' },
{ label: 'Минимум', value: 'low' },
{ label: 'Цена закрытия', value: 'close' },
],
},
{ type: 'number', key: 'offset', label: 'Отступ', defaultValue: 0, min: -100, max: 100 },
],
},
[IndicatorsIds.EMA]: {
series: [
{
name: 'Line', // todo: change with enum
id: 'ema',
seriesOptions: {
color: getThemeStore().colors.indicatorLineEma,
},
dataFormatter: (params) => {
return emaIndicator(params as IndicatorDataFormatter<'Line'>, 25);
},
},
],
settings: [
{ type: 'number', key: 'length', label: 'Длина', defaultValue: 10, min: 1 },
{
type: 'select',
key: 'source',
label: 'Данные',
defaultValue: 'close',
options: [
{ label: 'Цена открытия', value: 'open' },
{ label: 'Максимум', value: 'high' },
{ label: 'Минимум', value: 'low' },
{ label: 'Цена закрытия', value: 'close' },
],
},
{ type: 'number', key: 'offset', label: 'Отступ', defaultValue: 0, min: -100, max: 100 },
],
},
[IndicatorsIds.MACD]: {
newPane: true,
series: [
{
name: 'Line', // todo: change with enum
id: 'longEma',
dataFormatter: (params) => {
return emaIndicator(params as IndicatorDataFormatter<'Line'>, 26);
},
seriesOptions: {
priceScaleId: 'macd_emas',
visible: false,
lastValueVisible: false,
color: getThemeStore().colors.chartPriceLineText,
},
},
{
name: 'Line', // todo: change with enum
id: 'shortEma',
dataFormatter: (params) => {
return emaIndicator(params as IndicatorDataFormatter<'Line'>, 12);
},
seriesOptions: {
priceScaleId: 'macd_emas',
visible: false,
lastValueVisible: false,
color: getThemeStore().colors.chartPriceLineText,
},
},
{
name: 'Line', // todo: change with enum
id: 'macd',
priceScaleOptions: {
mode: PriceScaleMode.Normal,
},
dataFormatter: (params) => macdLine(params as IndicatorDataFormatter<'Line'>),
seriesOptions: {
priceScaleId: Direction.Right,
lastValueVisible: false,
color: getThemeStore().colors.indicatorLineSma,
},
},
{
name: 'Line', // todo: change with enum
id: 'macd_signal',
priceScaleOptions: {
mode: PriceScaleMode.Normal,
},
dataFormatter: (params) => macdSignal(params as IndicatorDataFormatter<'Line'>),
seriesOptions: {
priceScaleId: Direction.Right,
lastValueVisible: false,
color: getThemeStore().colors.chartCandleWickUp,
},
},
{
name: 'Histogram', // todo: change with enum
id: 'hist',
priceScaleOptions: {
autoScale: true,
},
seriesOptions: {
priceScaleId: Direction.Right,
lastValueVisible: false,
},
dataFormatter: (params) => macdHist(params as IndicatorDataFormatter<'Histogram'>),
},
],
},
};
import { IChartApi } from 'lightweight-charts';
import { BehaviorSubject, Observable, Subscription, throttleTime } from 'rxjs';
import { DataSource } from '@core/DataSource';
import { DOMObject, DOMObjectParams } from '@core/DOMObject';
import { Pane } from '@core/Pane';
import { PaneManager } from '@core/PaneManager';
import { indicatorsMap } from '@src/core/Indicators';
import { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ChartTypeOptions, IndicatorConfig, IndicatorsIds } from '@src/types';
import { ensureDefined } from '@src/utils';
type IIndicator = DOMObject;
export interface IndicatorParams extends DOMObjectParams {
mainSymbol$: Observable<string>;
lwcChart: IChartApi;
dataSource: DataSource;
paneManager: PaneManager;
chartOptions?: ChartTypeOptions;
config?: IndicatorConfig;
}
export class Indicator extends DOMObject implements IIndicator {
private series: SeriesStrategies[] = [];
private seriesMap: Map<string, SeriesStrategies> = new Map();
private lwcChart: IChartApi;
private associatedPane: Pane;
private subscriptions = new Subscription();
constructor({
id,
lwcChart,
dataSource,
zIndex,
onDelete,
moveUp,
moveDown,
mainSymbol$,
paneManager,
config,
}: IndicatorParams) {
super({ id: id as string, zIndex, onDelete, moveUp, moveDown });
this.lwcChart = lwcChart;
const indicatorConfig = indicatorsMap[id as IndicatorsIds] ?? config;
const { series, newPane } = ensureDefined(indicatorConfig);
this.associatedPane = newPane ? paneManager.addPane() : paneManager.getMainPane();
series.forEach(({ name, id: serieId, dataFormatter, seriesOptions, priceScaleOptions }) => {
const serie = SeriesFactory.create(name!)({
lwcChart,
dataSource,
customFormatter: dataFormatter,
seriesOptions,
priceScaleOptions,
mainSymbol$,
mainSerie$: this.associatedPane.getMainSerie(),
showSymbolLabel: false,
paneIndex: this.associatedPane.getId(),
indicatorReference: this,
});
this.seriesMap.set(serieId, serie);
this.series.push(serie);
});
this.associatedPane.setIndicator(id as IndicatorsIds, this);
}
public subscribeDataChange(handler: () => void): Subscription {
// todo: возможно нужно переписать завязавшись гна this.associatedPane.getMainSerie()
const firer = new BehaviorSubject<number>(0);
this.series.forEach((serie) => {
const handle = () => {
firer.next(firer.value + 1);
};
serie.subscribeDataChanged(handle);
this.subscriptions.add(() => serie.unsubscribeDataChanged(handle));
});
return firer.pipe(throttleTime(500)).subscribe(() => {
handler();
});
}
public getPane(): Pane {
return this.associatedPane;
}
public getSeriesMap(): Map<string, SeriesStrategies> {
return this.seriesMap;
}
public show() {
this.series.forEach((s) => {
s.show();
});
super.show();
}
public hide() {
this.series.forEach((s) => {
s.hide();
});
super.hide();
}
// destroy и delete принципиально отличаются!
// delete вызовет destroy в конце концов. По сути - это destroy с сайд-эффектом в eventManager
public delete() {
super.delete();
}
// destroy и delete принципиально отличаются!
public destroy() {
this.subscriptions.unsubscribe();
this.series.forEach((s) => {
s.destroy();
});
this.associatedPane.removeIndicator(this.id as IndicatorsIds);
}
}
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';
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: IndicatorLabel | 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;
private subscriptions = new Subscription();
constructor({ config, eventManager, indicators, subscribeChartEvent, mainSeries, paneId }: LegendParams) {
this.config = config;
this.eventManager = eventManager;
this.paneId = paneId;
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.handleMainSeriesDataChange();
}),
);
}
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 = () => {
for (const [_, indicator] of this.indicators) {
this.subscriptions.add(indicator?.subscribeDataChange(this.updateWithLastCandle));
}
};
private handleMainSeriesDataChange = () => {
const handler = () => {
this.updateWithLastCandle();
};
this.mainSeries?.subscribeDataChanged(handler);
this.subscriptions.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({
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;
indicatorSeries.set(serieName, { ...value });
}
model.push({
name: indicatorLabelById[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,
});
}
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;
indicatorSeries.set(serieName, value);
}
model.push({
name: indicatorLabelById[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.subscriptions.unsubscribe();
this.model$.complete();
this.tooltipVisability.complete();
this.tooltipPos.complete();
};
}
import { IChartApi } from 'lightweight-charts';
import { BehaviorSubject, 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 { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ChartTypeOptions, IndicatorsIds } from '@src/types';
interface SeriesParams {
eventManager: EventManager;
dataSource: DataSource;
mainSerie$: BehaviorSubject<SeriesStrategies | null>;
lwcChart: IChartApi;
paneManager: PaneManager;
DOM: DOMModel;
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<IndicatorsIds, Indicator>> = new BehaviorSubject(new Map());
private DOM: DOMModel;
constructor({ eventManager, dataSource, lwcChart, DOM, chartOptions, mainSerie$, paneManager }: SeriesParams) {
this.eventManager = eventManager;
this.lwcChart = lwcChart;
this.chartOptions = chartOptions;
this.DOM = DOM;
this.eventManager.getUserIndicatorsList().subscribe((ids: Set<IndicatorsIds>) => {
const indicatorsMap = new Map(this.indicatorsMap$.value);
ids.forEach((id: IndicatorsIds) => {
if (!indicatorsMap.has(id)) {
const ind = this.addEntity<Indicator>(
(zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) =>
new Indicator({
id,
lwcChart,
mainSymbol$: this.eventManager.getSymbol(),
dataSource,
chartOptions,
zIndex,
onDelete: this.deleteIndicator,
moveUp,
moveDown,
paneManager,
}),
);
indicatorsMap.set(id, ind);
}
});
// todo: move to deleteSeries
const indicatorsIdsToDelete = Array.from(indicatorsMap.keys()).filter((existingKey) => {
return !ids.has(existingKey);
});
indicatorsIdsToDelete.forEach((s: IndicatorsIds) => {
// todo: move to deleteSeries
const indicatorToDestroy = indicatorsMap.get(s);
if (!indicatorToDestroy) return;
this.removeEntity(indicatorToDestroy);
indicatorToDestroy.destroy();
indicatorsMap.delete(s);
});
this.indicatorsMap$.next(indicatorsMap);
this.entities$.next(Array.from(indicatorsMap.values()));
});
}
public addEntity<T extends Indicator>(
factory: (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) => T,
): T {
return this.DOM.setEntity(factory);
}
public removeEntity(entity: DOMObject): void {
this.DOM.removeEntity(entity);
}
public deleteIndicator = (id: string) => {
this.eventManager.toggleUserIndicator(id as IndicatorsIds);
};
public entities(): Observable<Indicator[]> {
return this.entities$.asObservable();
}
}
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 { 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;
}
// 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 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,
}: PaneParams) {
this.onDelete = onDelete;
this.eventManager = eventManager;
this.lwcChart = lwcChart;
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,
// 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();
}
}
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>();
private panesIdIterator = 0;
constructor(params: PaneManagerParams) {
this.paneChartInheritedParams = { ...params, isMainPane: false };
this.mainPane = new Pane({ ...params, isMainPane: true, id: 0, onDelete: () => {} });
this.panesMap.set(this.panesIdIterator++, this.mainPane);
}
public getPanes() {
return this.panesMap;
}
public getMainPane: () => Pane = () => {
return this.mainPane;
};
public addPane(dataSource?: DataSource): Pane {
const id = this.panesIdIterator++;
const pane = new Pane({
...this.paneChartInheritedParams,
id,
dataSource: dataSource ?? null,
basedOn: dataSource ? undefined : this.mainPane,
onDelete: () => {
this.panesIdIterator--;
this.panesMap.delete(id);
},
});
this.panesMap.set(id, pane);
return pane;
}
public getDrawingsManager(): DrawingsManager {
// todo: temp
return this.mainPane.getDrawingManager();
}
}
import { combineLatest, Subscription } from 'rxjs';
import { ControlBar } from '@components/ControlBar';
import { Footer } from '@components/Footer';
import { Header } from '@components/Header';
import { DataSource, DataSourceParams } from '@core/DataSource';
import { ModalRenderer } from '@core/ModalRenderer';
import { SettingsModal } from '@src/components/SettingsModal';
import Toolbar from '@src/components/Toolbar';
import { CompareManager } from '@src/core/CompareManager';
import { FullscreenController } from '@src/core/Fullscreen';
import { configureThemeStore } from '@src/theme/store';
import { ThemeKey, ThemeMode } from '@src/theme/types';
import { ChartSeriesType, ChartTypeOptions, IndicatorsIds, OHLCConfig, TooltipConfig } from '@src/types';
import { Timeframes } from '@src/types/timeframes';
import { setPricePrecision } from '@src/utils';
import { Chart } from './Chart';
import { ChartSettings, ChartSettingsSource } from './ChartSettings';
import { ContainerManager } from './ContainerManager';
import { EventManager } from './EventManager';
import { ReactRenderer } from './ReactRenderer';
import { TimeScaleHoverController } from './TimescaleHoverController';
import { UIRenderer } from './UIRenderer';
import 'exchange-elements/dist/fonts/inter/font.css';
import 'exchange-elements/dist/style.css';
import 'exchange-elements/dist/tokens/moex.css';
// todo: forbid @lib in /src
export interface IMoexChart {
container: HTMLElement;
supportedTimeframes: Timeframes[];
initialTimeframe: Timeframes;
supportedChartSeriesTypes: ChartSeriesType[];
initialChartSeriesTypes: ChartSeriesType;
initialSymbol: string;
getDataSource: DataSourceParams['getData'];
theme: ThemeKey; // 'mb' | 'mxt' | 'tr'
ohlc: OHLCConfig;
initialIndicators?: IndicatorsIds[];
size?:
| {
width: number;
height: number;
}
| false;
mode?: ThemeMode; // 'light' | 'dark'
undoRedoEnabled?: boolean;
showMenuButton?: boolean;
showBottomPanel?: boolean;
showControlBar?: boolean;
showFullscreenButton?: boolean;
showSettingsButton?: boolean;
showCompareButton?: boolean;
/**
* Дефолтная конфигурация тултипа - всегда показывается по умолчанию.
* При добавлении/изменении полей в конфиге - они объединяются с дефолтными значениями.
*
* Полная кастомизация:
* @example
* ```typescript
* tooltipConfig: {
* time: { visible: true, label: 'Дата и время' },
* symbol: { visible: true, label: 'Инструмент' },
* close: { visible: true, label: 'Курс' },
* change: { visible: true, label: 'Изменение' },
* volume: { visible: true, label: 'Объем' },
* open: { visible: false },
* high: { visible: false },
* low: { visible: false }
* }
*```
*/
chartSettings?: ChartSettingsSource;
chartOptions?: ChartTypeOptions; // todo: разнести по разным полям в соответствии с тиами графика
tooltipConfig?: TooltipConfig;
openCompareModal?: () => void;
}
export class MoexChart {
private chart: Chart;
private resizeObserver?: ResizeObserver;
private eventManager: EventManager;
private rootContainer: HTMLElement;
private headerRenderer: UIRenderer;
private modalRenderer: ModalRenderer;
private toolbarRenderer: UIRenderer | undefined;
private controlBarRenderer?: UIRenderer;
private footerRenderer?: UIRenderer;
private timeScaleHoverController: TimeScaleHoverController;
private dataSource: DataSource;
private subscriptions = new Subscription();
private fullscreen: FullscreenController;
constructor(config: IMoexChart) {
setPricePrecision(config.ohlc.precision);
this.eventManager = new EventManager({
initialTimeframe: config.initialTimeframe,
initialSeries: config.initialChartSeriesTypes,
initialSymbol: config.initialSymbol,
initialChartOptions: config.chartOptions,
});
if (config.initialIndicators) {
this.eventManager.setUserIndicators(config.initialIndicators);
}
if (config.chartSettings) {
this.setSettings(config.chartSettings);
}
this.dataSource = new DataSource({
getData: config.getDataSource,
eventManager: this.eventManager,
});
this.rootContainer = config.container;
this.fullscreen = new FullscreenController(this.rootContainer);
const store = configureThemeStore(config);
const {
chartAreaContainer,
toolBarContainer,
headerContainer,
modalContainer,
controlBarContainer,
footerContainer,
toggleToolbar, // todo: move this function to toolbarRenderer
} = ContainerManager.createContainers({
parentContainer: this.rootContainer,
showBottomPanel: config.showBottomPanel, // todo: apply config.showBottomPanel in FullscreenController
showMenuButton: config.showMenuButton,
});
this.modalRenderer = new ModalRenderer(modalContainer);
this.chart = new Chart({
eventManager: this.eventManager,
modalRenderer: this.modalRenderer,
container: chartAreaContainer,
theme: store.theme,
mode: store.mode,
seriesTypes: config.supportedChartSeriesTypes,
dataSource: this.dataSource,
chartOptions: config.chartOptions, // todo: remove, use only model from eventManager
ohlcConfig: config.ohlc, // todo: omptimize
tooltipConfig: config.tooltipConfig ?? {},
});
this.subscriptions.add(
combineLatest([store.theme$, store.mode$]).subscribe(([theme, mode]) => {
this.chart.updateTheme(theme, mode);
document.documentElement.dataset.theme = theme;
document.documentElement.dataset.mode = mode;
}),
);
this.headerRenderer = new ReactRenderer(headerContainer);
this.toolbarRenderer = new ReactRenderer(toolBarContainer);
if (config.showControlBar) {
this.controlBarRenderer = new ReactRenderer(controlBarContainer);
}
if (config.showBottomPanel) {
this.footerRenderer = new ReactRenderer(footerContainer);
}
this.headerRenderer.renderComponent(
<Header
timeframes={config.supportedTimeframes}
selectedTimeframeObs={this.eventManager.getTimeframeObs()}
setTimeframe={(value) => {
this.eventManager.setTimeframe(value);
}}
seriesTypes={config.supportedChartSeriesTypes}
selectedSeriesObs={this.eventManager.getSelectedSeries()}
setSelectedSeries={(value) => {
this.eventManager.setSeriesSelected(value);
}}
showSettingsModal={
config.showSettingsButton
? () =>
this.modalRenderer.renderComponent(
<SettingsModal
// todo: deal with onSave
changeTimeFormat={(format) => this.eventManager.setTimeFormat(format)}
changeDateFormat={(format) => this.eventManager.setDateFormat(format)}
chartDateTimeFormatObs={this.eventManager.getChartOptionsModel()}
/>,
{ title: 'Настройки' },
)
: undefined
}
toggleUserIndicator={this.eventManager.toggleUserIndicator}
activeIndicatorsObs={this.eventManager.getUserIndicatorsListArr()}
showMenuButton={config.showMenuButton}
showFullscreenButton={config.showFullscreenButton}
fullscreen={this.fullscreen}
undoRedo={config.undoRedoEnabled ? this.eventManager.getUndoRedo() : undefined}
toggleToolbarVisible={toggleToolbar}
showCompareButton={!!config.showCompareButton}
openCompareModal={config.openCompareModal ? config.openCompareModal : undefined}
isMXT={config.theme === 'mxt'}
/>,
);
this.timeScaleHoverController = new TimeScaleHoverController({
eventManager: this.eventManager,
controlBarContainer,
chartContainer: chartAreaContainer,
});
if (config.showMenuButton) {
this.toolbarRenderer.renderComponent(
<Toolbar
toggleDOM={this.chart.getDom().toggleDOM}
addDrawing={(name) => {
// todo: deal with new panes logic
this.chart.getDrawingsManager().addDrawing(name);
}}
/>,
);
}
if (this.controlBarRenderer && config.showControlBar) {
this.controlBarRenderer.renderComponent(
<ControlBar
scroll={this.chart.scrollTimeScale}
zoom={this.chart.zoomTimeScale}
reset={this.chart.resetZoom}
visible={this.eventManager.getControlBarVisible()}
/>,
);
}
if (this.footerRenderer && config.showBottomPanel) {
this.footerRenderer.renderComponent(
<Footer
supportedTimeframes={config.supportedTimeframes}
setInterval={this.eventManager.setInterval}
intervalObs={this.eventManager.getInterval()}
/>,
);
}
}
public setSettings(settings: ChartSettingsSource): void {
this.eventManager.importChartSettings(settings);
}
public getSettings(): ChartSettings {
return this.eventManager.exportChartSettings();
}
public getRealtimeApi() {
return this.chart.getRealtimeApi();
}
// todo: описать в доке
public getCompareManager(): CompareManager {
return this.chart.getCompareManager();
}
public setSymbol(symbol: string): void {
if (!symbol) return;
this.eventManager.setSymbol(symbol);
}
/**
* Уничтожение графика и очистка ресурсов
* @returns void
*/
destroy(): void {
this.headerRenderer.destroy();
this.subscriptions.unsubscribe();
this.timeScaleHoverController.destroy();
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = undefined;
}
if (this.controlBarRenderer) {
this.controlBarRenderer.destroy();
}
if (this.footerRenderer) {
this.footerRenderer.destroy();
}
if (this.chart) {
this.chart.destroy();
}
if (this.eventManager) {
this.eventManager.destroy();
}
ContainerManager.clearContainers(this.rootContainer);
}
}
import { ReactElement } from 'react';
import { Modal, ModalProps } from '@components/Modal';
import { ReactRenderer } from '@core/ReactRenderer';
export class ModalRenderer extends ReactRenderer {
constructor(container: HTMLElement) {
super(container);
}
public override renderComponent(component: ReactElement, props?: ModalProps): void {
if (!props) {
throw new Error('[Modal]: modal props are not defined');
}
super.renderComponent(
<Modal
{...props}
onHide={this.clear}
appendTo={this.container as HTMLElement}
>
{component}
</Modal>,
);
}
}
import { Button } from 'exchange-elements/v2';
import { Observable } from 'rxjs';
import { TrashIcon } from '@components/Icon';
import { LegendModel, ohlcValuesToShowForMainSerie } from '@core/Legend';
import { OHLCConfig } from '@src/types';
import { formatDisplayText, useObservable } from '@src/utils';
import styles from './index.module.scss';
export interface LegendProps {
ohlcConfig?: OHLCConfig;
viewModel: Observable<LegendModel>;
}
const mainSerieKeys = new Set(ohlcValuesToShowForMainSerie);
function getEntries(values: LegendModel[number]['values']) {
return values instanceof Map ? Array.from(values.entries()) : Object.entries(values);
}
export const LegendComponent = ({ ohlcConfig, viewModel }: LegendProps) => {
const model = useObservable(viewModel);
const showMainSerieValues = Boolean(ohlcConfig?.show);
return (
<section className={styles.legend}>
{model?.map((item) => (
<div
key={`legend-${item.name}`}
className={styles.row}
>
<div className={styles.symbol}>{item.name}</div>
{getEntries(item.values).map(([key, value]) => {
if (!item.isIndicator && (!showMainSerieValues || !mainSerieKeys.has(key))) {
return null;
}
const text = formatDisplayText(value.value);
if (!text) {
return null;
}
return (
<div
key={`${item.name}-${key}`}
className={styles.item}
>
{value.name && <span>{value.name}</span>}
<span style={{ color: value.color }}>{text}</span>
</div>
);
})}
{item.remove && (
<Button
size="sm"
className={styles.button}
onClick={item.remove}
label={<TrashIcon />}
/>
)}
</div>
))}
</section>
);
};