Загрузка данных
import { combineLatest, Subscription } from 'rxjs';
import { ControlBar } from '@components/ControlBar';
import { Footer } from '@components/Footer';
import { Header } from '@components/Header';
import { LegendComponent } from '@components/Legend';
import { DataSource, DataSourceParams } from '@core/DataSource';
import { Legend } from '@core/Legend';
import { ModalRenderer } from '@core/ModalRenderer';
import { TooltipService } from '@core/Tooltip';
import { ChartTooltip } from '@src/components/ChartTooltip';
import { SettingsModal } from '@src/components/SettingsModal';
import Toolbar from '@src/components/Toolbar';
import { CompareManager } from '@src/core/CompareManager';
import { DrawingsNames } from '@src/core/DrawingsManager';
import { FullscreenController } from '@src/core/Fullscreen';
import { IndicatorsIds, labelByIndicatorId } from '@src/core/Indicators';
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 { HoverController } from './HoverController';
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';
import { ReactElement } from 'react';
import { IndicatorSettingsModal } from '@src/components/IndicatorSettingsModal';
import { ModalProps } from '@src/components/Modal';
import { isEditableMaIndicator } from '@src/core/Indicators/settings';
// 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 legend: Legend;
private rootContainer: HTMLElement;
private headerRenderer: UIRenderer;
private legendRenderer: UIRenderer;
private modalRenderer: ModalRenderer;
private tooltipRenderer: UIRenderer | undefined;
private toolbarRenderer: UIRenderer | undefined;
private controlBarRenderer?: UIRenderer;
private footerRenderer?: UIRenderer;
private hoverController: HoverController;
private timeScaleHoverController: TimeScaleHoverController;
private dataSource: DataSource;
private subscriptions = new Subscription();
private tooltip: TooltipService | undefined;
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,
legendContainer,
modalContainer,
overlayContainer,
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
});
this.legend = new Legend({
config: config.ohlc,
dataSource: this.dataSource,
eventManager: this.eventManager,
mainSeriesObs: this.chart.getMainSeries(),
compareManager: this.chart.getCompareManager(),
indicatorManager: this.chart.getIndicatorManager(),
});
if (config.tooltipConfig) {
this.tooltip = new TooltipService({ config: config.tooltipConfig, legend: this.legend });
}
/*
Внутри 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.subscriptions.add(
combineLatest([store.theme$, store.mode$]).subscribe(([t, m]) => {
this.chart.updateTheme(t, m);
document.documentElement.dataset.theme = t;
document.documentElement.dataset.mode = m;
}),
);
this.subscriptions.add(
this.chart
.getDrawingsManager()
.entities()
.subscribe((drawings) => {
const hasRuler = drawings.some((drawing) => drawing.id.startsWith(DrawingsNames.ruler));
legendContainer.style.display = hasRuler ? 'none' : '';
}),
);
this.headerRenderer = new ReactRenderer(headerContainer);
this.legendRenderer = new ReactRenderer(legendContainer);
if (this.tooltip) {
this.tooltipRenderer = new ReactRenderer(overlayContainer);
}
this.toolbarRenderer = new ReactRenderer(toolBarContainer);
if (config.showControlBar) {
this.controlBarRenderer = new ReactRenderer(controlBarContainer);
}
if (config.showBottomPanel) {
this.footerRenderer = new ReactRenderer(footerContainer);
}
if (this.tooltip) {
this.tooltip.setViewport({
width: this.rootContainer.clientWidth,
height: this.rootContainer.clientHeight,
});
}
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.showChartSettingsModal : undefined}
setUserIndicators={this.eventManager.setUserIndicators}
toggleUserIndicator={this.eventManager.toggleUserIndicator}
activeIndicatorsObs={this.chart.getIndicatorManager().activeIndicators()}
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.hoverController = new HoverController({
legend: this.legend,
tooltip: this.tooltip,
chartEventSub: this.chart.subscribeChartEvent,
chartEventUnsub: this.chart.unsubscribeChartEvent,
});
this.timeScaleHoverController = new TimeScaleHoverController({
eventManager: this.eventManager,
controlBarContainer,
chartContainer: chartAreaContainer,
});
this.legendRenderer.renderComponent(
<LegendComponent
ohlcConfig={this.legend.getConfig()}
viewModel={this.legend.getLegendViewModel()}
onCompareRemove={(symbol, mode) => this.chart.getCompareManager().removeSymbolMode(symbol, mode)}
onIndicatorSettingsClick={this.showIndicatorSettingsModal}
onIndicatorRemove={(indicatorId) => this.chart.getIndicatorManager().removeIndicator(indicatorId)}
/>,
);
if (this.tooltipRenderer && this.tooltip) {
this.tooltipRenderer.renderComponent(
<ChartTooltip
formatObs={this.eventManager.getChartOptionsModel()}
timeframeObs={this.eventManager.getTimeframeObs()}
viewModel={this.tooltip.getTooltipViewModel()}
ohlcConfig={this.legend.getConfig()}
tooltipConfig={this.tooltip.getConfig()}
/>,
);
}
if (config.showMenuButton) {
this.toolbarRenderer.renderComponent(
<Toolbar
toggleDOM={this.chart.getDom().toggleDOM}
addDrawing={(name) => {
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()}
/>,
);
}
if (!config.size) {
this.setupAutoResize(chartAreaContainer);
}
}
public setSettings(settings: ChartSettingsSource): void {
this.eventManager.importChartSettings(settings);
}
public getSettings(): ChartSettings {
return this.eventManager.exportChartSettings();
}
public getRealtimeApi() {
return this.chart.getRealtimeApi();
}
private showModal(component: ReactElement, props: ModalProps): void {
this.modalRenderer.renderComponent(component, props);
}
private showChartSettingsModal = (): void => {
this.showModal(
<SettingsModal
// todo: deal with onSave
changeTimeFormat={(format) => this.eventManager.setTimeFormat(format)}
changeDateFormat={(format) => this.eventManager.setDateFormat(format)}
chartDateTimeFormatObs={this.eventManager.getChartOptionsModel()}
/>,
{ title: 'Настройки' },
);
};
private showIndicatorSettingsModal = (indicatorId: IndicatorsIds): void => {
if (!isEditableMaIndicator(indicatorId)) {
return;
}
const initialSettings = this.chart.getIndicatorManager().getIndicatorSettings(indicatorId);
if (!initialSettings) {
return;
}
let draftSettings = { ...initialSettings };
this.showModal(
<IndicatorSettingsModal
settings={initialSettings}
onChange={(nextSettings) => {
draftSettings = nextSettings;
}}
/>,
{
size: 'sm',
title: labelByIndicatorId[indicatorId],
onSave: () => this.chart.getIndicatorManager().updateIndicatorSettings(indicatorId, draftSettings),
},
);
};
/**
* Настройка автоматического изменения размера
*/
private setupAutoResize(chartAreaContainer: HTMLDivElement): void {
if (typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver(() => {
const { width, height } = chartAreaContainer.getBoundingClientRect();
this.tooltip?.setViewport({
width: Math.floor(width),
height: Math.floor(height),
});
});
this.resizeObserver.observe(chartAreaContainer);
}
}
// 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.tooltip?.destroy();
this.legend.destroy();
this.headerRenderer.destroy();
this.legendRenderer.destroy();
this.hoverController.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 { Flex, InputText, Select } from 'exchange-elements/v2';
import { ChangeEventHandler, useState } from 'react';
import { MaIndicatorSettings, MaSource } from '@src/core/Indicators/settings';
import styles from './index.module.scss';
interface IndicatorSettingsModalProps {
settings: MaIndicatorSettings;
onChange?: (settings: MaIndicatorSettings) => void;
}
const sourceOptions: { label: string; value: MaSource }[] = [
{ label: 'Цена открытия', value: 'open' },
{ label: 'Максимум', value: 'high' },
{ label: 'Минимум', value: 'low' },
{ label: 'Цена закрытия', value: 'close' },
];
export function IndicatorSettingsModal({ settings, onChange }: IndicatorSettingsModalProps) {
const [draftSettings, setDraftSettings] = useState<MaIndicatorSettings>(settings);
// useEffect(() => {
// setDraftSettings(settings);
// }, [settings]);
const updateDraftSettings = (nextSettings: MaIndicatorSettings): void => {
setDraftSettings(nextSettings);
onChange?.(nextSettings);
};
const handleLengthChange: ChangeEventHandler<HTMLInputElement> = (event) => {
const nextLength = Number(event.target.value);
updateDraftSettings({
...draftSettings,
length: Number.isFinite(nextLength) && nextLength > 0 ? nextLength : 1,
});
};
const handleOffsetChange: ChangeEventHandler<HTMLInputElement> = (event) => {
const nextOffset = Number(event.target.value);
updateDraftSettings({
...draftSettings,
offset: Number.isFinite(nextOffset) ? nextOffset : 0,
});
};
const handleSourceChange = (nextSource: MaSource): void => {
updateDraftSettings({
...draftSettings,
source: nextSource,
});
};
return (
<Flex
direction="column"
divider={null}
spacing="0500"
wrap="nowrap"
>
<InputText
className={styles.input}
value={String(draftSettings.length)}
onChange={handleLengthChange}
label="Длина"
labelPos="top"
size="sm"
/>
<Select
classNames={{ dropdown: styles.dropdown, input: styles.input_wrapper }}
value={draftSettings.source}
onChange={handleSourceChange}
options={sourceOptions}
label="Данные"
labelPos="top"
size="sm"
/>
<InputText
className={styles.input}
value={String(draftSettings.offset)}
onChange={handleOffsetChange}
label="Отступ"
labelPos="top"
size="sm"
/>
</Flex>
);
}