Загрузка данных
export const CHART_ROOT_CLASSNAME = 'moex-chart-root';
export const CHART_PORTAL_HOST_CLASSNAME = 'moex-chart-portal-host';
export const CHART_MODAL_CONTAINER_CLASSNAME = 'moex-chart-modal-container';
export const CHART_PANE_OVERLAY_CONTAINER = 'moex-chart-pane-overlay-container';
export const CHART_PANE_EMPTY_STATE_CONTAINER = 'moex-chart-pane-empty-state-container';
export const CHART_DRAWING_TOOLBAR_CONTAINER = 'moex-chart-drawing-toolbar-container';
export const CHART_DRAWING_TOOLTIP = 'moex-chart-drawing-tooltip';
export const CHART_PRICE_SCALE_CONTROLS = 'moex-price-scale-controls';
export const CHART_PRICE_SCALE_CONTROLS_CONTENT = `${CHART_PRICE_SCALE_CONTROLS}-content`;
export const CHART_PRICE_SCALE_CONTROLS_VISIBLE = `${CHART_PRICE_SCALE_CONTROLS}_visible`;
import {
CHART_DRAWING_TOOLBAR_CONTAINER,
CHART_MODAL_CONTAINER_CLASSNAME,
CHART_PANE_EMPTY_STATE_CONTAINER,
CHART_PANE_OVERLAY_CONTAINER,
CHART_ROOT_CLASSNAME,
} from '@src/constants';
import styles from './styles.module.scss';
interface CreateContainersOptions {
parentContainer: HTMLElement;
showBottomPanel?: boolean;
showMenuButton?: boolean;
}
enum ZIndex {
Chart = '0',
Base = '1',
Legend = '2',
Floating = '3',
Modal = '100',
}
const ContainerLayoutConfig = {
headerHeight: 36,
footerHeight: 32,
toolbarWidth: 42,
verticalGap: 8,
};
/**
* Утилита для создания DOM контейнеров
*/
export class ContainerManager {
private static injectFont(): void {
const existingLink = document.querySelector('link[href*="fonts.googleapis.com"]');
if (existingLink) {
return;
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,100..900&display=swap';
document.head.appendChild(link);
}
/**
* Создание контейнеров для графика и UI компонентов
*/
static createContainers({ parentContainer, showBottomPanel, showMenuButton }: CreateContainersOptions) {
this.injectFont();
const { headerHeight, footerHeight, toolbarWidth, verticalGap } = ContainerLayoutConfig;
parentContainer.innerHTML = '';
parentContainer.classList.add(CHART_ROOT_CLASSNAME);
parentContainer.style.height = '100%';
parentContainer.style.width = '100%';
parentContainer.style.padding = 'var(--space-1000)';
parentContainer.style.backgroundColor = 'var(--neutral-0)';
parentContainer.style.borderRadius = 'var(--space-0500)';
parentContainer.style.display = 'grid';
parentContainer.style.rowGap = `${verticalGap}px`;
parentContainer.style.gridTemplateRows = showBottomPanel
? `${headerHeight}px minmax(0, 1fr) ${footerHeight}px`
: `${headerHeight}px minmax(0, 1fr)`;
const headerContainer = document.createElement('div');
headerContainer.style.width = '100%';
headerContainer.style.height = `${headerHeight}px`;
headerContainer.style.overflow = 'auto hidden';
headerContainer.className = styles.scrollableBox;
const footerContainer = document.createElement('div');
footerContainer.style.width = '100%';
footerContainer.style.height = `${footerHeight}px`;
const chartContainer = document.createElement('div');
chartContainer.style.position = 'relative';
chartContainer.style.height = '100%';
chartContainer.style.width = '100%';
chartContainer.style.minWidth = '0';
chartContainer.style.minHeight = '0';
chartContainer.style.display = 'grid';
chartContainer.style.columnGap = 'var(--space-0500)';
chartContainer.style.gridTemplateColumns = 'minmax(0, 1fr)';
const chartAreaContainer = document.createElement('div');
chartAreaContainer.style.position = 'relative';
chartAreaContainer.style.height = '100%';
chartAreaContainer.style.minHeight = '0';
chartAreaContainer.style.minWidth = '0';
chartAreaContainer.style.cursor = 'crosshair';
const toolBarContainer = document.createElement('div');
toolBarContainer.style.height = '100%';
toolBarContainer.style.minHeight = '0';
toolBarContainer.style.overflow = 'hidden';
const controlBarContainer = document.createElement('div');
controlBarContainer.style.position = 'absolute';
controlBarContainer.style.left = '50%';
controlBarContainer.style.transform = 'translateX(-50%)';
controlBarContainer.style.bottom = 'var(--space-2000)';
controlBarContainer.style.zIndex = ZIndex.Base;
const drawingToolbarContainer = document.createElement('div');
drawingToolbarContainer.classList.add(CHART_DRAWING_TOOLBAR_CONTAINER);
drawingToolbarContainer.style.position = 'absolute';
drawingToolbarContainer.style.inset = '0';
drawingToolbarContainer.style.zIndex = ZIndex.Floating;
drawingToolbarContainer.style.pointerEvents = 'none';
drawingToolbarContainer.style.overflow = 'hidden';
const modalContainer = document.createElement('div');
modalContainer.classList.add(CHART_MODAL_CONTAINER_CLASSNAME);
modalContainer.style.position = 'absolute';
modalContainer.style.inset = '0';
modalContainer.style.zIndex = ZIndex.Modal;
modalContainer.hidden = true;
chartAreaContainer.append(controlBarContainer, drawingToolbarContainer);
chartContainer.append(chartAreaContainer, modalContainer);
parentContainer.append(headerContainer, chartContainer);
if (showBottomPanel) {
parentContainer.append(footerContainer);
}
const toggleToolbar = () => {
const mounted = toolBarContainer.isConnected;
if (mounted) {
toolBarContainer.remove();
chartContainer.style.gridTemplateColumns = 'minmax(0, 1fr)';
return false;
}
chartContainer.insertBefore(toolBarContainer, chartAreaContainer);
chartContainer.style.gridTemplateColumns = `${toolbarWidth}px minmax(0, 1fr)`;
return true;
};
chartContainer.insertBefore(toolBarContainer, chartAreaContainer);
chartContainer.style.gridTemplateColumns = `${toolbarWidth}px minmax(0, 1fr)`;
if (!showMenuButton) {
toggleToolbar();
}
return {
headerContainer,
footerContainer,
chartContainer,
chartAreaContainer,
toolBarContainer,
modalContainer,
controlBarContainer,
drawingToolbarContainer,
toggleToolbar,
};
}
/**
* Очистка контейнеров
*/
static clearContainers(parentContainer: HTMLElement): void {
parentContainer.innerHTML = '';
}
static createPaneContainers() {
const legendContainer = document.createElement('div');
legendContainer.style.width = '80%';
legendContainer.style.position = 'absolute';
legendContainer.style.top = '0';
legendContainer.style.left = '0';
legendContainer.style.zIndex = ZIndex.Legend;
legendContainer.style.pointerEvents = 'none';
const paneOverlayContainer = document.createElement('div');
paneOverlayContainer.classList.add(CHART_PANE_OVERLAY_CONTAINER);
paneOverlayContainer.style.position = 'absolute';
paneOverlayContainer.style.inset = '0';
paneOverlayContainer.style.zIndex = ZIndex.Base;
paneOverlayContainer.style.pointerEvents = 'none';
const paneEmptyStateContainer = document.createElement('div');
paneEmptyStateContainer.classList.add(CHART_PANE_EMPTY_STATE_CONTAINER);
paneEmptyStateContainer.hidden = true;
return {
legendContainer,
paneOverlayContainer,
paneEmptyStateContainer,
};
}
}
import { BehaviorSubject, combineLatest, distinctUntilChanged, map, Observable, Subscription } from 'rxjs';
import { ChartOptionsModel, ChartSeriesType, Intervals, SymbolInfo, SymbolInfoInput, TimeFormat } from '@src/types';
import { Defaults } from '@src/types/defaults';
import { Timeframes } from '@src/types/timeframes';
import { DateFormat, getTimeframeByInterval, normalizeSymbolInfo, shouldShowTime } from '@src/utils';
import { ChartSettings, ChartSettingsSource, parseChartSettings } from './ChartSettings';
import { UndoKey, UndoRedo } from './UndoRedo';
interface EventManagerParams {
initialTimeframe: Timeframes;
initialSeries: ChartSeriesType;
initialSymbolInfo: SymbolInfoInput;
initialTimeFormat?: TimeFormat;
initialDateFormat?: DateFormat;
initialInterval?: Intervals | null;
}
interface SetWithHistoryOptions {
history?: boolean;
}
const EMPTY_SYMBOL_INFO: SymbolInfo = {
symbolId: '',
symbol: '',
symbolName: '',
};
/**
* Менеджер настроек, которые меняются во время использования.
* Отвечает за централизованное управление настройками графика.
*/
export class EventManager {
private timeframe$: BehaviorSubject<Timeframes>;
private seriesSelected$: BehaviorSubject<ChartSeriesType>;
private symbolInfo$: BehaviorSubject<SymbolInfo>;
private timeFormat$: BehaviorSubject<TimeFormat>;
private dateFormat$: BehaviorSubject<DateFormat>;
private interval$: BehaviorSubject<Intervals | null>;
private controlBarVisible$ = new BehaviorSubject<boolean>(false); // todo: move to render
private undoRedo: UndoRedo;
constructor({
initialTimeframe,
initialSeries,
initialSymbolInfo,
initialTimeFormat,
initialDateFormat,
initialInterval = null,
}: EventManagerParams) {
const normalizedSymbolInfo = normalizeSymbolInfo(initialSymbolInfo);
this.timeframe$ = new BehaviorSubject<Timeframes>(initialTimeframe);
this.seriesSelected$ = new BehaviorSubject<ChartSeriesType>(initialSeries);
this.symbolInfo$ = new BehaviorSubject<SymbolInfo>(normalizedSymbolInfo ?? EMPTY_SYMBOL_INFO);
this.timeFormat$ = new BehaviorSubject<TimeFormat>(initialTimeFormat ?? Defaults.timeFormat);
this.dateFormat$ = new BehaviorSubject<DateFormat>(initialDateFormat ?? Defaults.dateFormat);
this.interval$ = new BehaviorSubject<Intervals | null>(initialInterval);
this.undoRedo = new UndoRedo({
timeframe: (value) => this.timeframe$.next(value),
seriesSelected: (value) => this.seriesSelected$.next(value),
symbolInfo: (value) => this.symbolInfo$.next(value),
timeFormat: (value) => this.timeFormat$.next(value),
dateFormat: (value) => this.dateFormat$.next(value),
interval: (value) => this.interval$.next(value),
});
}
private setWithHistory<K extends UndoKey, V>(
key: K,
subject: BehaviorSubject<V>,
next: V,
options?: SetWithHistoryOptions,
): void {
const prev = subject.getValue();
if (Object.is(prev, next)) {
return;
}
subject.next(next);
const historyEnabled = options?.history ?? true;
if (historyEnabled) {
this.undoRedo.push(key, prev, next);
}
}
public getUndoRedo(): UndoRedo {
return this.undoRedo;
}
public getTimeframe(): Timeframes {
return this.timeframe$.value;
}
public setInterval = (next: Intervals, options?: SetWithHistoryOptions): void => {
const timeframe = getTimeframeByInterval(next);
this.undoRedo.group(() => {
this.setWithHistory('timeframe', this.timeframe$, timeframe, options);
this.setWithHistory('interval', this.interval$, next, options);
});
};
public resetInterval = (options?: SetWithHistoryOptions): void =>
this.setWithHistory('interval', this.interval$, null, options);
public getInterval(): Observable<Intervals | null> {
return this.interval$.asObservable();
}
public setSymbol = (symbolInfoInput: SymbolInfoInput, options?: SetWithHistoryOptions): void => {
const nextSymbolInfo = normalizeSymbolInfo(symbolInfoInput);
if (!nextSymbolInfo) {
return;
}
const currentSymbolInfo = this.symbolInfo$.value;
if (
currentSymbolInfo.symbolId === nextSymbolInfo.symbolId &&
currentSymbolInfo.symbol === nextSymbolInfo.symbol &&
currentSymbolInfo.symbolName === nextSymbolInfo.symbolName
) {
return;
}
this.setWithHistory('symbolInfo', this.symbolInfo$, nextSymbolInfo, options);
};
public clearSymbol = (options?: SetWithHistoryOptions): void => {
if (!this.symbolInfo$.value.symbolId) {
return;
}
this.setWithHistory('symbolInfo', this.symbolInfo$, EMPTY_SYMBOL_INFO, options);
};
public hasSymbol(): Observable<boolean> {
return this.symbolInfo$.pipe(
map(({ symbolId }) => Boolean(symbolId)),
distinctUntilChanged(),
);
}
public symbolId(): Observable<string> {
return this.symbolInfo$.pipe(
map(({ symbolId }) => symbolId),
distinctUntilChanged(),
);
}
public symbol(): Observable<string> {
return this.symbolInfo$.pipe(
map(({ symbol }) => symbol),
distinctUntilChanged(),
);
}
public symbolName(): Observable<string> {
return this.symbolInfo$.pipe(
map(({ symbolName }) => symbolName),
distinctUntilChanged(),
);
}
public setTimeFormat = (next: TimeFormat, options?: SetWithHistoryOptions): void =>
this.setWithHistory('timeFormat', this.timeFormat$, next, options);
public setDateFormat = (next: DateFormat, options?: SetWithHistoryOptions): void =>
this.setWithHistory('dateFormat', this.dateFormat$, next, options);
public getChartOptionsModel(): Observable<ChartOptionsModel> {
// todo: подумать - стоит ли унести это в чарт
return combineLatest([this.timeFormat$, this.dateFormat$, this.timeframe$]).pipe(
map(([timeFormat, dateFormat, timeframe]) => ({
timeFormat,
dateFormat,
showTime: shouldShowTime(timeframe),
})),
);
}
public setTimeframe = (next: Timeframes, options?: SetWithHistoryOptions): void =>
this.undoRedo.group(() => {
this.resetInterval(options);
this.setWithHistory('timeframe', this.timeframe$, next, options);
});
public timeframe(): Observable<Timeframes> {
return this.timeframe$.asObservable();
}
public subscribeTimeframe(callback: (format: Timeframes) => void): Subscription {
return this.timeframe$.subscribe(callback);
}
public getTimeframeObs(): Observable<Timeframes> {
return this.timeframe$.asObservable();
}
public setSeriesSelected = (next: ChartSeriesType, options?: SetWithHistoryOptions): void =>
this.setWithHistory('seriesSelected', this.seriesSelected$, next, options);
public getSelectedSeries(): Observable<ChartSeriesType> {
return this.seriesSelected$.asObservable();
}
public subscribeSeriesSelected(callback: (next: ChartSeriesType) => void): Subscription {
return this.seriesSelected$.subscribe(callback);
}
public setControlBarVisible(visible: boolean): void {
this.controlBarVisible$.next(visible);
}
public getControlBarVisible(): Observable<boolean> {
return this.controlBarVisible$.asObservable();
}
public exportChartSettings(): ChartSettings {
return {
symbolInfo: this.symbolInfo$.value,
timeframe: this.timeframe$.value,
seriesSelected: this.seriesSelected$.value,
timeFormat: this.timeFormat$.value,
dateFormat: this.dateFormat$.value,
interval: this.interval$.value,
};
}
public importChartSettings(settings: ChartSettingsSource): void {
const { symbolInfo, seriesSelected, timeframe, timeFormat, dateFormat, interval } = parseChartSettings(settings);
const setOptions = { history: false };
if (symbolInfo) {
if (symbolInfo.symbolId) {
this.setSymbol(symbolInfo, setOptions);
} else {
this.clearSymbol(setOptions);
}
}
if (seriesSelected) {
this.setSeriesSelected(seriesSelected, setOptions);
}
if (timeFormat) {
this.setTimeFormat(timeFormat, setOptions);
}
if (dateFormat) {
this.setDateFormat(dateFormat, setOptions);
}
if (interval != null) {
this.setInterval(interval, setOptions);
return;
}
if (timeframe) {
this.setTimeframe(timeframe, setOptions);
return;
}
if (interval === null) {
this.resetInterval(setOptions);
}
}
public destroy(): void {
this.timeFormat$.complete();
this.dateFormat$.complete();
this.timeframe$.complete();
this.controlBarVisible$.complete();
this.interval$.complete();
this.symbolInfo$.complete();
this.seriesSelected$.complete();
}
}
import { IChartApi } from 'lightweight-charts';
import { BehaviorSubject, map, 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 { IndicatorsIds } from '@src/constants';
import { DOMObject } from '@src/core/DOMObject';
import { indicatorsMap as indicatorsConfigMap } from '@src/core/Indicators';
import { ChartTypeOptions, IndicatorConfig } from '@src/types';
import { IndicatorSnapshot } from '@src/types/snapshot';
import { applyNextIndicatorColors, getIndicatorColors } from '@src/utils';
interface IndicatorManagerParams {
eventManager: EventManager;
dataSource: DataSource;
lwcChart: IChartApi;
paneManager: PaneManager;
DOM: DOMModel;
initialIndicators?: IndicatorSnapshot[];
chartOptions?: ChartTypeOptions;
}
export class IndicatorManager {
private readonly eventManager: EventManager;
private readonly lwcChart: IChartApi;
private readonly chartOptions?: ChartTypeOptions;
private readonly indicatorsMap$ = new BehaviorSubject<Map<string, Indicator>>(new Map());
private readonly DOM: DOMModel;
private readonly dataSource: DataSource;
private readonly paneManager: PaneManager;
constructor({
eventManager,
dataSource,
lwcChart,
DOM,
chartOptions,
initialIndicators,
paneManager,
}: IndicatorManagerParams) {
this.eventManager = eventManager;
this.lwcChart = lwcChart;
this.chartOptions = chartOptions;
this.DOM = DOM;
this.dataSource = dataSource;
this.paneManager = paneManager;
initialIndicators?.forEach((indicator) => {
this.addIndicator(indicator);
});
}
public addEntity<T extends Indicator>(
factory: (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) => T,
): T {
return this.DOM.setEntity(factory);
}
public addIndicator(snap: Partial<IndicatorSnapshot>): void {
if (!snap.indicatorType) {
console.error('[IndicatorManager] Не был получен тип индиктора');
return;
}
const indicatorsMap = new Map(this.indicatorsMap$.value);
const id = snap.id ?? `${snap.indicatorType}-${crypto.randomUUID()}`;
const config = getConfigByIndicatorType({
indicatorType: snap.indicatorType,
existedIndicators: this.indicatorsMap$.value,
});
const associatedPane =
snap.paneId !== undefined
? (this.paneManager.getPaneById(snap.paneId) ?? this.paneManager.addPane())
: config.newPane
? this.paneManager.addPane()
: this.paneManager.getMainPane();
const indicatorToSet = this.addEntity<Indicator>((zIndex, moveUp, moveDown) => {
return new Indicator({
id,
paneId: associatedPane.getId(),
zIndex,
onDelete: this.deleteIndicator,
moveUp,
moveDown,
mainSymbolId$: this.eventManager.symbolId(),
mainSymbol$: this.eventManager.symbol(),
lwcChart: this.lwcChart,
dataSource: this.dataSource,
associatedPane,
config,
settings: snap.settings,
type: snap.indicatorType,
chartOptions: this.chartOptions,
});
});
indicatorsMap.set(id, indicatorToSet);
this.indicatorsMap$.next(indicatorsMap);
}
public getIndicators(): BehaviorSubject<Map<string, Indicator>> {
return this.indicatorsMap$;
}
public removeEntity(entity: DOMObject): void {
this.DOM.removeEntity(entity);
}
public deleteIndicator = (id: string): void => {
const indicator = this.indicatorsMap$.value.get(id);
if (!indicator) {
return;
}
const indicators = new Map(this.indicatorsMap$.value);
indicators.delete(id);
this.indicatorsMap$.next(indicators);
this.removeEntity(indicator);
indicator.destroy();
};
public entities(): Observable<Indicator[]> {
return this.indicatorsMap$.pipe(
map((indicators) => Array.from(indicators.values())),
);
}
}
function getConfigByIndicatorType({
indicatorType,
existedIndicators,
}: {
indicatorType: IndicatorsIds;
existedIndicators: ReadonlyMap<string, Indicator>;
}): IndicatorConfig {
const configWithAppliedSettings = indicatorsConfigMap()[indicatorType] as IndicatorConfig;
const usedColors = getUsedIndicatorColorsByType({
indicatorType,
existedIndicators,
});
return applyNextIndicatorColors(configWithAppliedSettings, usedColors);
}
function getUsedIndicatorColorsByType({
indicatorType,
existedIndicators,
}: {
indicatorType: IndicatorSnapshot['indicatorType'];
existedIndicators: ReadonlyMap<string, Indicator>;
}): string[] {
const colors: string[] = [];
existedIndicators.forEach((indicator) => {
if (indicator.getIndicatorType() !== indicatorType) {
return;
}
colors.push(...getIndicatorColors(indicator.getConfig()));
});
return colors;
}
import { IChartApi, IPaneApi, PriceScaleMode, Time } from 'lightweight-charts';
import { BehaviorSubject, combineLatest, 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, DrawingsManagerSnapshot } from '@core/DrawingsManager';
import { EventManager } from '@core/EventManager';
import { Hotkeys } from '@core/Hotkeys';
import { Indicator } from '@core/Indicator';
import { Legend } from '@core/Legend';
import { PriceScale, PriceScaleControls } from '@core/PriceScale';
import { ReactRenderer } from '@core/ReactRenderer';
import { TooltipService } from '@core/Tooltip';
import { UIRenderer } from '@core/UIRenderer';
import { EntitySettingsModal } from '@src/components/EntitySettingsModal';
import { DrawingsNames, indicatorLabelById, MAIN_PANE_INDEX } from '@src/constants';
import { ModalRenderer } from '@src/core/ModalRenderer';
import { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { t } from '@src/translations';
import { ActiveDrawingTool, Direction, OHLCConfig, TooltipConfig } from '@src/types';
import {
CompareSnapshot,
DOMObjectSnapshot,
IndicatorSnapshot,
ISerializable,
PaneSnapshot,
PriceScaleSide,
PriceScaleSnapshot,
} from '@src/types/snapshot';
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;
initialPriceScales?: PriceScaleSnapshot[];
onPriceScaleStateChange: () => void;
leftPriceScaleVisible: boolean;
rightPriceScaleVisible: boolean;
hotkeys: Hotkeys;
addDrawingManager: (manager: DrawingsManager, paneId: number) => void;
removeDrawingManager: (paneId: number) => void;
setActiveTool: (name: ActiveDrawingTool) => void;
getActiveTool: () => ActiveDrawingTool;
getIsEndlessMode: () => boolean;
continueDrawing: (name: DrawingsNames) => void;
}
// todo: Pane, ему должна принадлежать mainSerie, а также IndicatorManager и drawingsManager, mouseEvents. Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
// todo: Учитывать, что есть линейка, которая рисуется одна для всех пейнов
// todo: в CompareManage, при создании нового пейна для сравнения - инициализируем новый dataSource, принадлежащий только конкретному пейну. Убираем возможность добавлять индикаторы на такие пейны
// todo: на каждый символ свой DataSource (учитывать что есть MainPane и "главный" DataSource, который инициализиурется во время старта moexChart)
// todo: сделать два разных представления для compare, в зависимости от отображения на главном пейне или на второстепенном
export class Pane implements ISerializable<PaneSnapshot> {
private readonly id: number;
private readonly isMain: boolean;
private mainSeries = new BehaviorSubject<SeriesStrategies | null>(null); // Main Series. Exists in a single copy
// todo: Отвратительный нейминг. Нужно оставить главной только эту серию и переименовать её localMainSeries => mainSerie
// Если вдруг понадобится главная серия главного пейна, то брать из КоллекцииПейнов(-сейчас PaneManager)
// serie to attach drawings
private localMainSeries = new BehaviorSubject<SeriesStrategies | null>(null);
private legend!: Legend;
private tooltip: TooltipService | undefined;
private readonly indicatorsMap = new BehaviorSubject<Map<string, Indicator>>(new Map());
private readonly lwcPane: IPaneApi<Time>;
private readonly lwcChart: IChartApi;
private readonly eventManager: EventManager;
private readonly drawingsManager: DrawingsManager;
private legendContainer!: HTMLElement;
private paneOverlayContainer!: HTMLElement;
private paneEmptyStateContainer!: HTMLElement;
private legendRenderer!: UIRenderer;
private tooltipRenderer: UIRenderer | undefined;
private readonly modalRenderer: ModalRenderer;
private readonly leftPriceScale: PriceScale;
private readonly rightPriceScale: PriceScale;
private readonly priceScaleControls: PriceScaleControls;
private readonly subscribeChartEvent: ChartMouseEvents['subscribe'];
private readonly onDelete: () => void;
private readonly onPriceScaleStateChange: () => void;
private readonly subscriptions = new Subscription();
private paneContainerSyncFrameId: number | null = null;
constructor({
lwcChart,
eventManager,
dataSource,
DOM,
isMainPane,
ohlcConfig,
id,
basedOn,
subscribeChartEvent,
tooltipConfig,
onDelete,
chartContainer,
modalRenderer,
initialPriceScales = [],
onPriceScaleStateChange,
leftPriceScaleVisible,
rightPriceScaleVisible,
hotkeys,
addDrawingManager,
removeDrawingManager,
setActiveTool,
getActiveTool,
getIsEndlessMode,
continueDrawing,
}: PaneParams) {
this.onDelete = onDelete;
this.onPriceScaleStateChange = onPriceScaleStateChange;
this.eventManager = eventManager;
this.lwcChart = lwcChart;
this.modalRenderer = modalRenderer;
this.subscribeChartEvent = subscribeChartEvent;
this.isMain = isMainPane;
this.id = id;
if (isMainPane) {
this.lwcPane = this.lwcChart.panes()[MAIN_PANE_INDEX];
} else {
this.lwcPane = this.lwcChart.addPane(true);
}
this.leftPriceScale = this.createPriceScale(Direction.Left, initialPriceScales, leftPriceScaleVisible);
this.rightPriceScale = this.createPriceScale(Direction.Right, initialPriceScales, rightPriceScaleVisible);
// TODO: Перенести PriceScaleControls внутрь PriceScale, чтобы каждая шкала владела собственными контролами, а PriceScaleControls работал только с одной шкалой.
this.priceScaleControls = new PriceScaleControls({
leftPriceScale: this.leftPriceScale,
rightPriceScale: this.rightPriceScale,
onPriceScaleChange: this.handlePriceScaleStateChange,
});
this.initializeLegend({ ohlcConfig });
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()}
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.subscriptions.add(
this.mainSeries.subscribe(() => {
this.rebindIndicators();
}),
);
this.subscriptions.add(
combineLatest([this.eventManager.hasSymbol(), this.indicatorsMap]).subscribe(([hasMainSymbol, indicators]) => {
const hasIndicator = Array.from(indicators.values()).some((indicator) => indicator.getType() !== undefined);
this.paneEmptyStateContainer.hidden = hasMainSymbol || !hasIndicator;
}),
);
this.drawingsManager = new DrawingsManager({
// todo: менеджер дровингов должен быть один на чарт, не на пейн
pane: this,
eventManager,
DOM,
mainSeries$: this.localMainSeries.asObservable(),
lwcChart,
container: chartContainer,
modalRenderer: this.modalRenderer,
paneId: this.id,
hotkeys,
setActiveTool,
getActiveTool,
getIsEndlessMode,
continueDrawing,
});
addDrawingManager(this.drawingsManager, this.id);
this.subscriptions.add(() => removeDrawingManager(this.id));
this.subscriptions.add(
this.drawingsManager.entities().subscribe((drawings) => {
const hasRuler = drawings.some((drawing) => drawing.getDrawingName() === DrawingsNames.ruler);
this.legendContainer.style.display = hasRuler ? 'none' : '';
}),
);
}
public getLocalMainSeries = () => {
return this.localMainSeries.value;
};
public setLocalMainSeries = (next: SeriesStrategies) => {
if (!this.isMain) {
this.localMainSeries.next(next);
}
};
public isMainPane = () => {
return this.isMain;
};
public getDrawingsSnapshot(): DrawingsManagerSnapshot {
return this.drawingsManager.getSnapshot();
}
public setDrawingsSnapshot(snapshot: DrawingsManagerSnapshot): void {
this.drawingsManager.setSnapshot(snapshot);
}
public getMainSerie = () => {
return this.mainSeries;
};
public getId = () => {
return this.id;
};
public isReady = async (interval = 50): Promise<void> => {
return new Promise((resolve) => {
const check = () => {
if (this.lwcPane.getHTMLElement() === null) {
setTimeout(check, interval);
} else {
resolve();
}
};
check();
});
};
public getHTMLElement = () => {
return this.lwcPane.getHTMLElement();
};
public paneIndex = () => {
return this.lwcPane.paneIndex();
};
public getPriceScale(side: PriceScaleSide): PriceScale {
return side === Direction.Left ? this.leftPriceScale : this.rightPriceScale;
}
public setIndicator(indicatorId: string, indicator: Indicator): void {
const indicators = new Map(this.indicatorsMap.value);
indicators.set(indicatorId, indicator);
this.indicatorsMap.next(indicators);
this.priceScaleControls.refresh();
}
public removeIndicator(indicatorId: string): void {
if (!this.indicatorsMap.value.has(indicatorId)) {
return;
}
const indicators = new Map(this.indicatorsMap.value);
indicators.delete(indicatorId);
this.indicatorsMap.next(indicators);
this.priceScaleControls.refresh();
if (indicators.size === 0 && !this.isMain) {
this.onDelete();
}
}
public getDrawingManager(): DrawingsManager {
return this.drawingsManager;
}
public schedulePaneContainerSync(): void {
if (this.paneContainerSyncFrameId !== null) {
return;
}
this.paneContainerSyncFrameId = requestAnimationFrame(() => {
this.paneContainerSyncFrameId = null;
this.syncPaneContainers();
});
}
public refreshPriceScaleControls(): void {
this.priceScaleControls.refresh();
}
public resetPriceScalesAutoScale(): void {
this.leftPriceScale.enableAutoScale();
this.rightPriceScale.enableAutoScale();
this.handlePriceScaleStateChange();
}
public getSnapshot(): PaneSnapshot {
const indicators: (DOMObjectSnapshot & (IndicatorSnapshot | CompareSnapshot))[] = [];
this.indicatorsMap.value.forEach((indicator) => {
indicators.push(indicator.getSnapshot());
});
return {
isMain: this.isMain,
id: this.id,
indicators,
drawings: this.getDrawingsSnapshot(),
priceScales: [this.leftPriceScale.getSnapshot(), this.rightPriceScale.getSnapshot()],
};
}
public destroy(): void {
if (this.paneContainerSyncFrameId !== null) {
cancelAnimationFrame(this.paneContainerSyncFrameId);
this.paneContainerSyncFrameId = null;
}
this.drawingsManager.destroy();
this.subscriptions.unsubscribe();
this.tooltip?.destroy();
this.legend?.destroy();
this.legendRenderer.destroy();
this.tooltipRenderer?.destroy();
this.priceScaleControls.destroy();
this.paneEmptyStateContainer.remove();
this.legendContainer.remove();
this.paneOverlayContainer.remove();
this.indicatorsMap.complete();
this.localMainSeries.complete();
if (this.isMain) {
this.mainSeries.value?.destroy();
this.mainSeries.complete();
}
}
private createPriceScale(
side: PriceScaleSide,
initialPriceScales: PriceScaleSnapshot[],
initialVisible: boolean,
): PriceScale {
const initialMode =
initialPriceScales.find((priceScaleSnapshot) => priceScaleSnapshot.side === side)?.mode ?? PriceScaleMode.Normal;
return new PriceScale({
paneId: this.id,
side,
pane: this.lwcPane,
initialMode,
initialVisible,
hasVisibleSeriesData: () => this.hasVisibleSeriesData(side),
});
}
private hasVisibleSeriesData(side: PriceScaleSide): boolean {
const mainSeries = this.mainSeries.value;
const defaultPriceScaleSide = this.lwcChart.options().defaultVisiblePriceScaleId as PriceScaleSide;
if (this.isMain && side === defaultPriceScaleSide && mainSeries?.isVisible() && mainSeries.data().length > 0) {
return true;
}
const indicators = Array.from(this.indicatorsMap.value.values());
for (let indicatorIndex = 0; indicatorIndex < indicators.length; indicatorIndex += 1) {
const series = Array.from(indicators[indicatorIndex].getSeriesMap().values());
for (let seriesIndex = 0; seriesIndex < series.length; seriesIndex += 1) {
const currentSeries = series[seriesIndex];
const seriesPriceScaleSide = (currentSeries.options().priceScaleId ?? defaultPriceScaleSide) as PriceScaleSide;
if (currentSeries.isVisible() && currentSeries.data().length > 0 && seriesPriceScaleSide === side) {
return true;
}
}
}
return false;
}
private handlePriceScaleStateChange = (): void => {
this.priceScaleControls.refresh();
this.onPriceScaleStateChange();
};
private initializeLegend({ ohlcConfig }: { ohlcConfig: OHLCConfig }): void {
const { legendContainer, paneOverlayContainer, paneEmptyStateContainer } =
ContainerManager.createPaneContainers();
this.legendContainer = legendContainer;
this.paneOverlayContainer = paneOverlayContainer;
this.paneEmptyStateContainer = paneEmptyStateContainer;
this.paneEmptyStateContainer.textContent = t('Add main instrument to display indicator');
this.legendRenderer = new ReactRenderer(legendContainer);
this.schedulePaneContainerSync();
this.legend = new Legend({
config: ohlcConfig,
indicators: this.indicatorsMap,
eventManager: this.eventManager,
subscribeChartEvent: this.subscribeChartEvent,
mainSeries: this.isMain ? this.mainSeries : null,
paneId: this.id,
paneIndex: this.paneIndex,
openIndicatorSettings: (indicatorId, indicator) => {
let settings = indicator.getSettingsValues();
this.modalRenderer.renderComponent(
<EntitySettingsModal
tabs={[
{
key: 'arguments',
label: t('Arguments'),
fields: indicator.getSettingsFields(),
},
]}
values={settings}
onChange={(nextSettings) => {
settings = nextSettings;
}}
initialTabKey="arguments"
/>,
{
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 rebindIndicators(): void {
for (const indicator of this.indicatorsMap.value.values()) {
indicator.recreateSeries();
}
}
private initializeMainSerie({ lwcChart, dataSource }: { lwcChart: IChartApi; dataSource: DataSource }): void {
this.localMainSeries = this.mainSeries;
this.subscriptions.add(
this.eventManager.subscribeSeriesSelected((nextSeries) => {
this.mainSeries.value?.destroy();
const next = ensureDefined(SeriesFactory.create(nextSeries))({
lwcChart,
dataSource,
mainSymbolId$: this.eventManager.symbolId(),
mainSymbol$: this.eventManager.symbol(),
mainSerie$: this.mainSeries,
});
this.mainSeries.next(next);
this.priceScaleControls.refresh();
}),
);
}
private syncPaneContainers(): void {
const lwcPaneElement = this.lwcPane.getHTMLElement();
if (!lwcPaneElement) {
this.schedulePaneContainerSync();
return;
}
/*
Внутри lightweight-chart DOM построен как таблица из 3 td
[0] left priceScale, [1] center chart, [2] right priceScale
Кладём легенду в td[1] и тогда легенда сама будет адаптироваться при изменении ширины шкал
*/
const cells = lwcPaneElement.querySelectorAll<HTMLTableCellElement>(':scope > td');
const chartCell = cells.item(1);
if (!chartCell) {
this.schedulePaneContainerSync();
return;
}
chartCell.style.position = 'relative';
chartCell.appendChild(this.legendContainer);
chartCell.appendChild(this.paneEmptyStateContainer);
chartCell.appendChild(this.paneOverlayContainer);
this.priceScaleControls.mount(lwcPaneElement);
}
}
panesSnapshot.forEach((paneSnapshot) => {
if (paneSnapshot.isMain) {
return;
}
if (paneSnapshot.indicators.length === 0 && paneSnapshot.drawings.length === 0) {
return;
}
const pane = this.addPane(undefined, paneSnapshot.id, paneSnapshot.priceScales);
pane.setDrawingsSnapshot(paneSnapshot.drawings);
});
private scheduleHistoryBatch = () => {
if (this.historyBatchRunning || this.activeSymbolIds.length === 0) {
return;
}
this.historyBatchRunning = true;
requestAnimationFrame(() => {
const symbolIds = this.activeSymbolIds.slice();
Promise.all(symbolIds.map((symbolId) => this.dataSource.loadMoreHistory(symbolId))).finally(() => {
this.historyBatchRunning = false;
const range = this.lwcChart.timeScale().getVisibleLogicalRange();
if (range && range.from < HISTORY_LOAD_THRESHOLD) {
this.scheduleHistoryBatch();
}
});
});
};
const symbolIds$ = combineLatest([this.eventManager.symbolId(), this.compareManager.itemsObs()]).pipe(
map(([mainSymbolId, items]) =>
Array.from(new Set([mainSymbolId, ...items.map(({ symbolId }) => symbolId)].filter((symbolId) => symbolId !== ''))),
),
);
if (!interval || symbolIds.length === 0) {
return;
}
public setSymbol(symbolInfo: SymbolInfoInput): void {
this.eventManager.setSymbol(symbolInfo);
}
public clearSymbol(): void {
this.eventManager.clearSymbol();
}
.moex-chart-pane-empty-state-container {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-2000);
text-align: center;
pointer-events: none;
}
.moex-chart-pane-empty-state-container[hidden] {
display: none;
}
'Add main instrument to display indicator':
'Для отображения индикатора необходимо добавить основной инструмент',