Загрузка данных
import { BehaviorSubject, combineLatest, type Observable, Subscription } from 'rxjs';
import { distinctUntilChanged, map } from 'rxjs/operators';
import { ControlBar } from '@components/ControlBar';
import { Footer } from '@components/Footer';
import { Header } from '@components/Header';
import { DataSource, DataSourceParams } from '@core/DataSource';
import { Hotkeys, Keys } from '@core/Hotkeys';
import { ModalRenderer } from '@core/ModalRenderer';
import { FloatingDrawingToolbar } from '@src/components/FloatingToolbar';
import { SeasonalityControls } from '@src/components/SeasonalityControls';
import { SeasonalityTable } from '@src/components/SeasonalityTable';
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 { SeasonalityManager } from '@src/core/SeasonalityManager';
import { configureThemeStore } from '@src/theme/store';
import { ThemeKey, ThemeMode } from '@src/theme/types';
import { Locale, setLocale, t } from '@src/translations';
import {
Candle,
ChartSeriesType,
ChartViewType,
OHLCConfig,
SeasonalityRange,
SeasonalityViewType,
SymbolInfoInput,
TooltipConfig,
} from '@src/types';
import { ISerializable, MoexChartSnapshot, MoexChartSnapshotInput } from '@src/types/snapshot';
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';
import '../styles/global.scss';
// todo: forbid @lib in /src
export interface ChartCollectionPreset {
undoRedoEnabled?: boolean;
showMenuButton?: boolean;
showBottomPanel?: boolean;
showControlBar?: boolean;
showFullscreenButton?: boolean;
showSettingsButton?: boolean;
showCompareButton?: boolean;
showSymbolSearchButton?: boolean;
showSnapshotButton?: boolean;
showSeasonalityButton?: 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 }
* }
*```
*/
tooltipConfig?: TooltipConfig;
size?:
| {
width: number;
height: number;
}
| false;
supportedTimeframes: Timeframes[];
supportedChartSeriesTypes: ChartSeriesType[];
getDataSource: DataSourceParams['getData'];
getSeasonalityDataSource?: DataSourceParams['getData'];
startRealtime: (
getSymbols: () => string[],
getTimeframe: () => Timeframes,
update: (symbolId: string, candle: Candle) => void,
periodMs?: number,
) => () => void;
theme: ThemeKey; // 'mb' | 'mxt' | 'tr'
ohlc: OHLCConfig;
locale: Locale;
mode?: ThemeMode; // 'light' | 'dark'
openCompareModal?: () => void;
openSymbolSearchModal?: () => void;
}
export interface IMoexChart {
snapshot: MoexChartSnapshotInput;
chartCollectionPreset: ChartCollectionPreset;
container: HTMLElement;
}
export class MoexChart implements ISerializable<MoexChartSnapshot> {
private chart!: Chart;
private seasonalityManager!: SeasonalityManager;
private chartView!: BehaviorSubject<ChartViewType>;
private canCopyImageObs!: Observable<boolean>;
private seasonalityLayout!: SeasonalityLayout;
private resizeObserver?: ResizeObserver;
private eventManager!: EventManager;
private hotkeys!: Hotkeys;
private rootContainer!: HTMLElement;
private seasonalityControlsRenderer!: UIRenderer;
private seasonalityTableRenderer!: UIRenderer;
private headerRenderer!: UIRenderer;
private modalRenderer!: ModalRenderer;
private toolbarRenderer: UIRenderer | undefined;
private controlBarRenderer?: UIRenderer;
private footerRenderer?: UIRenderer;
private drawingToolbarRenderer!: UIRenderer;
private timeScaleHoverController!: TimeScaleHoverController;
private dataSource!: DataSource;
private subscriptions = new Subscription();
private fullscreen!: FullscreenController;
private chartCollectionPresetSettings!: ChartCollectionPreset;
constructor(config: IMoexChart) {
setLocale(config.chartCollectionPreset.locale);
this.setup(config);
}
private setup = (config: IMoexChart) => {
this.subscriptions = new Subscription();
this.chartView = new BehaviorSubject<ChartViewType>(ChartViewType.Chart);
this.toolbarRenderer = undefined;
this.controlBarRenderer = undefined;
this.footerRenderer = undefined;
this.chartCollectionPresetSettings = config.chartCollectionPreset;
setPricePrecision(config.chartCollectionPreset.ohlc.precision);
const { chartSeriesType, symbolId, symbol, symbolName, timeframe, interval, dateFormat, timeFormat } =
config.snapshot.charts[0];
this.eventManager = new EventManager({
initialTimeframe: timeframe,
initialSeries: chartSeriesType,
initialSymbolInfo: {
symbolId: symbolId ?? '',
symbol,
symbolName,
},
initialTimeFormat: timeFormat,
initialDateFormat: dateFormat,
initialInterval: interval,
});
this.dataSource = new DataSource({
getData: config.chartCollectionPreset.getDataSource,
eventManager: this.eventManager,
});
this.rootContainer = config.container;
this.fullscreen = new FullscreenController(this.rootContainer);
const store = configureThemeStore(config.chartCollectionPreset);
const {
chartAreaContainer,
chartContainer,
seasonalityContainer,
seasonalityControlsContainer,
seasonalityPlotContainer,
seasonalityTableContainer,
toolBarContainer,
headerContainer,
modalContainer,
controlBarContainer,
drawingToolbarContainer,
footerContainer,
toggleToolbar, // todo: move this function to toolbarModel
} = ContainerManager.createContainers({
parentContainer: this.rootContainer,
showBottomPanel: config.chartCollectionPreset.showBottomPanel, // todo: apply config.showBottomPanel in FullscreenController
showMenuButton: config.chartCollectionPreset.showMenuButton,
});
this.hotkeys = new Hotkeys(this.rootContainer, () => this.chartView.value === ChartViewType.Chart);
this.registerUndoRedoHotkeys(config.chartCollectionPreset.undoRedoEnabled);
this.modalRenderer = new ModalRenderer(modalContainer);
this.chart = new Chart({
params: {
dataSource: this.dataSource,
eventManager: this.eventManager,
modalRenderer: this.modalRenderer,
ohlcConfig: config.chartCollectionPreset.ohlc, // todo: omptimize
tooltipConfig: config.chartCollectionPreset.tooltipConfig ?? {},
panes: config.snapshot.charts[0].panes,
hotkeys: this.hotkeys,
},
lwcChartConfig: {
container: chartAreaContainer,
seriesTypes: config.chartCollectionPreset.supportedChartSeriesTypes,
theme: store.theme,
mode: store.mode,
},
});
this.seasonalityLayout = {
rootContainer: this.rootContainer,
chartContainer,
chartAreaContainer,
footerContainer,
seasonalityContainer,
seasonalityPlotContainer,
seasonalityTableContainer,
chartGridTemplateRows: this.rootContainer.style.gridTemplateRows,
};
this.seasonalityManager = new SeasonalityManager({
container: seasonalityPlotContainer,
getData: config.chartCollectionPreset.getSeasonalityDataSource ?? config.chartCollectionPreset.getDataSource,
precision: config.chartCollectionPreset.ohlc.precision,
});
this.canCopyImageObs = combineLatest([this.chartView, this.seasonalityManager.getState()]).pipe(
map(
([view, state]) =>
view === ChartViewType.Chart || (state.status === 'ready' && state.displayMode === SeasonalityViewType.Chart),
),
distinctUntilChanged(),
);
this.subscriptions.add(
this.seasonalityManager
.getState()
.pipe(
map((state) => state.displayMode),
distinctUntilChanged(),
)
.subscribe((displayMode) => setSeasonalityDisplayMode(this.seasonalityLayout, displayMode)),
);
this.seasonalityControlsRenderer = new ReactRenderer(seasonalityControlsContainer);
this.seasonalityTableRenderer = new ReactRenderer(seasonalityTableContainer);
this.seasonalityControlsRenderer.renderComponent(
<SeasonalityControls
stateObs={this.seasonalityManager.getState()}
setDisplayMode={this.seasonalityManager.setDisplayMode}
setScale={this.seasonalityManager.setScale}
setRange={this.seasonalityManager.setRange}
/>,
);
this.seasonalityTableRenderer.renderComponent(
<SeasonalityTable
stateObs={this.seasonalityManager.getState()}
precision={config.chartCollectionPreset.ohlc.precision}
/>,
);
this.subscriptions.add(
this.hotkeys.register({
keys: [Keys.escape],
enabled: () => this.chartView.value === ChartViewType.Seasonality && modalContainer.hidden,
callback: this.backToChart,
}),
);
this.subscriptions.add(this.eventManager.symbolId().subscribe(this.backToChart));
this.subscriptions.add(
combineLatest([store.theme$, store.mode$]).subscribe(([theme, mode]) => {
this.chart.updateTheme(theme, mode);
this.seasonalityManager.updateTheme();
document.documentElement.dataset.theme = theme;
document.documentElement.dataset.mode = mode;
}),
);
const realtimeParams = this.chart.getRealtimeApi();
this.subscriptions.add(
config.chartCollectionPreset.startRealtime(
realtimeParams.getSymbols,
realtimeParams.getTimeframe,
realtimeParams.update,
),
);
this.createRenderers(config.chartCollectionPreset, {
headerContainer,
toolBarContainer,
drawingToolbarContainer,
controlBarContainer,
footerContainer,
});
this.timeScaleHoverController = new TimeScaleHoverController({
eventManager: this.eventManager,
controlBarContainer,
chartContainer: chartAreaContainer,
});
this.renderAttachments(config, toggleToolbar);
};
private registerUndoRedoHotkeys(enabled = false): void {
if (!enabled) return;
const undoRedo = this.eventManager.getUndoRedo();
this.hotkeys.register({ keys: [Keys.mod, Keys.z], callback: undoRedo.undo });
this.hotkeys.register({ keys: [Keys.mod, Keys.y], callback: undoRedo.redo });
this.hotkeys.register({ keys: [Keys.mod, Keys.shift, Keys.z], callback: undoRedo.redo });
}
private createRenderers(
preset: ChartCollectionPreset,
containers: Pick<
ReturnType<typeof ContainerManager.createContainers>,
'headerContainer' | 'toolBarContainer' | 'drawingToolbarContainer' | 'controlBarContainer' | 'footerContainer'
>,
): void {
const { headerContainer, toolBarContainer, drawingToolbarContainer, controlBarContainer, footerContainer } =
containers;
this.headerRenderer = new ReactRenderer(headerContainer);
this.toolbarRenderer = new ReactRenderer(toolBarContainer);
this.drawingToolbarRenderer = new ReactRenderer(drawingToolbarContainer);
if (preset.showControlBar) {
this.controlBarRenderer = new ReactRenderer(controlBarContainer);
}
if (preset.showBottomPanel) {
this.footerRenderer = new ReactRenderer(footerContainer);
}
}
public setSettings(settings: ChartSettingsSource): void {
this.backToChart();
this.eventManager.importChartSettings(settings);
}
public getSettings(): ChartSettings {
return this.eventManager.exportChartSettings();
}
// todo: описать подробнее в доке. Точно ли public?
public getRealtimeApi() {
return this.chart.getRealtimeApi();
}
// todo: описать подробнее в доке
public getCompareManager(): CompareManager {
return this.chart.getCompareManager();
}
public setSnapshot(snapshot: MoexChartSnapshotInput) {
const configConstructorLike: IMoexChart = {
snapshot,
chartCollectionPreset: this.chartCollectionPresetSettings,
container: this.rootContainer,
};
this.destroy();
this.setup(configConstructorLike);
}
// todo: описать в доке
public getSnapshot(): MoexChartSnapshot {
const res = {
settings: this.getSettings(),
charts: [this.chart.getSnapshot()], // todo: в будущем может быть несколько инстансов чартов
};
return res;
}
public setSymbol(symbolInfo: SymbolInfoInput): void {
this.backToChart();
this.eventManager.setSymbol(symbolInfo);
}
public clearSymbol(): void {
this.backToChart();
this.eventManager.clearSymbol();
}
public showSeasonality(range?: SeasonalityRange): Promise<void> {
const { symbolInfo } = this.eventManager.exportChartSettings();
if (this.chartView.value !== ChartViewType.Seasonality) {
this.chart.setSleeping(true);
setSeasonalityVisible(this.seasonalityLayout, true);
this.chartView.next(ChartViewType.Seasonality);
this.seasonalityLayout.seasonalityContainer.focus({ preventScroll: true });
}
return this.seasonalityManager.show(symbolInfo.symbolId, range);
}
public backToChart = (): void => {
if (this.chartView.value === ChartViewType.Chart) {
return;
}
this.seasonalityManager.clear();
setSeasonalityVisible(this.seasonalityLayout, false);
this.chart.setSleeping(false);
this.chartView.next(ChartViewType.Chart);
this.seasonalityLayout.chartAreaContainer.focus({ preventScroll: true });
};
private setChartView = (view: ChartViewType): void => {
if (view === ChartViewType.Chart) {
this.backToChart();
return;
}
this.showSeasonality();
};
private renderHeader(config: IMoexChart, toggleToolbar: () => boolean): void {
this.headerRenderer.renderComponent(
<Header
chartViewObs={this.chartView}
canCopyImageObs={this.canCopyImageObs}
setChartView={config.chartCollectionPreset.showSeasonalityButton ? this.setChartView : undefined}
timeframes={config.chartCollectionPreset.supportedTimeframes}
selectedTimeframeObs={this.eventManager.getTimeframeObs()}
setTimeframe={(value) => {
this.eventManager.setTimeframe(value);
}}
seriesTypes={config.chartCollectionPreset.supportedChartSeriesTypes}
selectedSeriesObs={this.eventManager.getSelectedSeries()}
setSelectedSeries={(value) => {
this.eventManager.setSeriesSelected(value);
}}
showSettingsModal={
config.chartCollectionPreset.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: t('Settings') },
)
: undefined
}
addIndicatorToChart={(indicatorType: IndicatorsIds) =>
this.chart.getIndicatorManager().addIndicator({ indicatorType })
}
copyImage={
config.chartCollectionPreset.showSnapshotButton
? () =>
this.chartView.value === ChartViewType.Seasonality
? this.seasonalityManager.copyImage()
: this.chart.copyImage()
: undefined
}
showMenuButton={!!config.chartCollectionPreset.showMenuButton}
showFullscreenButton={!!config.chartCollectionPreset.showFullscreenButton}
fullscreen={this.fullscreen}
undoRedo={config.chartCollectionPreset.undoRedoEnabled ? this.eventManager.getUndoRedo() : undefined}
toggleToolbarVisible={toggleToolbar}
showCompareButton={!!config.chartCollectionPreset.showCompareButton}
openCompareModal={
config.chartCollectionPreset.openCompareModal ? config.chartCollectionPreset.openCompareModal : undefined
}
showSymbolSearchButton={!!config.chartCollectionPreset.openSymbolSearchModal}
openSymbolSearchModal={config.chartCollectionPreset.openSymbolSearchModal}
isMXT={config.chartCollectionPreset.theme === 'mxt'}
/>,
);
}
private renderAttachments(config: IMoexChart, toggleToolbar: () => boolean) {
const drawingsCollectionManager = this.chart.getDrawingsCollectionManager();
this.drawingToolbarRenderer.renderComponent(
// этот тулбар к drawingManger бы прибить, сильно на него завязан
<FloatingDrawingToolbar
selectedDrawing$={drawingsCollectionManager.selectedDrawing()}
onUpdateSettings={drawingsCollectionManager.updateSelectedDrawingSettings}
onToggleLock={() => drawingsCollectionManager.toggleSelectedDrawingLock()}
onOpenSettings={() => drawingsCollectionManager.openSelectedDrawingSettings()}
onDelete={() => drawingsCollectionManager.deleteSelectedDrawing()}
/>,
);
this.renderHeader(config, toggleToolbar);
if (this.toolbarRenderer && config.chartCollectionPreset.showMenuButton) {
const drawingCollection = this.chart.getDrawingsCollectionManager();
this.toolbarRenderer.renderComponent(
<Toolbar
toggleDOM={this.chart.getDom().toggleDOM}
addDrawing={drawingCollection.activateDrawingTool}
setEndlessDrawingsMode={drawingCollection.setEndlessDrawingMode}
isEndlessDrawingsMode$={drawingCollection.isEndlessDrawingsMode()}
activateCrosshair={() => drawingCollection.activateCrosshair()}
activeTool$={drawingCollection.getActiveTool()}
/>,
);
}
if (this.controlBarRenderer && config.chartCollectionPreset.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.chartCollectionPreset.showBottomPanel) {
this.footerRenderer.renderComponent(
<Footer
supportedTimeframes={config.chartCollectionPreset.supportedTimeframes}
setInterval={this.eventManager.setInterval}
intervalObs={this.eventManager.getInterval()}
/>,
);
}
}
/**
* Уничтожение графика и очистка ресурсов
* @returns void
*/
destroy(): void {
this.seasonalityControlsRenderer.destroy();
this.seasonalityTableRenderer.destroy();
this.headerRenderer.destroy();
this.seasonalityManager.destroy();
this.drawingToolbarRenderer.destroy();
this.subscriptions.unsubscribe();
this.chartView.complete();
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();
}
this.hotkeys.destroy();
if (this.eventManager) {
this.eventManager.destroy();
}
if (this.toolbarRenderer) {
this.toolbarRenderer.destroy();
}
this.dataSource.destroy();
ContainerManager.clearContainers(this.rootContainer);
}
}
interface SeasonalityLayout {
rootContainer: HTMLElement;
chartContainer: HTMLElement;
chartAreaContainer: HTMLElement;
footerContainer: HTMLElement;
seasonalityContainer: HTMLElement;
seasonalityPlotContainer: HTMLElement;
seasonalityTableContainer: HTMLElement;
chartGridTemplateRows: string;
}
function setSeasonalityVisible(layout: SeasonalityLayout, visible: boolean): void {
Object.assign(layout.rootContainer.style, {
gridTemplateRows: visible ? 'auto minmax(0, 1fr)' : layout.chartGridTemplateRows,
});
Object.assign(layout.chartContainer.style, {
visibility: visible ? 'hidden' : 'visible',
});
layout.chartContainer.inert = visible;
Object.assign(layout.footerContainer.style, {
display: visible ? 'none' : '',
});
Object.assign(layout.seasonalityContainer.style, {
visibility: visible ? 'visible' : 'hidden',
pointerEvents: visible ? 'auto' : 'none',
});
layout.seasonalityContainer.inert = !visible;
}
function setSeasonalityDisplayMode(layout: SeasonalityLayout, displayMode: SeasonalityViewType): void {
const isTable = displayMode === SeasonalityViewType.Table;
Object.assign(layout.seasonalityPlotContainer.style, {
visibility: isTable ? 'hidden' : '',
pointerEvents: isTable ? 'none' : '',
});
layout.seasonalityPlotContainer.inert = isTable;
layout.seasonalityTableContainer.hidden = !isTable;
layout.seasonalityTableContainer.inert = !isTable;
}
const DEFAULT_PRICE_PRECISION = 4;
const DEFAULT_PERCENT_PRECISION = 2;
const MIN_PRICE_PRECISION = 0;
const MAX_PRICE_PRECISION = 6;
let pricePrecision = DEFAULT_PRICE_PRECISION;
export function setPricePrecision(next: number = DEFAULT_PRICE_PRECISION): void {
const precision = Number.isFinite(next) ? Math.trunc(next) : DEFAULT_PRICE_PRECISION;
pricePrecision = Math.min(MAX_PRICE_PRECISION, Math.max(MIN_PRICE_PRECISION, precision));
}
export function getPercentPrecision(): number {
return DEFAULT_PERCENT_PRECISION;
}
export function getPricePrecision(): number {
return pricePrecision;
}
export function getPricePrecisionStep(): number {
return 10 ** -pricePrecision;
}