Загрузка данных
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 { IndicatorsIds } from '@src/constants';
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, 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.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 ?? {},
initialIndicators: config.initialIndicators,
});
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
}
addIndicatorToChart={(indicatorsType: IndicatorsIds) =>
this.chart.getIndicatorManager().addIndicator(indicatorsType)
}
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 { IChartApi, ISeriesApi, SeriesType } from 'lightweight-charts';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { EventManager } from '@core';
import { DOMModel } from '@core/DOMModel';
import { Drawing } from '@core/Drawings';
import { ISeriesDrawing } from '@core/Drawings/common';
import { Ray } from '@core/Drawings/ray';
import { TrendLine } from '@core/Drawings/trendLine';
import { VolumeProfile } from '@core/Drawings/volumeProfile';
import { drawingLabelById, DrawingsNames } from '@src/constants';
import { AxisLine } from '@src/core/Drawings/axisLine';
import { Diapson } from '@src/core/Drawings/diapson';
import { Rectangle } from '@src/core/Drawings/rectangle';
import { Ruler } from '@src/core/Drawings/ruler';
import { SliderPosition } from '@src/core/Drawings/sliderPosition';
import { Traectory } from '@src/core/Drawings/traectory';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
interface DrawingsManagerParams {
eventManager: EventManager;
mainSeries$: Observable<SeriesStrategies | null>;
lwcChart: IChartApi;
// chartOptions?: ChartTypeOptions;
DOM: DOMModel;
container: HTMLElement;
}
export interface DrawingParams {
chart: IChartApi;
series: ISeriesApi<SeriesType>;
eventManager: EventManager;
container: HTMLElement;
removeSelf: () => void;
}
export interface DrawingConfig {
singleInstance?: boolean;
construct: (params: DrawingParams) => ISeriesDrawing;
}
export const drawingsMap: Record<DrawingsNames, DrawingConfig> = {
[DrawingsNames.trendLine]: {
construct: ({ chart, series, container, eventManager, removeSelf }) => {
return new TrendLine(chart, series, {
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
});
},
},
[DrawingsNames.ray]: {
construct: ({ chart, series, container, eventManager, removeSelf }) => {
return new Ray(chart, series, {
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
});
},
},
[DrawingsNames.horizontalLine]: {
construct: ({ chart, series, eventManager, container, removeSelf }) => {
return new AxisLine(chart, series, {
direction: 'horizontal',
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
});
},
},
[DrawingsNames.horizontalRay]: {
construct: ({ chart, series, container, eventManager, removeSelf }) => {
return new TrendLine(chart, series, {
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
});
},
},
[DrawingsNames.verticalLine]: {
construct: ({ chart, series, eventManager, container, removeSelf }) => {
return new AxisLine(chart, series, {
direction: 'vertical',
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
});
},
},
[DrawingsNames.sliderLong]: {
construct: ({ chart, series, eventManager, container, removeSelf }) => {
return new SliderPosition(chart, series, {
side: 'long',
container,
formatObservable: eventManager.getChartOptionsModel(),
resetTriggers: [eventManager.getTimeframeObs(), eventManager.getInterval()],
removeSelf,
});
},
},
[DrawingsNames.sliderShort]: {
construct: ({ chart, series, eventManager, container, removeSelf }) => {
return new SliderPosition(chart, series, {
side: 'short',
container,
formatObservable: eventManager.getChartOptionsModel(),
resetTriggers: [eventManager.getTimeframeObs(), eventManager.getInterval()],
removeSelf,
});
},
},
[DrawingsNames.diapsonDates]: {
construct: ({ chart, series, container, eventManager, removeSelf }) => {
return new Diapson(chart, series, {
rangeMode: 'date',
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
});
},
},
[DrawingsNames.diapsonPrices]: {
construct: ({ chart, series, container, eventManager, removeSelf }) => {
return new Diapson(chart, series, {
rangeMode: 'price',
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
});
},
},
[DrawingsNames.fixedProfile]: {
construct: ({ chart, series, container, eventManager, removeSelf }) => {
return new VolumeProfile(chart, series, {
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
});
},
},
[DrawingsNames.rectangle]: {
construct: ({ chart, series, eventManager, container, removeSelf }) => {
return new Rectangle(chart, series, {
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
});
},
},
[DrawingsNames.ruler]: {
singleInstance: true,
construct: ({ chart, series, eventManager, removeSelf }) => {
return new Ruler(chart, series, {
formatObservable: eventManager.getChartOptionsModel(),
resetTriggers: [eventManager.getTimeframeObs(), eventManager.getInterval()],
removeSelf,
});
},
},
[DrawingsNames.traectory]: {
construct: ({ chart, series, eventManager, removeSelf, container }) => {
return new Traectory(chart, series, {
formatObservable: eventManager.getChartOptionsModel(),
container,
removeSelf,
});
},
},
};
export class DrawingsManager {
private eventManager: EventManager;
private lwcChart: IChartApi;
// private chartOptions?: ChartTypeOptions;
private drawingsQty = 0; // todo: replace with hash
private DOM: DOMModel;
private container: HTMLElement;
private mainSeries: SeriesStrategies | null = null;
private subscriptions = new Subscription();
private drawings$: BehaviorSubject<Drawing[]> = new BehaviorSubject<Drawing[]>([]);
constructor({ eventManager, mainSeries$, lwcChart, DOM, container }: DrawingsManagerParams) {
this.DOM = DOM;
this.eventManager = eventManager;
this.lwcChart = lwcChart;
// this.chartOptions = chartOptions;
this.container = container;
this.subscriptions.add(
mainSeries$.subscribe((s) => {
if (!s) return;
this.mainSeries = s;
this.drawings$.value.forEach((drawing) => drawing.rebind(s));
}),
);
}
public addDrawing(name: DrawingsNames) {
if (!this.mainSeries) {
throw new Error('[Drawings] main series is not defined');
}
this.removePendingDrawings();
const config = drawingsMap[name];
if (config.singleInstance) {
this.removeDrawingsByName(name);
}
const drawingId = `${name}${this.drawingsQty}`;
const construct = (chart: IChartApi, series: ISeriesApi<SeriesType>) =>
config.construct({
chart,
series,
eventManager: this.eventManager,
container: this.container,
removeSelf: () => this.removeDrawing(drawingId),
});
const drawing = (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) =>
new Drawing({
lwcChart: this.lwcChart,
mainSeries: this.mainSeries as SeriesStrategies,
id: drawingId,
name: drawingLabelById[name],
onDelete: this.removeDrawing,
zIndex,
moveDown,
moveUp,
construct,
});
const trendLine = this.DOM.setEntity<Drawing>(drawing);
this.drawingsQty++;
this.drawings$.next([...this.drawings$.value, trendLine]);
}
private removeDrawing = (id: string) => {
const drawings = this.drawings$.value;
const objectToRemove = drawings.find((d) => d.id === id);
if (!objectToRemove) {
return;
}
this.removeDrawings([objectToRemove]);
};
private removeDrawingsByName(name: DrawingsNames): void {
const drawingsToRemove = this.drawings$.value.filter((drawing) => drawing.id.startsWith(name));
this.removeDrawings(drawingsToRemove);
}
private removePendingDrawings(): void {
const drawingsToRemove = this.drawings$.value.filter((drawing) => drawing.isCreationPending());
this.removeDrawings(drawingsToRemove);
}
private removeDrawings(drawingsToRemove: Drawing[]): void {
if (!drawingsToRemove.length) {
return;
}
drawingsToRemove.forEach((drawing) => {
drawing.destroy();
this.DOM.removeEntity(drawing);
});
this.drawings$.next(this.drawings$.value.filter((drawing) => !drawingsToRemove.includes(drawing)));
}
public entities(): Observable<Drawing[]> {
return this.drawings$.asObservable();
}
public getDrawings() {}
public hideAll() {}
}
import { IChartApi, ISeriesApi, SeriesType } from 'lightweight-charts';
import { DOMObject, DOMObjectParams } from '@core/DOMObject';
import { ISeriesDrawing } from '@core/Drawings/common';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
type IDrawing = DOMObject;
interface DrawingParams extends DOMObjectParams {
lwcChart: IChartApi;
mainSeries: SeriesStrategies;
onDelete: (id: string) => void;
construct: (chart: IChartApi, series: ISeriesApi<SeriesType>) => ISeriesDrawing;
}
export class Drawing extends DOMObject implements IDrawing {
private lwcDrawing: ISeriesDrawing;
private mainSeries: SeriesStrategies;
constructor({ lwcChart, name, mainSeries, id, onDelete, zIndex, moveUp, moveDown, construct }: DrawingParams) {
super({ id, name, zIndex, onDelete, moveUp, moveDown });
this.lwcDrawing = construct(lwcChart, mainSeries);
this.onDelete = onDelete;
this.mainSeries = mainSeries;
}
public delete() {
this.destroy();
super.delete();
}
public getLwcDrawing() {
return this.lwcDrawing;
}
public show() {
this.lwcDrawing.show();
super.show();
}
public hide() {
this.lwcDrawing.hide();
super.hide();
}
public rebind = (nextMainSeries: SeriesStrategies) => {
this.lwcDrawing.rebind(nextMainSeries);
this.mainSeries = nextMainSeries;
};
public isCreationPending(): boolean {
return this.lwcDrawing.isCreationPending();
}
public destroy() {
this.mainSeries.detachPrimitive(this.lwcDrawing);
this.lwcDrawing.destroy();
}
}