Загрузка данных
import { 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 = '10',
Modal = '1000',
}
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.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.width = '250px';
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 modalContainer = document.createElement('div');
modalContainer.className = 'moex-chart-modal-container';
modalContainer.style.position = 'absolute';
modalContainer.style.width = '100%';
modalContainer.style.height = '100%';
modalContainer.style.maxWidth = '100%';
modalContainer.style.maxHeight = '100%';
modalContainer.style.zIndex = ZIndex.Modal;
modalContainer.style.pointerEvents = 'none';
chartAreaContainer.append(controlBarContainer, modalContainer);
chartContainer.append(chartAreaContainer);
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,
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.Base;
legendContainer.style.pointerEvents = 'none';
const paneOverlayContainer = document.createElement('div');
paneOverlayContainer.className = 'moex-chart-pane-overlay-container';
paneOverlayContainer.style.position = 'absolute';
paneOverlayContainer.style.inset = '0';
paneOverlayContainer.style.zIndex = ZIndex.Base;
paneOverlayContainer.style.pointerEvents = 'none';
return {
legendContainer,
paneOverlayContainer,
};
}
}
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 { Hotkeys, Keys } from '@core/Hotkeys';
import { EntitySettingsModal } from '@src/components/EntitySettingsModal';
import { drawingLabelById, drawingsMap, DrawingsNames } from '@src/constants';
import { ModalRenderer } from '@src/core/ModalRenderer';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ActiveDrawingTool } from '@src/types';
interface DrawingsManagerParams {
eventManager: EventManager;
mainSeries$: Observable<SeriesStrategies | null>;
lwcChart: IChartApi;
DOM: DOMModel;
container: HTMLElement;
modalRenderer: ModalRenderer;
paneId: number;
hotkeys: Hotkeys;
}
export interface DrawingSnapshotItem {
id: string;
drawingName: DrawingsNames;
state: unknown;
}
interface CreateDrawingOptions {
id?: string;
state?: unknown;
shouldUpdateDrawingsList?: boolean;
}
export type DrawingsManagerSnapshot = DrawingSnapshotItem[];
export class DrawingsManager {
private eventManager: EventManager;
private lwcChart: IChartApi;
private DOM: DOMModel;
private container: HTMLElement;
private modalRenderer: ModalRenderer;
private mainSeries: SeriesStrategies | null = null;
private subscriptions = new Subscription();
private drawings$ = new BehaviorSubject<Drawing[]>([]);
private activeTool$ = new BehaviorSubject<ActiveDrawingTool>('crosshair');
private endlessMode$ = new BehaviorSubject(false);
private recreateScheduled = false;
private pendingSnapshot: DrawingsManagerSnapshot | null = null;
private paneId: number;
private hotkeys: Hotkeys;
private copyPasteBuffer: DrawingSnapshotItem | null = null;
private escapeUnregisterHash: string | null = null;
constructor({
eventManager,
mainSeries$,
lwcChart,
DOM,
container,
modalRenderer,
paneId,
hotkeys,
}: DrawingsManagerParams) {
this.DOM = DOM;
this.eventManager = eventManager;
this.paneId = paneId;
this.lwcChart = lwcChart;
this.container = container;
this.modalRenderer = modalRenderer;
this.hotkeys = hotkeys;
this.subscriptions.add(
mainSeries$.subscribe((series) => {
if (!series) {
return;
}
this.mainSeries = series;
this.drawings$.value.forEach((drawing) => drawing.rebind(series));
if (this.pendingSnapshot) {
const snapshot = this.pendingSnapshot;
this.pendingSnapshot = null;
this.setSnapshot(snapshot);
}
}),
);
window.addEventListener('pointerup', this.handlePointerUp);
this.container.addEventListener('click', this.handleClick);
this.container.addEventListener('pointerdown', this.handlePointerDown);
// todo: implement ctrl+v
// hotkeys.register({
// keys: [Keys.control, Keys.v],
// callback: () => {
// const bufferWithAppliedPosition = {
// ...this.copyPasteBuffer,
// state: {
// ...this.copyPasteBuffer?.state,
// startAnchor: {
// price: 73.36210252637723,
// time: 1783679170
// }
// }
// }
//
// this.setSnapshot([
// ...this.getSnapshot(),
// bufferWithAppliedPosition
// ])
// // hotkeys.unregister({ // todo: unregister all else ctrl+c's
// // keys: [Keys.control, Keys.c]
// // })
// }
// })
}
private handlePointerDown = (): void => {
this.DOM.refreshEntities();
};
private handlePointerUp = (): void => {
this.DOM.refreshEntities();
this.updateActiveTool();
};
private handleClick = (): void => {
this.DOM.refreshEntities();
this.updateActiveTool();
};
private updateActiveTool = (): void => {
const hasPendingDrawing = this.drawings$.value.some((drawing) => drawing.isCreationPending());
if (hasPendingDrawing) {
return;
}
const activeTool = this.activeTool$.value;
const isSingleInstanceTool = activeTool !== 'crosshair' && drawingsMap[activeTool]?.singleInstance;
if (activeTool !== 'crosshair' && this.endlessMode$.value && !isSingleInstanceTool) {
if (this.recreateScheduled) {
return;
}
this.recreateScheduled = true;
queueMicrotask(() => {
this.recreateScheduled = false;
const currentTool = this.activeTool$.value;
const hasPendingAfterTick = this.drawings$.value.some((drawing) => drawing.isCreationPending());
if (currentTool === 'crosshair') {
return;
}
if (!this.endlessMode$.value) {
return;
}
if (drawingsMap[currentTool]?.singleInstance) {
return;
}
if (hasPendingAfterTick) {
return;
}
this.createDrawing(currentTool);
});
return;
}
this.activeTool$.next('crosshair');
};
private removeDrawing = (id: string): void => {
const drawing = this.drawings$.value.find((item) => item.id === id);
if (!drawing) {
return;
}
this.removeDrawings([drawing]);
};
private removeDrawingsByName(name: DrawingsNames, shouldUpdateTool = true): void {
const drawingsToRemove = this.drawings$.value.filter((drawing) => drawing.getDrawingName() === name);
this.removeDrawings(drawingsToRemove, shouldUpdateTool);
}
private removePendingDrawings(shouldUpdateTool = true): void {
const drawingsToRemove = this.drawings$.value.filter((drawing) => drawing.isCreationPending());
this.removeDrawings(drawingsToRemove, shouldUpdateTool);
}
private removeDrawings(drawingsToRemove: Drawing[], shouldUpdateTool = true): 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)));
if (shouldUpdateTool) {
this.updateActiveTool();
}
this.DOM.refreshEntities();
}
public addDrawingForce = (name: DrawingsNames): Promise<void> => {
this.removePendingDrawings(false);
if (drawingsMap[name].singleInstance) {
this.removeDrawingsByName(name, false);
}
this.activeTool$.next(name);
const drawing = this.createDrawing(name);
this.DOM.refreshEntities();
return drawing.waitForCreation();
};
private createDrawing(name: DrawingsNames, options: CreateDrawingOptions = {}): Drawing {
if (!this.mainSeries) {
throw new Error('[Drawings] main series is not defined');
}
const { id, state, shouldUpdateDrawingsList = true } = options;
const config = drawingsMap[name];
const drawingId = id ?? crypto.randomUUID();
let createdDrawing: Drawing | null = null;
const construct = (chart: IChartApi, series: ISeriesApi<SeriesType>) =>
config.construct({
chart,
series,
eventManager: this.eventManager,
container: this.container,
removeSelf: () => this.removeDrawing(drawingId),
openSettings: () => {
if (createdDrawing) {
this.openSettings(createdDrawing);
}
},
});
const drawingFactory = (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) =>
new Drawing({
lwcChart: this.lwcChart,
mainSeries: this.mainSeries as SeriesStrategies,
id: drawingId,
drawingName: name,
name: drawingLabelById()[name],
onDelete: this.removeDrawing,
zIndex,
moveDown,
moveUp,
construct,
paneId: this.paneId,
hotkeys: this.hotkeys,
setCopyPasteBuffer: (copyPasteBuffer) => {
this.copyPasteBuffer = copyPasteBuffer;
},
resetActiveTool: () => {
this.activeTool$.next('crosshair');
},
});
const entity = this.DOM.setEntity<Drawing>(drawingFactory);
createdDrawing = entity;
if (state !== undefined) {
entity.setState(state);
}
if (shouldUpdateDrawingsList) {
this.drawings$.next([...this.drawings$.value, entity]);
}
return entity;
}
public getSnapshot(): DrawingsManagerSnapshot {
return this.drawings$.value
.filter((drawing) => !drawing.isCreationPending())
.map((drawing) => ({
id: drawing.id,
drawingName: drawing.getDrawingName(),
state: drawing.getState(),
}));
}
public setSnapshot(snapshot: DrawingsManagerSnapshot): void {
if (!Array.isArray(snapshot)) {
return;
}
if (!this.mainSeries) {
this.pendingSnapshot = snapshot;
return;
}
this.removeDrawings(this.drawings$.value, false);
const restoredDrawings = snapshot.reduce<Drawing[]>((drawings, item) => {
if (!drawingsMap[item.drawingName]) {
return drawings;
}
drawings.push(
this.createDrawing(item.drawingName, { id: item.id, state: item.state, shouldUpdateDrawingsList: false }),
);
return drawings;
}, []);
this.drawings$.next(restoredDrawings);
this.activeTool$.next('crosshair');
this.DOM.refreshEntities();
}
public setEndlessDrawingMode = (value: boolean): void => {
if (value) {
this.escapeUnregisterHash = this.hotkeys.register({
keys: [Keys.escape],
callback: () => {
this.setEndlessDrawingMode(false);
},
});
} else {
this.hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
}
this.endlessMode$.next(value);
};
public isEndlessDrawingsMode(): Observable<boolean> {
return this.endlessMode$.asObservable();
}
public getActiveTool(): Observable<ActiveDrawingTool> {
return this.activeTool$.asObservable();
}
public activateCrosshair(): void {
this.removePendingDrawings(false);
this.activeTool$.next('crosshair');
this.DOM.refreshEntities();
}
public entities(): Observable<Drawing[]> {
return this.drawings$.asObservable();
}
private openSettings = (drawing: Drawing) => {
const tabs = drawing.getSettingsTabs();
if (!tabs.length || tabs.every((tab) => tab.fields.length === 0)) {
return;
}
let settings = drawing.getSettings();
this.modalRenderer.renderComponent(
<EntitySettingsModal
tabs={tabs}
values={settings}
onChange={(nextSettings) => {
settings = nextSettings;
}}
initialTabKey={tabs[0]?.key}
/>,
{
size: 'sm',
title: drawing.name,
onSave: () => drawing.updateSettings(settings),
},
);
};
public getDrawings(): Drawing[] {
return this.drawings$.value;
}
public hideAll(): void {
this.drawings$.value.forEach((drawing) => drawing.hide());
this.DOM.refreshEntities();
}
public destroy(): void {
this.hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
window.removeEventListener('pointerup', this.handlePointerUp);
this.container.removeEventListener('click', this.handleClick);
this.container.removeEventListener('pointerdown', this.handlePointerDown);
this.drawings$.value.forEach((drawing) => drawing.destroy());
this.subscriptions.unsubscribe();
this.drawings$.complete();
this.activeTool$.complete();
this.endlessMode$.complete();
}
}
import { IChartApi, IPaneApi, PriceScaleMode, 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, 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 { Direction, OHLCConfig, TooltipConfig } from '@src/types';
import {
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;
}
// 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
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 legendRenderer!: UIRenderer;
private tooltipRenderer: UIRenderer | undefined;
private readonly modalRenderer: ModalRenderer;
private readonly leftPriceScale: PriceScale;
private readonly rightPriceScale: PriceScale;
private readonly priceScaleControls: PriceScaleControls;
private mainSerieSub?: Subscription;
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,
}: 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()}
// 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,
modalRenderer: this.modalRenderer,
paneId: this.id,
hotkeys,
});
this.subscriptions.add(
this.drawingsManager.entities().subscribe((drawings) => {
const hasRuler = drawings.some((drawing) => drawing.getDrawingName() === DrawingsNames.ruler);
this.legendContainer.style.display = hasRuler ? 'none' : '';
}),
);
}
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 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 map = this.indicatorsMap.value;
map.set(indicatorId, indicator);
this.indicatorsMap.next(map);
this.priceScaleControls.refresh();
}
public removeIndicator(indicatorId: string): void {
const map = this.indicatorsMap.value;
map.delete(indicatorId);
this.indicatorsMap.next(map);
this.priceScaleControls.refresh();
if (map.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)[] = [];
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.subscriptions.unsubscribe();
this.tooltip?.destroy();
this.legend?.destroy();
this.legendRenderer.destroy();
this.tooltipRenderer?.destroy();
this.priceScaleControls.destroy();
this.legendContainer.remove();
this.paneOverlayContainer.remove();
this.indicatorsMap.complete();
this.mainSerieSub?.unsubscribe();
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;
if (this.isMain && side === Direction.Right && 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 options = currentSeries.options();
const seriesPriceScaleSide = options.priceScaleId ?? Direction.Right;
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 } = ContainerManager.createPaneContainers();
this.legendContainer = legendContainer;
this.paneOverlayContainer = paneOverlayContainer;
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.getSettings();
this.modalRenderer.renderComponent(
<EntitySettingsModal
tabs={[
{
key: 'arguments',
label: t('Arguments'),
fields: indicator.getSettingsConfig(),
},
]}
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 initializeMainSerie({ lwcChart, dataSource }: { lwcChart: IChartApi; dataSource: DataSource }): void {
this.mainSerieSub = 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.paneOverlayContainer);
this.priceScaleControls.mount(lwcPaneElement);
}
}
import { 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 = '10',
Modal = '1000',
}
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.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.width = '250px';
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 modalContainer = document.createElement('div');
modalContainer.className = 'moex-chart-modal-container';
modalContainer.style.position = 'absolute';
modalContainer.style.width = '100%';
modalContainer.style.height = '100%';
modalContainer.style.maxWidth = '100%';
modalContainer.style.maxHeight = '100%';
modalContainer.style.zIndex = ZIndex.Modal;
modalContainer.style.pointerEvents = 'none';
chartAreaContainer.append(controlBarContainer, modalContainer);
chartContainer.append(chartAreaContainer);
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,
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.Base;
legendContainer.style.pointerEvents = 'none';
const paneOverlayContainer = document.createElement('div');
paneOverlayContainer.className = 'moex-chart-pane-overlay-container';
paneOverlayContainer.style.position = 'absolute';
paneOverlayContainer.style.inset = '0';
paneOverlayContainer.style.zIndex = ZIndex.Base;
paneOverlayContainer.style.pointerEvents = 'none';
return {
legendContainer,
paneOverlayContainer,
};
}
}
import { ChartSeriesType, Intervals, SymbolInfo, TimeFormat, Timeframes } from '@src/types';
import { DateFormat } from '@src/utils';
export type UndoKey = keyof UndoConfig;
interface UndoConfig {
timeframe: (value: Timeframes) => void;
seriesSelected: (value: ChartSeriesType) => void;
symbolInfo: (value: SymbolInfo) => void;
timeFormat: (value: TimeFormat) => void;
dateFormat: (value: DateFormat) => void;
interval: (value: Intervals | null) => void;
}
interface HistoryItem {
kind: 'item';
key: UndoKey;
prev: unknown;
next: unknown;
}
interface HistoryGroup {
kind: 'group';
entries: HistoryEntry[];
}
type HistoryEntry = HistoryItem | HistoryGroup;
export class UndoRedo {
private undoStack: HistoryEntry[] = [];
private redoStack: HistoryEntry[] = [];
private groupStack: HistoryEntry[][] = [];
constructor(private readonly config: UndoConfig) {}
private beginGroup(): void {
this.groupStack.push([]);
}
private endGroup(): void {
const entries = this.groupStack.pop();
if (!entries || entries.length === 0) return;
const group: HistoryGroup = { kind: 'group', entries };
const parent = this.groupStack[this.groupStack.length - 1];
if (parent) {
parent.push(group);
return;
}
this.undoStack.push(group);
this.redoStack = [];
}
public group<T>(fn: () => T): T {
this.beginGroup();
try {
return fn();
} finally {
this.endGroup();
}
}
public push(key: UndoKey, prev: unknown, next: unknown): void {
if (Object.is(prev, next)) return;
const item: HistoryItem = { kind: 'item', key, prev, next };
if (this.redoStack.length > 0) {
this.redoStack = [];
}
const currentGroup = this.groupStack[this.groupStack.length - 1];
if (currentGroup) {
currentGroup.push(item);
return;
}
this.undoStack.push(item);
}
public undo(): void {
const entry = this.undoStack.pop();
if (!entry) return;
this.applyEntry(entry, 'undo');
this.redoStack.push(entry);
}
public redo(): void {
const entry = this.redoStack.pop();
if (!entry) return;
this.applyEntry(entry, 'redo');
this.undoStack.push(entry);
}
private applyEntry(entry: HistoryEntry, direction: 'undo' | 'redo'): void {
if (entry.kind === 'group') {
const list = entry.entries;
if (direction === 'undo') {
for (let i = list.length - 1; i >= 0; i -= 1) this.applyEntry(list[i], direction);
} else {
for (let i = 0; i < list.length; i += 1) this.applyEntry(list[i], direction);
}
return;
}
const apply = this.config[entry.key] as (v: unknown) => void;
apply(direction === 'undo' ? entry.prev : entry.next);
}
public canUndo(): boolean {
return this.undoStack.length > 0;
}
public canRedo(): boolean {
return this.redoStack.length > 0;
}
public clear(): void {
this.undoStack = [];
this.redoStack = [];
this.groupStack = [];
}
}