Загрузка данных
import { combineLatest, Subscription } from 'rxjs';
import { ControlBar } from '@components/ControlBar';
import { Footer } from '@components/Footer';
import { Header } from '@components/Header';
import { DataSource, DataSourceParams } from '@core/DataSource';
import { Hotkeys, Keys } from '@core/Hotkeys';
import { ModalRenderer } from '@core/ModalRenderer';
import { FloatingDrawingToolbar } from '@src/components/FloatingToolbar';
import { SettingsModal } from '@src/components/SettingsModal';
import Toolbar from '@src/components/Toolbar';
import { IndicatorsIds } from '@src/constants';
import { CompareManager } from '@src/core/CompareManager';
import { FullscreenController } from '@src/core/Fullscreen';
import { configureThemeStore } from '@src/theme/store';
import { ThemeKey, ThemeMode } from '@src/theme/types';
import { Locale, setLocale, t } from '@src/translations';
import { Candle, ChartSeriesType, ChartTypeOptions, OHLCConfig, 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;
/**
* Дефолтная конфигурация тултипа - всегда показывается по умолчанию.
* При добавлении/изменении полей в конфиге - они объединяются с дефолтными значениями.
*
* Полная кастомизация:
* @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'];
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;
lwcInheritedChartOptions?: ChartTypeOptions;
}
export class MoexChart implements ISerializable<MoexChartSnapshot> {
private chart!: Chart;
private resizeObserver?: ResizeObserver;
private eventManager!: EventManager;
private hotkeys!: Hotkeys;
private rootContainer!: HTMLElement;
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.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,
symbol,
symbolName,
},
initialTimeFormat: timeFormat,
initialDateFormat: dateFormat,
initialInterval: interval,
});
// todo: сюда прокидывается не подходящий под сигнатуру интерфейс. Функция не работает
// if (config.lwcInheritedChartOptions) {
// this.setSettings(config.lwcInheritedChartOptions);
// }
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,
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();
if (config.chartCollectionPreset.undoRedoEnabled) {
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,
});
}
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,
chartOptions: config.lwcInheritedChartOptions, // todo: remove, use only model from eventManager
},
});
this.subscriptions.add(
combineLatest([store.theme$, store.mode$]).subscribe(([theme, mode]) => {
this.chart.updateTheme(theme, mode);
document.documentElement.dataset.theme = theme;
document.documentElement.dataset.mode = mode;
}),
);
const realtimeParams = this.chart.getRealtimeApi();
this.subscriptions.add(
config.chartCollectionPreset.startRealtime(
realtimeParams.getSymbols,
realtimeParams.getTimeframe,
realtimeParams.update,
),
);
this.headerRenderer = new ReactRenderer(headerContainer);
this.toolbarRenderer = new ReactRenderer(toolBarContainer);
this.drawingToolbarRenderer = new ReactRenderer(drawingToolbarContainer);
if (config.chartCollectionPreset.showControlBar) {
this.controlBarRenderer = new ReactRenderer(controlBarContainer);
}
if (config.chartCollectionPreset.showBottomPanel) {
this.footerRenderer = new ReactRenderer(footerContainer);
}
this.timeScaleHoverController = new TimeScaleHoverController({
eventManager: this.eventManager,
controlBarContainer,
chartContainer: chartAreaContainer,
});
this.renderAttachments(config, toggleToolbar);
};
public setSettings(settings: ChartSettingsSource): void {
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.eventManager.setSymbol(symbolInfo);
}
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.headerRenderer.renderComponent(
<Header
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 })
}
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'}
/>,
);
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.headerRenderer.destroy();
this.drawingToolbarRenderer.destroy();
this.subscriptions.unsubscribe();
this.timeScaleHoverController.destroy();
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = undefined;
}
if (this.controlBarRenderer) {
this.controlBarRenderer.destroy();
}
if (this.footerRenderer) {
this.footerRenderer.destroy();
}
if (this.chart) {
this.chart.destroy();
}
this.hotkeys.destroy();
if (this.eventManager) {
this.eventManager.destroy();
}
if (this.toolbarRenderer) {
this.toolbarRenderer.destroy();
}
this.dataSource.destroy();
ContainerManager.clearContainers(this.rootContainer);
}
}
import dayjs from 'dayjs';
import {
BarPrice,
ChartOptions,
createChart,
CrosshairMode,
DeepPartial,
IChartApi,
IRange,
LocalizationOptionsBase,
LogicalRange,
Time,
UTCTimestamp,
} from 'lightweight-charts';
import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
import { map, withLatestFrom } from 'rxjs/operators';
import { ChartMouseEvents } from '@core/ChartMouseEvents';
import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { DrawingsManagerCollection } from '@core/DrawingsManagerCollection';
import { EventManager } from '@core/EventManager';
import { Hotkeys } from '@core/Hotkeys';
import { IndicatorManager } from '@core/IndicatorManager';
import { ModalRenderer } from '@core/ModalRenderer';
import { PaneManager } from '@core/PaneManager';
import { CompareManager } from '@src/core/CompareManager';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { getThemeStore } from '@src/theme/store';
import { ThemeKey, ThemeMode } from '@src/theme/types';
import { getLocale } from '@src/translations';
import {
Candle,
ChartOptionsModel,
ChartSeriesType,
ChartTypeOptions,
Direction,
OHLCConfig,
TooltipConfig,
} from '@src/types';
import { Defaults } from '@src/types/defaults';
import { DayjsOffset, Intervals, intervalsToDayjs } from '@src/types/intervals';
import { ChartSnapshot, CompareSnapshot, IndicatorSnapshot, ISerializable, PaneSnapshot } from '@src/types/snapshot';
import { formatCompactNumber } from '@src/utils';
import { createTickMarkFormatter, formatDate } from '@src/utils/formatter';
export interface ChartConfig extends Partial<ChartOptionsModel> {
container: HTMLElement;
seriesTypes: ChartSeriesType[];
theme: ThemeKey;
mode?: ThemeMode;
chartOptions?: ChartTypeOptions;
localization?: LocalizationOptionsBase;
}
export enum Resize {
Shrink,
Expand,
}
const HISTORY_LOAD_THRESHOLD = 500;
interface ChartParams {
params: {
dataSource: DataSource;
eventManager: EventManager;
modalRenderer: ModalRenderer;
ohlcConfig: OHLCConfig;
tooltipConfig: TooltipConfig;
panes: PaneSnapshot[];
hotkeys: Hotkeys;
};
lwcChartConfig: ChartConfig;
}
function splitIndicatorSnapshots(panes: PaneSnapshot[]): {
compareSnapshots: CompareSnapshot[];
indicatorSnapshots: IndicatorSnapshot[];
} {
const snapshots = panes.flatMap(({ id, indicators }) =>
indicators.map((indicator) => ({
...indicator,
paneId: id,
})),
);
function isIndicatorSnapshot(
input: (IndicatorSnapshot | CompareSnapshot) & { indicatorType?: unknown },
): input is IndicatorSnapshot {
return input.indicatorType !== undefined;
}
function isCompareSnapshot(
input: (IndicatorSnapshot | CompareSnapshot) & { indicatorType?: unknown },
): input is CompareSnapshot {
return input.indicatorType === undefined;
}
return {
indicatorSnapshots: snapshots.filter((x) => isIndicatorSnapshot(x)),
compareSnapshots: snapshots.filter((x) => isCompareSnapshot(x)),
};
}
/**
* Абстракция над библиотекой для построения графиков
*/
export class Chart implements ISerializable<ChartSnapshot> {
private lwcChart!: IChartApi;
private container: HTMLElement;
private eventManager: EventManager;
private paneManager!: PaneManager;
private compareManager: CompareManager;
private mouseEvents: ChartMouseEvents;
private indicatorManager: IndicatorManager;
private optionsSubscription: Subscription;
private dataSource: DataSource;
private chartConfig: ChartConfig;
private mainSeries: BehaviorSubject<SeriesStrategies | null>; // Main Series. Exists in a single copy
private DOM: DOMModel;
private isPointerDown = false;
private didResetOnDrag = false;
private subscriptions = new Subscription();
private currentInterval: Intervals | null = null;
private activeSymbolIds: string[] = [];
private historyBatchRunning = false;
constructor({ params, lwcChartConfig }: ChartParams) {
const { eventManager, dataSource, modalRenderer, ohlcConfig, tooltipConfig, panes: panesSnapshot } = params;
this.eventManager = eventManager;
this.dataSource = dataSource;
this.container = lwcChartConfig.container;
this.chartConfig = lwcChartConfig;
this.lwcChart = createChart(this.container, getOptions(lwcChartConfig));
this.optionsSubscription = this.eventManager
.getChartOptionsModel()
.subscribe(({ dateFormat, timeFormat, showTime }) => {
this.chartConfig = {
...this.chartConfig,
dateFormat,
timeFormat,
showTime,
};
this.lwcChart.applyOptions({
...getOptions(this.chartConfig),
localization: {
timeFormatter: (time: UTCTimestamp) => formatDate(time, dateFormat, timeFormat, showTime),
},
});
});
this.subscriptions.add(this.optionsSubscription);
this.mouseEvents = new ChartMouseEvents({
lwcChart: this.lwcChart,
container: this.container,
});
this.mouseEvents.subscribe('wheel', this.onWheel);
this.mouseEvents.subscribe('pointerDown', this.onPointerDown);
this.mouseEvents.subscribe('pointerMove', this.onPointerMove);
this.mouseEvents.subscribe('pointerUp', this.onPointerUp);
this.mouseEvents.subscribe('pointerCancel', this.onPointerUp);
this.DOM = new DOMModel({
modalRenderer,
});
this.paneManager = new PaneManager({
eventManager: this.eventManager,
panesSnapshot,
lwcChart: this.lwcChart,
dataSource,
DOM: this.DOM,
ohlcConfig,
subscribeChartEvent: this.subscribeChartEvent,
chartContainer: this.container,
tooltipConfig,
modalRenderer,
hotkeys: params.hotkeys,
});
this.mainSeries = this.paneManager.getMainPane().getMainSerie();
const { indicatorSnapshots, compareSnapshots } = splitIndicatorSnapshots(panesSnapshot);
this.indicatorManager = new IndicatorManager({
lwcChart: this.lwcChart,
eventManager,
dataSource: this.dataSource,
paneManager: this.paneManager,
initialIndicators: indicatorSnapshots,
DOM: this.DOM,
chartOptions: lwcChartConfig.chartOptions,
});
this.compareManager = new CompareManager({
chart: this.lwcChart,
eventManager,
dataSource: this.dataSource,
paneManager: this.paneManager,
initialIndicators: compareSnapshots,
indicatorManager: this.indicatorManager,
});
this.paneManager.start({
compareEntities$: this.compareManager.entities(),
indicatorEntities$: this.indicatorManager.entities(),
});
this.paneManager.setVisibleLogicalRange(this.lwcChart.timeScale().getVisibleLogicalRange());
this.paneManager.invalidate();
this.setupDataSourceSubs();
this.setupHistoricalDataLoading();
}
public getPriceScaleWidth(direction: Direction): number {
try {
const priceScale = this.lwcChart.priceScale(direction);
return priceScale ? priceScale.width() : 0;
} catch {
return 0;
}
}
public getDrawingsCollectionManager = (): DrawingsManagerCollection => {
return this.paneManager.getDrawingsCollectionManager();
};
public getIndicatorManager = (): IndicatorManager => {
return this.indicatorManager;
};
private onWheel = () => {
this.eventManager.resetInterval({
history: false,
});
};
private onPointerDown = () => {
this.isPointerDown = true;
this.didResetOnDrag = false;
};
private onPointerMove = () => {
if (!this.isPointerDown) return;
if (this.didResetOnDrag) return;
this.didResetOnDrag = true;
this.eventManager.resetInterval({
history: false,
});
};
private onPointerUp = () => {
this.isPointerDown = false;
};
public getDom(): DOMModel {
return this.DOM;
}
public getMainSeries(): Observable<SeriesStrategies | null> {
return this.mainSeries.asObservable();
}
public getCompareManager(): CompareManager {
return this.compareManager;
}
public updateTheme(theme: ThemeKey, mode: ThemeMode) {
this.chartConfig = {
...this.chartConfig,
theme,
mode,
};
this.lwcChart.applyOptions(getOptions(this.chartConfig));
this.paneManager.invalidate();
}
public destroy(): void {
this.subscriptions.unsubscribe();
this.mouseEvents.destroy();
this.compareManager.destroy();
this.paneManager.destroy();
this.lwcChart.remove();
}
public subscribeChartEvent: ChartMouseEvents['subscribe'] = (event, callback) =>
this.mouseEvents.subscribe(event, callback);
public unsubscribeChartEvent: ChartMouseEvents['unsubscribe'] = (event, callback) => {
this.mouseEvents.unsubscribe(event, callback);
};
// todo: add/move to undo/redo model(eventManager)
public scrollTimeScale = (direction: Direction) => {
this.eventManager.resetInterval({
history: false,
});
const diff = direction === Direction.Left ? -2 : 2;
const currentPosition = this.lwcChart.timeScale().scrollPosition();
this.lwcChart.timeScale().scrollToPosition(currentPosition + diff, false);
};
// todo: add/move to undo/redo model(eventManager)
public zoomTimeScale = (resize: Resize) => {
this.eventManager.resetInterval({
history: false,
});
const diff = resize === Resize.Shrink ? -1 : 1;
const currentRange = this.lwcChart.timeScale().getVisibleRange();
if (!currentRange) return;
const { from, to } = currentRange as IRange<number>;
if (!from || !to) return;
const next: IRange<Time> = {
from: (from + (to - from) * 0.1 * diff) as Time,
to: to as Time,
};
this.lwcChart.timeScale().setVisibleRange(next);
};
// todo: add to undo/redo model(eventManager)
public resetZoom = () => {
this.eventManager.resetInterval({
history: false,
});
this.lwcChart.timeScale().resetTimeScale();
this.paneManager.resetPriceScalesAutoScale();
};
public getRealtimeApi() {
return {
getTimeframe: () => this.eventManager.getTimeframe(),
getSymbols: () => this.activeSymbolIds,
update: (symbolId: string, candle: Candle) => {
this.dataSource.updateRealtime(symbolId, candle);
},
};
}
public getSnapshot(): ChartSnapshot {
const { seriesSelected, timeframe, dateFormat, timeFormat, interval, symbolInfo } =
this.eventManager.exportChartSettings();
return {
panes: this.paneManager.getSnapshot(),
chartSeriesType: seriesSelected,
timeframe,
dateFormat,
timeFormat,
interval,
...symbolInfo,
};
}
private scheduleHistoryBatch = () => {
if (this.historyBatchRunning) 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();
}
});
});
};
private setupDataSourceSubs(): void {
const getWarmupFrom = (): number => {
if (this.currentInterval && this.currentInterval !== Intervals.All) {
return getIntervalRange(this.currentInterval).from;
}
const range = this.lwcChart.timeScale().getVisibleRange();
if (!range) return 0;
const { from } = range as IRange<number>;
return from;
};
const warmupSymbolIds = (symbolIds: string[]): void => {
const from = getWarmupFrom();
if (!from) return;
Promise.all(symbolIds.map((symbolId) => this.dataSource.loadTill(symbolId, from))).catch((error) => {
console.error('[Chart] Ошибка при прогреве символов:', error);
});
};
const symbolIds$ = combineLatest([this.eventManager.symbolId(), this.compareManager.itemsObs()]).pipe(
map(([mainSymbolId, items]) => Array.from(new Set([mainSymbolId, ...items.map(({ symbolId }) => symbolId)]))),
);
this.subscriptions.add(
this.eventManager
.getInterval()
.pipe(withLatestFrom(symbolIds$))
.subscribe(([interval, symbolIds]) => {
this.currentInterval = interval;
if (!interval) return;
if (interval === Intervals.All) {
Promise.all(symbolIds.map((symbolId) => this.dataSource.loadAllHistory(symbolId)))
.then(() => {
const firstTimes = symbolIds
.map((symbolId) => this.dataSource.getOldestTime(symbolId))
.filter((time): time is number => time !== null);
const lastTimes = symbolIds
.map((symbolId) => this.dataSource.getLastCandle(symbolId)?.time)
.filter((time): time is number => time !== undefined);
if (firstTimes.length === 0 || lastTimes.length === 0) {
return;
}
requestAnimationFrame(() => {
this.lwcChart.timeScale().setVisibleRange({
from: Math.min(...firstTimes) as Time,
to: Math.max(...lastTimes) as Time,
});
});
})
.catch((error) => console.error('[Chart] Ошибка при загрузке всей истории:', error));
return;
}
const { from, to } = getIntervalRange(interval);
Promise.all(symbolIds.map((symbolId) => this.dataSource.loadTill(symbolId, from)))
.then(() => {
this.lwcChart.timeScale().setVisibleRange({
from: from as Time,
to: to as Time,
});
})
.catch((error) => {
console.error('[Chart] Ошибка при применении интервала:', error);
});
}),
);
this.subscriptions.add(
symbolIds$.subscribe((symbolIds) => {
const previousSymbolIds = new Set(this.activeSymbolIds);
this.activeSymbolIds = symbolIds;
this.dataSource.setSymbols(symbolIds);
const addedSymbolIds = symbolIds.filter((symbolId) => !previousSymbolIds.has(symbolId));
if (addedSymbolIds.length) {
warmupSymbolIds(addedSymbolIds);
}
}),
);
}
private setupHistoricalDataLoading(): void {
// todo (не)вызвать loadMoreHistory после проверки на необходимость дозагрузки после смены таймфрейма
this.mouseEvents.subscribe('visibleLogicalRangeChange', (logicalRange: LogicalRange | null) => {
this.paneManager.setVisibleLogicalRange(logicalRange);
if (!logicalRange) return;
if (this.currentInterval === Intervals.All) {
return;
}
const needsMoreData = logicalRange.from < HISTORY_LOAD_THRESHOLD;
if (!needsMoreData) return;
this.scheduleHistoryBatch();
});
}
}
function getIntervalRange(interval: Intervals): {
from: number;
to: number;
} {
const { value, unit } = intervalsToDayjs[interval] as DayjsOffset;
const from = Math.floor(dayjs().subtract(value, unit).valueOf() / 1000);
const to = Math.floor(dayjs().valueOf() / 1000);
return {
from,
to,
};
}
function getOptions(config: ChartConfig): DeepPartial<ChartOptions> {
const timeFormat = config.timeFormat ?? Defaults.timeFormat;
const showTime = config.showTime ?? Defaults.showTime;
const use12HourFormat = timeFormat === '12h';
const timeFormatString = use12HourFormat ? 'h:mm A' : 'HH:mm';
const { colors } = getThemeStore();
const localization: LocalizationOptionsBase = {
locale: getLocale(),
priceFormatter: (priceValue: BarPrice) => {
return formatCompactNumber(priceValue);
},
};
return {
width: config.container.clientWidth,
height: config.container.clientHeight,
autoSize: true,
defaultVisiblePriceScaleId: Direction.Right,
layout: {
background: {
color: colors.chartBackground,
},
textColor: colors.chartTextPrimary,
panes: {
separatorColor: colors.chartPaneSeparator,
separatorHoverColor: colors.chartPaneSeparator,
enableResize: true,
},
},
grid: {
vertLines: {
color: colors.chartGridLine,
},
horzLines: {
color: colors.chartGridLine,
},
},
crosshair: {
mode: CrosshairMode.Normal,
vertLine: {
color: colors.chartCrosshairLine,
labelBackgroundColor: colors.chartCrosshairLabel,
style: 0,
},
horzLine: {
color: colors.chartCrosshairLine,
labelBackgroundColor: colors.chartCrosshairLabel,
style: 2,
},
},
timeScale: {
timeVisible: showTime,
secondsVisible: false,
tickMarkFormatter: createTickMarkFormatter(timeFormatString),
borderVisible: false,
allowBoldLabels: false,
rightOffset: 10,
shiftVisibleRangeOnNewBar: true,
allowShiftVisibleRangeOnWhitespaceReplacement: true,
},
rightPriceScale: {
minimumWidth: 60,
borderVisible: false,
},
leftPriceScale: {
minimumWidth: 60,
borderVisible: false,
},
localization,
};
}
import { IChartApi, ISeriesApi, MouseEventParams, SeriesType } from 'lightweight-charts';
import { cloneDeep, isEqual } from 'lodash-es';
import { BehaviorSubject, distinctUntilChanged, map, Observable, Subscription } from 'rxjs';
import { EventManager } from '@core';
import { DOMModel } from '@core/DOMModel';
import { Drawing } from '@core/Drawings';
import { Hotkeys } 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, DOMObjectSnapshot } from '@src/types';
import { Pane } from './Pane';
import type { DrawingInteraction } from '@src/core/Drawings/SeriesDrawingBase';
import type { SettingsValues } from '@src/types/settings';
interface DrawingsManagerParams {
eventManager: EventManager;
mainSeries$: Observable<SeriesStrategies | null>;
lwcChart: IChartApi;
DOM: DOMModel;
container: HTMLElement;
modalRenderer: ModalRenderer;
paneId: number;
hotkeys: Hotkeys;
pane: Pane;
setActiveTool: (name: ActiveDrawingTool) => void;
getActiveTool: () => ActiveDrawingTool;
getIsEndlessMode: () => boolean;
continueDrawing: (name: DrawingsNames) => void;
}
export interface DrawingSnapshotItem extends Partial<DOMObjectSnapshot> {
id: string;
drawingName: DrawingsNames;
state: unknown;
isLocked?: boolean;
zIndex?: number;
}
interface CreateDrawingOptions {
id?: string;
state?: unknown;
isLocked?: boolean;
zIndex?: number;
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 paneId: number;
private hotkeys: Hotkeys;
private mainSeries: SeriesStrategies | null = null;
private subscriptions = new Subscription();
private drawings$ = new BehaviorSubject<Drawing[]>([]);
private selectedDrawing$ = new BehaviorSubject<Drawing | null>(null); // todo: переместить в DrawingsManagerCollection
private pendingSnapshot: DrawingsManagerSnapshot | null = null;
private selectedDrawingSnapshot: DrawingSnapshotItem | null = null;
private pane: Pane;
private copyPasteBuffer: DrawingSnapshotItem | null = null;
private setActiveTool: (name: ActiveDrawingTool) => void;
private getActiveTool: () => ActiveDrawingTool;
private getIsEndlessMode: () => boolean;
private continueDrawing: (name: DrawingsNames) => void;
constructor({
eventManager,
mainSeries$,
lwcChart,
DOM,
container,
modalRenderer,
paneId,
hotkeys,
pane,
setActiveTool,
getActiveTool,
getIsEndlessMode,
continueDrawing,
}: DrawingsManagerParams) {
this.DOM = DOM;
this.eventManager = eventManager;
this.paneId = paneId;
this.pane = pane;
this.lwcChart = lwcChart;
this.container = container;
this.modalRenderer = modalRenderer;
this.hotkeys = hotkeys;
this.setActiveTool = setActiveTool;
this.getActiveTool = getActiveTool;
this.getIsEndlessMode = getIsEndlessMode;
this.continueDrawing = continueDrawing;
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;
doAfterPromise(() => this.setSnapshot(snapshot), this.pane.isReady());
}
}),
);
window.addEventListener('pointerup', this.handlePointerUp);
window.addEventListener('pointercancel', this.handlePointerUp);
this.container.addEventListener('click', this.handleClick);
this.container.addEventListener('pointerdown', this.handlePointerDown);
this.container.addEventListener('dblclick', this.handleDoubleClick);
this.container.addEventListener('contextmenu', this.handleContextMenu);
// 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]
// // })
// }
// })
}
// TODO: handlePointerDown конфликтует с DrawingsManagerCollection.handlePaneClick
private handlePointerDown = (event: PointerEvent): void => {
if (!this.isEventInPane(event)) {
return;
}
this.selectedDrawingSnapshot = null;
if (event.button === 0) {
const drawings = this.drawings$.value;
const pendingDrawing = drawings.find((drawing) => drawing.isCreationPending());
if (!pendingDrawing && this.getActiveTool() !== 'crosshair') {
return;
}
const selectedDrawing = this.selectedDrawing$.value;
const drawing =
pendingDrawing ??
(selectedDrawing?.getSeriesDrawing().isHit(event) ? selectedDrawing : null) ??
this.findTopDrawing(event);
if (drawing && !drawing.isCreationPending()) {
this.selectedDrawingSnapshot = this.createDrawingSnapshot(drawing);
}
(drawing ?? selectedDrawing)?.getSeriesDrawing().pointerDown(event);
}
this.DOM.refreshEntities();
};
private handlePointerUp = (): void => {
const previousSnapshot = this.selectedDrawingSnapshot;
this.selectedDrawingSnapshot = null;
queueMicrotask(() => {
if (!previousSnapshot) {
return;
}
const drawing = this.findDrawing(previousSnapshot.id);
if (!drawing || drawing.isCreationPending()) {
return;
}
this.pushDrawingChange(previousSnapshot, this.createDrawingSnapshot(drawing));
});
this.DOM.refreshEntities();
};
private handleDoubleClick = (event: MouseEvent): void => {
if (!this.isEventInPane(event)) {
return;
}
const pendingDrawing = this.drawings$.value.find((drawing) => drawing.isCreationPending());
const drawing = pendingDrawing ?? this.findTopDrawing(event);
if (!drawing) {
return;
}
if (!drawing.isCreationPending() && drawing !== this.selectedDrawing$.value) {
this.selectedDrawing$.next(drawing);
}
drawing.getSeriesDrawing().doubleClick(event);
this.DOM.refreshEntities();
};
private handleContextMenu = (event: MouseEvent): void => {
if (!this.isEventInPane(event)) {
return;
}
const pendingDrawing = this.drawings$.value.find((drawing) => drawing.isCreationPending());
const drawing = pendingDrawing ?? this.findTopDrawing(event);
drawing?.getSeriesDrawing().contextMenu(event);
};
private handleClick = (event: MouseEvent): void => {
if (!this.isEventInPane(event)) {
return;
}
const pendingDrawing = this.drawings$.value.find((drawing) => drawing.isCreationPending());
pendingDrawing?.getSeriesDrawing().click(event);
this.DOM.refreshEntities();
};
private isEventInPane(event: MouseEvent): boolean {
const paneElement = this.pane.getHTMLElement();
return paneElement !== null && event.target instanceof Node && paneElement.contains(event.target);
}
private findTopDrawing(event: MouseEvent): Drawing | null {
let topDrawing: Drawing | null = null;
for (const drawing of this.drawings$.value) {
if (!drawing.getSeriesDrawing().isHit(event)) {
continue;
}
if (!topDrawing || drawing.zIndex > topDrawing.zIndex) {
topDrawing = drawing;
}
}
return topDrawing;
}
private findDrawing(id: string): Drawing | undefined {
return this.drawings$.value.find((drawing) => drawing.id === id);
}
private createDrawingSnapshot(drawing: Drawing): DrawingSnapshotItem {
return {
...drawing.getSnapshot(),
drawingName: drawing.getDrawingName(),
state: cloneDeep(drawing.getState()),
isLocked: drawing.isLocked(),
};
}
private updateDrawing(drawing: Drawing, update: () => void): void {
if (drawing.isCreationPending()) {
return;
}
const previousSnapshot = this.createDrawingSnapshot(drawing);
update();
this.pushDrawingChange(previousSnapshot, this.createDrawingSnapshot(drawing));
}
private pushDrawingChange(
previousSnapshot: DrawingSnapshotItem | null,
nextSnapshot: DrawingSnapshotItem | null,
): void {
if (isEqual(previousSnapshot, nextSnapshot)) {
return;
}
const previous = cloneDeep(previousSnapshot);
const next = cloneDeep(nextSnapshot);
// todo: объединять последовательные изменения одного дровинга в одну запись истории
this.eventManager.getUndoRedo().pushCommand({
undo: () => {
this.replaceDrawingSnapshot(next, previous);
},
redo: () => {
this.replaceDrawingSnapshot(previous, next);
},
});
}
private replaceDrawingSnapshot(
currentSnapshot: DrawingSnapshotItem | null,
nextSnapshot: DrawingSnapshotItem | null,
): void {
if (currentSnapshot && nextSnapshot && currentSnapshot.id === nextSnapshot.id) {
const drawing = this.findDrawing(nextSnapshot.id);
if (drawing) {
drawing.setState(cloneDeep(nextSnapshot.state));
drawing.setLocked(nextSnapshot.isLocked ?? false);
this.DOM.refreshEntities();
return;
}
}
if (currentSnapshot) {
this.removeDrawingInternal(currentSnapshot.id, false);
}
if (nextSnapshot) {
this.restoreDrawing(nextSnapshot);
}
this.setActiveTool('crosshair');
}
private restoreDrawing(snapshot: DrawingSnapshotItem): Drawing {
const existingDrawing = this.findDrawing(snapshot.id);
if (existingDrawing) {
existingDrawing.setState(cloneDeep(snapshot.state));
existingDrawing.setLocked(snapshot.isLocked ?? false);
if (snapshot.zIndex !== undefined) {
existingDrawing.setZIndex(snapshot.zIndex);
}
this.drawings$.next([...this.drawings$.value].sort((left, right) => left.zIndex - right.zIndex));
this.DOM.refreshEntities();
return existingDrawing;
}
return this.createDrawing({
name: snapshot.drawingName,
options: {
id: snapshot.id,
state: cloneDeep(snapshot.state),
isLocked: snapshot.isLocked,
zIndex: snapshot.zIndex,
},
});
}
private updateActiveTool(): void {
const hasPendingDrawing = this.drawings$.value.some((drawing) => drawing.isCreationPending());
if (hasPendingDrawing) {
return;
}
const activeTool = this.getActiveTool();
if (activeTool !== 'crosshair' && this.getIsEndlessMode()) {
this.continueDrawing(activeTool);
return;
}
this.setActiveTool('crosshair');
}
private removeDrawing = (id: string): void => {
const drawing = this.findDrawing(id);
if (!drawing) {
return;
}
if (drawing.isCreationPending()) {
this.removeDrawingInternal(id);
return;
}
const snapshot = this.createDrawingSnapshot(drawing);
this.removeDrawingInternal(id);
this.pushDrawingChange(snapshot, null);
};
private removeDrawingInternal(id: string, shouldUpdateTool = true): void {
const drawing = this.findDrawing(id);
if (!drawing) {
return;
}
this.removeDrawings([drawing], 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;
}
const selectedDrawing = this.selectedDrawing$.value;
if (selectedDrawing && drawingsToRemove.includes(selectedDrawing)) {
this.selectedDrawing$.next(null);
}
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 startDrawing = async (name: DrawingsNames, event?: MouseEventParams): Promise<void> => {
this.removePendingDrawings(false);
const previousDrawing = drawingsMap[name].singleInstance
? this.drawings$.value.find((drawing) => drawing.getDrawingName() === name)
: undefined;
const previousSnapshot = previousDrawing ? this.createDrawingSnapshot(previousDrawing) : null;
if (previousDrawing) {
this.removeDrawingInternal(previousDrawing.id, false);
}
if (this.selectedDrawing$.value) {
this.selectedDrawing$.next(null);
}
this.setActiveTool(name);
const drawing = this.createDrawing({
name,
event,
});
this.DOM.refreshEntities();
await drawing.waitForCreation();
if (!this.findDrawing(drawing.id)) {
if (previousSnapshot) {
this.restoreDrawing(previousSnapshot);
}
return;
}
this.pushDrawingChange(previousSnapshot, this.createDrawingSnapshot(drawing));
if (this.getActiveTool() === name) {
this.selectedDrawing$.next(drawing);
this.updateActiveTool();
}
this.DOM.refreshEntities();
};
private createDrawing({
name,
options = {},
event,
}: {
name: DrawingsNames;
options?: CreateDrawingOptions;
event?: MouseEventParams;
}): Drawing {
const { mainSeries } = this;
if (!mainSeries) {
throw new Error('[Drawings] main series is not defined');
}
const { id, state, isLocked = false, zIndex, shouldUpdateDrawingsList = true } = options;
const config = drawingsMap[name];
const drawingId = id ?? crypto.randomUUID();
let createdDrawing: Drawing | null = null;
const selected$ = this.selectedDrawing$.pipe(
map((drawing) => drawing?.id === drawingId),
distinctUntilChanged(),
);
const construct = (chart: IChartApi, series: ISeriesApi<SeriesType>, interaction: DrawingInteraction) => {
const paneElement = this.pane.getHTMLElement();
if (!paneElement) {
throw new Error('[Drawing Manager]: cannot place drawing, there is no pane');
}
const cells = paneElement.querySelectorAll<HTMLTableCellElement>(':scope > td');
const canvasElement = cells.item(1);
return config.construct({
chart,
series,
eventManager: this.eventManager,
container: canvasElement,
interaction,
removeSelf: () => this.removeDrawing(drawingId),
openSettings: () => {
if (createdDrawing) {
this.openSettings(createdDrawing);
}
},
initialEvent: event,
});
};
const drawingFactory = (entityZIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) =>
new Drawing({
lwcChart: this.lwcChart,
mainSeries,
id: drawingId,
drawingName: name,
name: drawingLabelById()[name],
onDelete: this.removeDrawing,
onCopy: () => {
if (createdDrawing) {
this.copyPasteBuffer = this.createDrawingSnapshot(createdDrawing);
}
},
zIndex: entityZIndex,
moveDown,
moveUp,
construct,
selected$,
isSelected: () => this.selectedDrawing$.value?.id === drawingId,
select: () => {
if (!createdDrawing || createdDrawing.isCreationPending() || this.selectedDrawing$.value === createdDrawing) {
return;
}
this.selectedDrawing$.next(createdDrawing);
},
deselect: () => {
if (!createdDrawing || this.selectedDrawing$.value !== createdDrawing) {
return;
}
this.selectedDrawing$.next(null);
},
isLocked,
paneId: this.paneId,
hotkeys: this.hotkeys,
});
const entity = this.DOM.setEntity<Drawing>(drawingFactory, zIndex);
createdDrawing = entity;
if (state !== undefined) {
entity.setState(cloneDeep(state));
}
if (shouldUpdateDrawingsList) {
this.drawings$.next([...this.drawings$.value, entity].sort((left, right) => left.zIndex - right.zIndex));
}
return entity;
}
public getSnapshot(): DrawingsManagerSnapshot {
return this.drawings$.value
.filter((drawing) => !drawing.isCreationPending())
.map((drawing) => this.createDrawingSnapshot(drawing));
}
public setSnapshot(snapshot: DrawingsManagerSnapshot): void {
if (!Array.isArray(snapshot)) {
return;
}
if (!this.mainSeries) {
this.pendingSnapshot = cloneDeep(snapshot);
return;
}
this.selectedDrawingSnapshot = null;
this.removeDrawings(this.drawings$.value, false);
const restoredDrawings = snapshot.reduce<Drawing[]>((drawings, item) => {
if (!drawingsMap[item.drawingName]) {
return drawings;
}
drawings.push(
this.createDrawing({
name: item.drawingName,
options: {
id: item.id,
state: cloneDeep(item.state),
isLocked: item.isLocked,
zIndex: item.zIndex,
shouldUpdateDrawingsList: false,
},
}),
);
return drawings;
}, []);
this.drawings$.next(restoredDrawings.sort((left, right) => left.zIndex - right.zIndex));
this.setActiveTool('crosshair');
this.DOM.refreshEntities();
}
public cancelPendingDrawing(): void {
this.removePendingDrawings(false);
this.DOM.refreshEntities();
}
public entities(): Observable<Drawing[]> {
return this.drawings$.asObservable();
}
public selectedDrawing(): Observable<Drawing | null> {
return this.selectedDrawing$.asObservable();
}
public updateSelectedDrawingSettings = (settings: SettingsValues): void => {
const drawing = this.selectedDrawing$.value;
if (!drawing) {
return;
}
this.updateDrawing(drawing, () => {
drawing.updateSettings(settings);
});
};
public openSelectedDrawingSettings(): void {
const drawing = this.selectedDrawing$.value;
if (!drawing) {
return;
}
this.openSettings(drawing);
}
public deleteSelectedDrawing(): void {
const drawing = this.selectedDrawing$.value;
if (!drawing) {
return;
}
this.removeDrawing(drawing.id);
}
public toggleSelectedDrawingLock(): void {
const drawing = this.selectedDrawing$.value;
if (!drawing) {
return;
}
this.updateDrawing(drawing, () => {
drawing.toggleLock();
});
}
private openSettings = (drawing: Drawing): void => {
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: () => {
if (!this.findDrawing(drawing.id)) {
return;
}
this.updateDrawing(drawing, () => {
drawing.updateSettings(settings);
});
},
},
);
};
public getDrawings(): Drawing[] {
return this.drawings$.value;
}
public hideAll(): void {
if (this.selectedDrawing$.value) {
this.selectedDrawing$.next(null);
}
this.drawings$.value.forEach((drawing) => drawing.hide());
this.DOM.refreshEntities();
}
public destroy(): void {
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointercancel', this.handlePointerUp);
this.container.removeEventListener('click', this.handleClick);
this.container.removeEventListener('pointerdown', this.handlePointerDown);
this.container.removeEventListener('dblclick', this.handleDoubleClick);
this.container.removeEventListener('contextmenu', this.handleContextMenu);
this.drawings$.value.forEach((drawing) => drawing.destroy());
this.selectedDrawingSnapshot = null;
this.copyPasteBuffer = null;
this.subscriptions.unsubscribe();
this.drawings$.complete();
this.selectedDrawing$.complete();
}
}
async function doAfterPromise(cb: () => void, waiter: Promise<void>) {
await waiter;
cb();
}
import { BehaviorSubject, Observable } from 'rxjs';
import { DrawingsNames } from '@src/constants';
import { DRAWING_KEYBOARD_SHORTCUTS, findDrawingPointerShortcut } from '@src/core/Drawings/shortcuts';
import { ActiveDrawingTool, SettingsValues } from '@src/types';
import { Drawing } from './Drawings';
import { DrawingsManager } from './DrawingsManager';
import { Hotkeys, Keys } from './Hotkeys';
import type { Pane } from './Pane';
import type { MouseEventParams } from 'lightweight-charts';
// todo: нужно дописывать класс)
export class DrawingsManagerCollection {
private managersMap: Map<number, DrawingsManager> = new Map();
private hotkeys: Hotkeys;
private activeTool$ = new BehaviorSubject<ActiveDrawingTool>('crosshair');
private endlessMode$ = new BehaviorSubject(false);
private isAwaitingDrawingStart = false;
private unregisterHotkeys: (() => void)[] = [];
constructor({ hotkeys }: { hotkeys: Hotkeys }) {
this.hotkeys = hotkeys;
for (const { keys, drawingName } of DRAWING_KEYBOARD_SHORTCUTS) {
this.unregisterHotkeys.push(
this.hotkeys.register({
keys,
callback: () => {
this.activateDrawingTool(drawingName);
},
}),
);
}
this.unregisterHotkeys.push(
this.hotkeys.register({
keys: [Keys.escape],
callback: this.activateCrosshair,
}),
);
}
public getIsEndlessMode(): boolean {
return this.endlessMode$.value;
}
public getActiveToolValue(): ActiveDrawingTool {
return this.activeTool$.value;
}
public setActiveTool(next: ActiveDrawingTool): void {
this.activeTool$.next(next);
if (next === 'crosshair') {
this.isAwaitingDrawingStart = false;
}
}
public removeDrawingManager(paneId: number): void {
this.managersMap.delete(paneId);
}
public addDrawingManager(manager: DrawingsManager, paneId: number): void {
this.managersMap.set(paneId, manager);
}
public activateDrawingTool = (name: DrawingsNames): void => {
this.cancelPendingDrawings();
this.setActiveTool(name);
this.isAwaitingDrawingStart = true;
};
public handlePaneClick(pane: Pane, event: MouseEventParams): void {
const activeTool = this.activeTool$.value;
if (activeTool === 'crosshair') {
const pointerShortcut = findDrawingPointerShortcut(event);
if (pointerShortcut) {
pane.getDrawingManager().startDrawing(pointerShortcut.drawingName, event);
}
return;
}
if (!this.isAwaitingDrawingStart) {
return;
}
this.isAwaitingDrawingStart = false;
pane.getDrawingManager().startDrawing(activeTool, event);
}
public setEndlessDrawingMode = (value: boolean): void => {
if (this.endlessMode$.value === value) {
return;
}
this.endlessMode$.next(value);
};
public isEndlessDrawingsMode(): Observable<boolean> {
return this.endlessMode$.asObservable();
}
public activateCrosshair = (): void => {
this.cancelPendingDrawings();
this.setActiveTool('crosshair');
};
public getActiveTool(): Observable<ActiveDrawingTool> {
return this.activeTool$.asObservable();
}
public selectedDrawing(): Observable<Drawing | null> {
return Array.from(this.managersMap.values())[0].selectedDrawing();
}
public updateSelectedDrawingSettings = (settings: SettingsValues): void => {
for (const manager of this.managersMap.values()) {
manager.updateSelectedDrawingSettings(settings);
}
};
public toggleSelectedDrawingLock = (): void => {
Array.from(this.managersMap.values())[0].toggleSelectedDrawingLock();
};
public openSelectedDrawingSettings = (): void => {
Array.from(this.managersMap.values())[0].openSelectedDrawingSettings();
};
public deleteSelectedDrawing = (): void => {
Array.from(this.managersMap.values())[0].deleteSelectedDrawing();
};
public destroy(): void {
for (const unregisterHotkey of this.unregisterHotkeys) {
unregisterHotkey();
}
this.unregisterHotkeys = [];
this.activeTool$.complete();
this.endlessMode$.complete();
}
private cancelPendingDrawings(): void {
for (const manager of this.managersMap.values()) {
manager.cancelPendingDrawing();
}
}
}
import { IChartApi, ISeriesApi, SeriesType } from 'lightweight-charts';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { DOMObject, DOMObjectParams } from '@core/DOMObject';
import { Hotkeys, Keys } from '@core/Hotkeys';
import { DrawingsNames } from '@src/constants';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { SettingsTab, SettingsValues, ToolbarSettingField } from '@src/types/settings';
import type { DrawingInteraction, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
interface DrawingParams extends DOMObjectParams {
drawingName: DrawingsNames;
lwcChart: IChartApi;
mainSeries: SeriesStrategies;
onDelete: (id: string) => void;
onCopy: () => void;
construct: (chart: IChartApi, series: ISeriesApi<SeriesType>, interaction: DrawingInteraction) => ISeriesDrawing;
selected$: Observable<boolean>;
isSelected: () => boolean;
select: () => void;
deselect: () => void;
isLocked?: boolean;
hotkeys: Hotkeys;
}
export class Drawing extends DOMObject {
private lwcDrawing: ISeriesDrawing;
private mainSeries: SeriesStrategies;
private drawingName: DrawingsNames;
private hotkeys: Hotkeys;
private lockedSubject: BehaviorSubject<boolean>;
private settingsSubject: BehaviorSubject<SettingsValues>;
private subscriptions = new Subscription();
private unregisterDeleteHotkey = () => {};
private unregisterCopyHotkey = () => {};
constructor({
lwcChart,
name,
mainSeries,
drawingName,
id,
onDelete,
onCopy,
zIndex,
moveUp,
moveDown,
construct,
selected$,
isSelected,
select,
deselect,
isLocked = false,
paneId,
hotkeys,
}: DrawingParams) {
super({
id,
name,
zIndex,
onDelete,
moveUp,
moveDown,
paneId,
});
this.hotkeys = hotkeys;
this.mainSeries = mainSeries;
this.drawingName = drawingName;
this.lockedSubject = new BehaviorSubject(isLocked);
const interaction: DrawingInteraction = {
selected$,
locked$: this.lockedSubject.asObservable(),
isSelected,
isLocked: () => this.lockedSubject.value,
select,
deselect,
};
this.lwcDrawing = construct(lwcChart, mainSeries, interaction);
this.settingsSubject = new BehaviorSubject(this.lwcDrawing.getSettings());
this.subscriptions.add(
this.lwcDrawing.subscribeSettings((settings) => {
this.settingsSubject.next(settings);
}),
);
this.subscriptions.add(
selected$.subscribe((isSelectedDrawing) => {
if (!isSelectedDrawing) {
this.unregisterSelectedDrawingHotkeys();
return;
}
this.unregisterDeleteHotkey = this.hotkeys.register({
keys: [Keys.delete],
callback: () => {
this.delete();
},
});
this.unregisterCopyHotkey = this.hotkeys.register({
keys: [Keys.mod, Keys.c],
callback: () => {
if (!this.isCreationPending()) {
onCopy();
}
},
});
}),
);
}
public getDrawingName(): DrawingsNames {
return this.drawingName;
}
public getSeriesDrawing(): ISeriesDrawing {
return this.lwcDrawing;
}
public show(): void {
this.lwcDrawing.show();
super.show();
}
public hide(): void {
this.lwcDrawing.hide();
super.hide();
}
public rebind = (nextMainSeries: SeriesStrategies): void => {
this.lwcDrawing.rebind(nextMainSeries);
this.mainSeries = nextMainSeries;
};
public isCreationPending(): boolean {
return this.lwcDrawing.isCreationPending();
}
public subscribeIsLocked(callback: (isLocked: boolean) => void): Subscription {
return this.lockedSubject.subscribe(callback);
}
public subscribeSettings(callback: (settings: SettingsValues) => void): Subscription {
return this.settingsSubject.subscribe(callback);
}
public isLocked(): boolean {
return this.lockedSubject.value;
}
public setLocked(isLocked: boolean): void {
if (this.lockedSubject.value === isLocked) {
return;
}
this.lockedSubject.next(isLocked);
}
public toggleLock(): void {
this.setLocked(!this.isLocked());
}
public waitForCreation(): Promise<void> {
return this.lwcDrawing.waitTillReady();
}
public shouldShowInObjectTree(): boolean {
return this.lwcDrawing.shouldShowInObjectTree();
}
public getState(): unknown {
return this.lwcDrawing.getState();
}
public setState(state: unknown): void {
this.lwcDrawing.setState(state);
this.settingsSubject.next(this.lwcDrawing.getSettings());
}
public getSettings(): SettingsValues {
return this.lwcDrawing.getSettings();
}
public updateSettings(settings: SettingsValues): void {
this.lwcDrawing.updateSettings(settings);
}
public getSettingsTabs(): SettingsTab[] {
return this.lwcDrawing.getSettingsTabs();
}
public getToolbarSettings(): ToolbarSettingField[] {
return this.getSettingsTabs()
.flatMap((tab) => tab.fields)
.filter((field): field is ToolbarSettingField => field.toolbar !== undefined);
}
public hasSettings(): boolean {
return this.getSettingsTabs().some((tab) => tab.fields.length > 0);
}
public destroy(): void {
this.subscriptions.unsubscribe();
this.unregisterSelectedDrawingHotkeys();
this.lockedSubject.complete();
this.settingsSubject.complete();
this.mainSeries.detachPrimitive(this.lwcDrawing);
this.lwcDrawing.destroy();
}
private unregisterSelectedDrawingHotkeys(): void {
this.unregisterDeleteHotkey();
this.unregisterCopyHotkey();
this.unregisterDeleteHotkey = () => {};
this.unregisterCopyHotkey = () => {};
}
}
import {
CHART_DRAWING_TOOLBAR_CONTAINER,
CHART_MODAL_CONTAINER_CLASSNAME,
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';
return {
legendContainer,
paneOverlayContainer,
};
}
}
import { IChartApi, PriceScaleMode, SeriesType } from 'lightweight-charts';
import { flatten } from 'lodash-es';
import { BehaviorSubject, distinctUntilChanged, map, Observable, of, Subscription } from 'rxjs';
import { DataSource } from '@core/DataSource';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { IndicatorManager } from '@core/IndicatorManager';
import { PaneManager } from '@core/PaneManager';
import { PriceScale } from '@core/PriceScale';
import { COMPARE_COLOR_PALETTE } from '@src/theme';
import { CompareItem, CompareMode, Direction, IndicatorConfig, SymbolInfo, SymbolInfoInput } from '@src/types';
import { CompareSnapshot } from '@src/types/snapshot';
import { createFallbackColor, normalizeColor, normalizeSymbol, normalizeSymbolInfo } from '@src/utils';
type MainScaleCompareMode = CompareMode.Absolute | CompareMode.Percentage;
interface CompareEntry extends CompareItem {
key: string;
entity: Indicator;
}
interface CompareManagerParams {
chart: IChartApi;
eventManager: EventManager;
dataSource: DataSource;
indicatorManager: IndicatorManager;
paneManager: PaneManager;
initialIndicators?: CompareSnapshot[];
}
export class CompareManager {
private readonly chart: IChartApi;
private readonly eventManager: EventManager;
private readonly dataSource: DataSource;
private readonly indicatorManager: IndicatorManager;
private readonly paneManager: PaneManager;
private readonly entries = new Map<string, CompareEntry>();
private readonly itemsSubject = new BehaviorSubject<CompareItem[]>([]);
private readonly entitiesSubject = new BehaviorSubject<Indicator[]>([]);
private readonly subscriptions = new Subscription();
private percentageComparisonActive = false;
private restoringInitialIndicators = false;
constructor({
chart,
eventManager,
dataSource,
indicatorManager,
paneManager,
initialIndicators = [],
}: CompareManagerParams) {
this.chart = chart;
this.eventManager = eventManager;
this.dataSource = dataSource;
this.indicatorManager = indicatorManager;
this.paneManager = paneManager;
this.subscriptions.add(
this.eventManager.timeframe().subscribe(() => {
this.applyPolicy();
}),
);
this.setup(initialIndicators);
}
public itemsObs(): Observable<CompareItem[]> {
return this.itemsSubject.asObservable();
}
public entities(): Observable<Indicator[]> {
return this.entitiesSubject.asObservable();
}
public clear(): void {
for (const key of this.entries.keys()) {
this.removeEntry(key);
}
this.commitEntriesChange();
}
public async setSymbolMode(
seriesType: SeriesType,
symbolInfoInput: SymbolInfoInput,
mode: CompareMode,
paneId?: number,
): Promise<void> {
const symbolInfo = normalizeSymbolInfo(symbolInfoInput);
if (!symbolInfo) {
return;
}
const { symbolId, symbol, symbolName } = symbolInfo;
if (mode === CompareMode.NewScale && this.isNewScaleDisabled() && !this.restoringInitialIndicators) {
return;
}
const isMainScaleMode = isMainScaleCompareMode(mode);
if (isMainScaleMode) {
for (const entry of this.entries.values()) {
if (isMainScaleCompareMode(entry.mode)) {
entry.mode = mode;
}
}
}
const key = makeKey(symbolId, mode);
if (this.entries.has(key)) {
if (isMainScaleMode) {
this.commitEntriesChange();
}
return;
}
const entity = this.indicatorManager.addEntity<Indicator>((zIndex, moveUp, moveDown) => {
const usedColorsByCompare = this.entitiesSubject.value.map(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(indicator) => indicator.getConfig().series?.[0]?.seriesOptions?.color,
);
const existingIndicators = Array.from(this.indicatorManager.getIndicators().value.values());
const usedColorsByIndicatorsRaw = existingIndicators.map((indicator) =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
indicator.config?.series?.map((series) => series.seriesOptions?.color),
);
const usedColorsByIndicators = flatten(usedColorsByIndicatorsRaw).filter((color) => color !== undefined);
const usedColors = usedColorsByCompare.concat(usedColorsByIndicators);
const config = getDefaultCompareIndicatorConfig(symbolInfo, usedColors);
const associatedPane =
mode === CompareMode.NewPane
? paneId !== undefined
? (this.paneManager.getPaneById(paneId) ?? this.paneManager.addPane())
: this.paneManager.addPane()
: this.paneManager.getMainPane();
return new Indicator({
id: key,
lwcChart: this.chart,
mainSymbolId$: of(symbolId),
mainSymbol$: of(symbol),
dataSource: this.dataSource,
associatedPane,
config: {
...config,
series: [
{
...config.series[0],
actLikeMainSerie: true,
seriesOptions: {
...config.series[0]?.seriesOptions,
...(mode === CompareMode.NewScale ? { priceScaleId: Direction.Left } : {}),
},
},
],
newPane: mode === CompareMode.NewPane,
},
zIndex,
onDelete: () => {
if (this.removeEntry(key)) {
this.commitEntriesChange();
}
},
moveUp,
moveDown,
paneId: associatedPane.getId(),
});
});
this.entries.set(key, {
key,
symbolId,
symbol,
symbolName,
mode,
entity,
});
this.commitEntriesChange();
await this.dataSource.waitUntilReady(symbolId);
}
public removeSymbolMode(symbolIdRaw: string, mode: CompareMode): void {
const symbolId = normalizeSymbol(symbolIdRaw);
if (!symbolId) {
return;
}
if (this.removeEntry(makeKey(symbolId, mode))) {
this.commitEntriesChange();
}
}
public removeSymbol(symbolIdRaw: string): void {
const symbolId = normalizeSymbol(symbolIdRaw);
if (!symbolId) {
return;
}
let removed = false;
for (const [key, entry] of this.entries) {
if (entry.symbolId !== symbolId) {
continue;
}
removed = this.removeEntry(key) || removed;
}
if (removed) {
this.commitEntriesChange();
}
}
public isNewScaleDisabled(): boolean {
return this.itemsSubject.value.length > 0;
}
public isNewScaleDisabledObservable(): Observable<boolean> {
return this.itemsSubject.pipe(
map((items) => items.length > 0),
distinctUntilChanged(),
);
}
public getAllEntities() {
return Array.from(this.entries.values()).map(({ symbolId, symbol, symbolName, entity, mode }) => ({
symbolId,
symbol,
symbolName,
entity,
mode,
}));
}
public destroy(): void {
this.subscriptions.unsubscribe();
this.clear();
this.itemsSubject.complete();
this.entitiesSubject.complete();
}
private setup = async (compareIndicators: CompareSnapshot[]): Promise<void> => {
this.restoringInitialIndicators = true;
const mainPane = this.paneManager.getMainPane();
const mainPaneId = mainPane.getId();
const mainScaleMode =
mainPane.getPriceScale(Direction.Right).getMode() === PriceScaleMode.Percentage
? CompareMode.Percentage
: CompareMode.Absolute;
try {
for (const compareIndicator of compareIndicators) {
const { scale, symbolInfo, seriesName, paneId } = compareIndicator;
const symbolInfoNormalized = normalizeSymbolInfo(symbolInfo);
if (!symbolInfoNormalized) {
continue;
}
if (mainPaneId !== paneId && scale === Direction.Left) {
throw new Error('[CompareManager]: несколько шкал на второстеменном пейне не поддерживаются');
}
const compareMode =
scale === Direction.Left ? CompareMode.NewScale : mainPaneId === paneId ? mainScaleMode : CompareMode.NewPane;
// eslint-disable-next-line no-await-in-loop
await this.setSymbolMode(seriesName, symbolInfoNormalized, compareMode, paneId!);
}
} finally {
this.restoringInitialIndicators = false;
}
};
private removeEntry(key: string): boolean {
const entry = this.entries.get(key);
if (!entry) {
return false;
}
this.entries.delete(key);
this.indicatorManager.removeEntity(entry.entity);
entry.entity.destroy();
return true;
}
private commitEntriesChange(): void {
this.applyPolicy();
this.publish();
}
private publish(): void {
const entries = Array.from(this.entries.values());
this.itemsSubject.next(
entries.map(({ symbolId, symbol, symbolName, mode }) => ({
symbolId,
symbol,
symbolName,
mode,
})),
);
this.entitiesSubject.next(entries.map(({ entity }) => entity));
}
private syncPercentageMode(priceScale: PriceScale, shouldEnablePercentageMode: boolean): void {
if (this.restoringInitialIndicators) {
this.percentageComparisonActive = shouldEnablePercentageMode;
return;
}
if (this.percentageComparisonActive === shouldEnablePercentageMode) {
return;
}
this.percentageComparisonActive = shouldEnablePercentageMode;
if (shouldEnablePercentageMode) {
priceScale.setMode(PriceScaleMode.Percentage);
return;
}
if (priceScale.getMode() === PriceScaleMode.Percentage) {
priceScale.setMode(PriceScaleMode.Normal);
}
}
private applyPolicy(): void {
let percentageComparisonActive = false;
let newScaleComparisonActive = false;
for (const { mode } of this.entries.values()) {
if (mode === CompareMode.Percentage) {
percentageComparisonActive = true;
}
if (mode === CompareMode.NewScale) {
newScaleComparisonActive = true;
}
}
this.paneManager.setPriceScaleSideVisible(Direction.Left, newScaleComparisonActive);
this.paneManager.setPriceScaleSideVisible(Direction.Right, true);
const mainRightPriceScale = this.paneManager.getMainPane().getPriceScale(Direction.Right);
this.syncPercentageMode(mainRightPriceScale, percentageComparisonActive);
this.paneManager.invalidate();
}
}
function isMainScaleCompareMode(mode: CompareMode): mode is MainScaleCompareMode {
return mode === CompareMode.Absolute || mode === CompareMode.Percentage;
}
function makeKey(symbolId: string, mode: CompareMode): string {
const keyMode = isMainScaleCompareMode(mode) ? 'MAIN' : mode;
return `${symbolId}|${keyMode}`;
}
function getPaletteColorFromIndex(usedColors: Set<string>, startIndex: number): string {
for (let offset = 0; offset < COMPARE_COLOR_PALETTE.length; offset += 1) {
const color = COMPARE_COLOR_PALETTE[(startIndex + offset) % COMPARE_COLOR_PALETTE.length];
if (!usedColors.has(normalizeColor(color))) {
return color;
}
}
return createFallbackColor(usedColors.size);
}
function getDefaultCompareIndicatorConfig(symbolInfo: SymbolInfo, usedColors: string[]): IndicatorConfig {
const reservedColors = new Set(usedColors.map(normalizeColor));
return {
symbolInfo,
newPane: true,
label: symbolInfo.symbolName,
series: [
{
name: 'Line', // todo: change with enum
id: `compare-${crypto.randomUUID()}`,
seriesOptions: {
visible: true,
color: getPaletteColorFromIndex(reservedColors, 0),
},
},
],
};
}
import { IChartApi } from 'lightweight-charts';
import { Observable, Subscription } from 'rxjs';
import { DataSource } from '@core/DataSource';
import { DOMObject, DOMObjectParams } from '@core/DOMObject';
import { Pane } from '@core/Pane';
import { indicatorLabelById, indicatorSeriesLabelById, IndicatorsIds } from '@src/constants';
import { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ChartTypeOptions, Direction, IndicatorConfig, SettingsValues } from '@src/types';
import { CompareSnapshot, DOMObjectSnapshot, IndicatorSnapshot, ISerializable } from '@src/types/snapshot';
type IIndicator = DOMObject;
export interface IndicatorParams extends DOMObjectParams {
mainSymbolId$: Observable<string>;
mainSymbol$: Observable<string>;
lwcChart: IChartApi;
dataSource: DataSource;
associatedPane: Pane;
config: IndicatorConfig;
settings?: SettingsValues;
type?: IndicatorsIds;
chartOptions?: ChartTypeOptions;
}
// todo: сделать отдельно представление для Compare, и попобовать убрать CompareManager из Chart
export class Indicator extends DOMObject implements ISerializable<IndicatorSnapshot | CompareSnapshot> {
private indicatorType?: IndicatorsIds;
private series: SeriesStrategies[] = [];
private seriesMap: Map<string, SeriesStrategies> = new Map();
private lwcChart: IChartApi;
private dataSource: DataSource;
private mainSymbolId$: Observable<string>;
private mainSymbol$: Observable<string>;
private associatedPane: Pane;
private config: IndicatorConfig;
private settings: SettingsValues = {};
private dataChangeHandlers = new Set<() => void>();
private seriesSubscriptions = new Subscription();
constructor({
id,
type,
lwcChart,
dataSource,
zIndex,
onDelete,
moveUp,
moveDown,
mainSymbolId$,
mainSymbol$,
associatedPane,
paneId,
config,
settings,
}: IndicatorParams) {
super({ id, name: config?.label ?? id, zIndex, onDelete, moveUp, moveDown, paneId });
this.lwcChart = lwcChart;
this.dataSource = dataSource;
this.mainSymbolId$ = mainSymbolId$;
this.mainSymbol$ = mainSymbol$;
this.indicatorType = type;
this.config = config;
this.name = this.getLabel();
this.settings = { ...this.getDefaultSettings(), ...settings };
this.associatedPane = associatedPane;
this.createSeries();
this.associatedPane.setIndicator(this.id, this);
}
public recreateSeries() {
this.destroySeries();
this.createSeries();
}
public subscribeDataChange(handler: () => void): Subscription {
this.dataChangeHandlers.add(handler);
return new Subscription(() => {
this.dataChangeHandlers.delete(handler);
});
}
public getLabel = () => {
if (this.config.label) {
return this.config.label;
}
if (this.indicatorType) {
return indicatorLabelById()[this.indicatorType];
}
return this.id;
};
public getSeriesLabel(serieName: string): string | undefined {
if (this.config.seriesLabels?.[serieName]) {
return this.config.seriesLabels[serieName];
}
if (this.indicatorType) {
return indicatorSeriesLabelById[this.indicatorType]?.[serieName];
}
return undefined;
}
public getId(): string {
return this.id;
}
public getType(): IndicatorsIds | undefined {
return this.indicatorType;
}
public getPane(): Pane {
return this.associatedPane;
}
public getSeriesMap(): Map<string, SeriesStrategies> {
return this.seriesMap;
}
public getConfig(): IndicatorConfig {
return this.config;
}
public getIndicatorType(): IndicatorSnapshot['indicatorType'] | undefined {
return this.indicatorType;
}
public getSettingsValues(): SettingsValues {
return { ...this.settings };
}
public getSettingsFields() {
return this.config.settings ?? [];
}
public hasSettings(): boolean {
return Boolean(this.config.settings?.length);
}
public updateSettings(settings: SettingsValues): void {
this.settings = settings;
// todo: обновлять данные серий без удаления и повторного создания
this.recreateSeries();
this.notifyDataChanged();
}
public show() {
this.series.forEach((s) => {
s.show();
});
super.show();
}
public hide() {
this.series.forEach((s) => {
s.hide();
});
super.hide();
}
public override getSnapshot(): DOMObjectSnapshot & (IndicatorSnapshot | CompareSnapshot) {
const domSnap = super.getSnapshot();
if (this.indicatorType) {
return {
...domSnap,
indicatorType: this.indicatorType,
settings: this.getSettingsValues(),
};
}
const seriesName = this.config.series[0]?.name;
if (!seriesName) {
throw new Error('[Indicator]: невозможно сохранить состояние compare индикатора');
}
const scale = (this.series[0]?.options().priceScaleId ??
this.lwcChart.options().defaultVisiblePriceScaleId) as Direction;
return {
...domSnap,
symbolInfo: this.config.symbolInfo!,
seriesName,
scale,
};
}
public setSnapshot(snap: IndicatorSnapshot): void {}
// destroy и delete принципиально отличаются!
// delete вызовет destroy в конце концов. По сути - это destroy с сайд-эффектом в eventManager
public delete() {
super.delete();
}
// destroy и delete принципиально отличаются!
public destroy() {
this.destroySeries();
this.dataChangeHandlers.clear();
this.associatedPane.removeIndicator(this.id);
}
private createSeries(): void {
this.config.series.forEach(
({ name, id: serieId, dataFormatter, seriesOptions, priceScaleOptions, actLikeMainSerie, onSerieInit }) => {
const serie = SeriesFactory.create(name!)({
lwcChart: this.lwcChart,
dataSource: this.dataSource,
customFormatter: dataFormatter
? (params) =>
dataFormatter({
...params,
settings: this.settings,
indicatorReference: this,
})
: undefined,
seriesOptions,
priceScaleOptions,
mainSymbolId$: this.mainSymbolId$,
mainSymbol$: this.mainSymbol$,
mainSerie$: this.associatedPane.getMainSerie(),
showSymbolLabel: false,
paneIndex: this.associatedPane.paneIndex(),
indicatorReference: this,
actLikeMainSerie,
});
onSerieInit?.(serie);
const handleDataChanged = () => {
this.notifyDataChanged();
};
serie.subscribeDataChanged(handleDataChanged);
this.seriesSubscriptions.add(() => {
serie.unsubscribeDataChanged(handleDataChanged);
});
this.seriesMap.set(serieId, serie);
this.series.push(serie);
},
);
if (!this.associatedPane.getLocalMainSeries()) {
const mainSeriesCandidates = this.series.filter((serie) => serie.actLikeMainSerie);
if (mainSeriesCandidates.length < 1) {
throw new Error('[Indicator]: there is no mainSerie in this indicator');
}
this.associatedPane.setLocalMainSeries(mainSeriesCandidates[0]);
}
}
private destroySeries(): void {
this.seriesSubscriptions.unsubscribe();
this.seriesSubscriptions = new Subscription();
this.series.forEach((s) => {
s.destroy();
});
this.series = [];
this.seriesMap.clear();
}
private notifyDataChanged(): void {
this.dataChangeHandlers.forEach((handler) => {
handler();
});
}
private getDefaultSettings(): SettingsValues {
const settings: SettingsValues = {};
this.config.settings?.forEach((field) => {
settings[field.key] = field.defaultValue;
});
return settings;
}
}
import { IChartApi } from 'lightweight-charts';
import { BehaviorSubject, 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 SeriesParams {
eventManager: EventManager;
dataSource: DataSource;
lwcChart: IChartApi;
paneManager: PaneManager;
DOM: DOMModel;
initialIndicators?: IndicatorSnapshot[];
chartOptions?: ChartTypeOptions;
}
export class IndicatorManager {
private eventManager: EventManager;
private lwcChart: IChartApi;
private chartOptions?: ChartTypeOptions;
private entities$: BehaviorSubject<Indicator[]> = new BehaviorSubject<Indicator[]>([]);
private indicatorsMap$: BehaviorSubject<Map<string, Indicator>> = new BehaviorSubject(new Map()); // todo: заменить IndicatorsIds ключ на уникальный id индикатора
private DOM: DOMModel;
private dataSource: DataSource;
private paneManager: PaneManager;
constructor({ eventManager, dataSource, lwcChart, DOM, chartOptions, initialIndicators, paneManager }: SeriesParams) {
this.eventManager = eventManager;
this.lwcChart = lwcChart;
this.chartOptions = chartOptions;
this.DOM = DOM;
this.dataSource = dataSource;
this.paneManager = paneManager;
this.indicatorsMap$ = new BehaviorSubject<Map<string, Indicator>>(new Map());
initialIndicators?.forEach((ind) => {
this.addIndicator(ind);
});
}
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: number, moveUp: (id: string) => void, moveDown: (id: string) => void) => {
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);
this.entities$.next(Array.from(indicatorsMap.values()));
}
public getIndicators() {
return this.indicatorsMap$;
}
public removeEntity(entity: DOMObject): void {
this.DOM.removeEntity(entity);
}
public deleteIndicator = (id: string) => {
const indicatorsMap = new Map(this.indicatorsMap$.value);
const entity = indicatorsMap.get(id);
if (!entity) {
return;
}
this.removeEntity(entity);
entity.destroy();
indicatorsMap.delete(id);
this.indicatorsMap$.next(indicatorsMap);
this.entities$.next(Array.from(indicatorsMap.values()));
};
public entities(): Observable<Indicator[]> {
return this.entities$.asObservable();
}
}
function getConfigByIndicatorType({
indicatorType,
existedIndicators,
}: {
indicatorType: IndicatorsIds;
existedIndicators: Map<string, Indicator>;
}): IndicatorConfig {
const configWithAppliesSettings = indicatorsConfigMap()[indicatorType] as IndicatorConfig;
const usedColors = getUsedIndicatorColorsByType({ indicatorType, existedIndicators });
return applyNextIndicatorColors(configWithAppliesSettings, usedColors);
}
function getUsedIndicatorColorsByType({
indicatorType,
existedIndicators,
}: {
indicatorType: IndicatorSnapshot['indicatorType'];
existedIndicators: Map<string, Indicator>;
}): string[] {
const colors: string[] = [];
existedIndicators.forEach((indicator) => {
if (indicator.getIndicatorType() !== indicatorType) {
return;
}
colors.push(...getIndicatorColors(indicator.getConfig()));
});
return colors;
}
import { MouseEventParams, Point, Time } from 'lightweight-charts';
import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
import { ChartMouseEvents } from '@core/ChartMouseEvents';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { IndicatorsIds } from '@src/constants';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { CompareMode, IndicatorLabel, OHLCConfig } from '@src/types';
export interface LegendParams {
config: OHLCConfig;
eventManager: EventManager;
indicators: BehaviorSubject<Map<string, Indicator>>;
subscribeChartEvent: ChartMouseEvents['subscribe'];
mainSeries: BehaviorSubject<SeriesStrategies | null> | null;
paneId: number;
paneIndex: () => number;
openIndicatorSettings: (id: IndicatorsIds, indicator: Indicator) => void;
}
export interface Ohlc {
time: Time;
open?: number;
high?: number;
low?: number;
close?: number;
value?: number;
absoluteChange?: number;
percentageChange?: number;
}
export interface CompareLegendItem {
symbolId: string;
symbol: string;
symbolName: string;
mode: CompareMode;
value: string;
color: string;
}
export interface LegendVM {
vm: LegendModel;
}
export type LegendModel = {
id: string;
name: IndicatorLabel | string;
isIndicator: boolean;
values: Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name?: string }>>;
remove?: () => void;
settings?: () => void;
}[];
export const ohlcValuesToShowForMainSerie = [
// 'time',
'open',
'high',
'low',
'close',
'value',
// 'absoluteChange',
// 'percentageChange',
];
export const ohlcValuesToShowForIndicators = [
// 'time',
'open',
// 'high',
// 'low',
// 'close',
'value',
// 'absoluteChange',
// 'percentageChange'
];
export class Legend {
private eventManager: EventManager;
private indicators: Map<string, Indicator> = new Map();
private config: OHLCConfig;
private mainSeries!: SeriesStrategies;
private mainSymbolName = '';
private isChartHovered = false;
private model$ = new BehaviorSubject<LegendModel>([]);
private tooltipVisability = new BehaviorSubject<boolean>(false);
private tooltipPos = new BehaviorSubject<null | Point>(null);
private paneId: number;
private paneIndex: () => number;
private openIndicatorSettings: (id: IndicatorsIds, indicator: Indicator) => void;
private subscriptions = new Subscription();
private indicatorSubscriptions = new Subscription();
private mainSeriesSubscription = new Subscription();
constructor({
config,
eventManager,
indicators,
subscribeChartEvent,
mainSeries,
paneId,
paneIndex,
openIndicatorSettings,
}: LegendParams) {
this.config = config;
this.eventManager = eventManager;
this.paneId = paneId;
this.paneIndex = paneIndex;
this.openIndicatorSettings = openIndicatorSettings;
this.subscriptions.add(
this.eventManager.symbolName().subscribe((symbolName) => {
this.mainSymbolName = symbolName;
this.updateWithLastCandle();
}),
);
if (!mainSeries) {
this.subscriptions.add(
indicators.subscribe((value: Map<string, Indicator>) => {
this.indicators = value;
this.handleIndicatorSeriesDataChange();
}),
);
} else {
this.subscriptions.add(
combineLatest([mainSeries, indicators]).subscribe(([mainSerie, inds]) => {
if (!mainSerie) {
return;
}
this.mainSeries = mainSerie;
this.indicators = inds;
this.handleMainSeriesChange();
this.handleIndicatorSeriesDataChange();
}),
);
}
this.subscriptions.add(subscribeChartEvent('crosshairMove', this.handleCrosshairMove));
}
public subscribeCursorPosition(cb: (point: Point | null) => void) {
this.subscriptions.add(this.tooltipPos.subscribe(cb));
}
public subscribeCursorVisability(cb: (isVisible: boolean) => void) {
this.subscriptions.add(this.tooltipVisability.subscribe(cb));
}
private handleIndicatorSeriesDataChange = () => {
this.indicatorSubscriptions.unsubscribe();
this.indicatorSubscriptions = new Subscription();
for (const [_, indicator] of this.indicators) {
this.indicatorSubscriptions.add(indicator.subscribeDataChange(this.updateWithLastCandle));
}
this.updateWithLastCandle();
};
private handleMainSeriesChange = () => {
this.mainSeriesSubscription.unsubscribe();
this.mainSeriesSubscription = new Subscription();
const handler = () => this.updateWithLastCandle();
this.mainSeries?.subscribeDataChanged(handler);
this.mainSeriesSubscription.add(() => this.mainSeries?.unsubscribeDataChanged(handler));
};
private updateWithLastCandle = () => {
if (this.isChartHovered) return;
const model: LegendModel = [];
if (this.mainSeries) {
const series = new Map();
const serieData = this.mainSeries.getLegendData();
Object.entries(serieData).forEach(([key, sd]) => {
series.set(`${key}`, {
...sd,
});
});
model.push({
id: `main-series-${this.paneId}`,
name: this.mainSymbolName,
values: series as Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>,
isIndicator: false,
});
}
if (!this.indicators) {
return;
}
for (const [_, indicator] of this.indicators) {
const indicatorType = indicator.getType();
const indicatorSeries = new Map();
for (const [serieName, serie] of indicator.getSeriesMap()) {
if (!serie.isVisible()) {
continue;
}
const serieData = serie.getLegendData();
const value = serieData.value ?? serieData.close;
if (!value) continue;
indicatorSeries.set(serieName, { ...value, name: indicator.getSeriesLabel(serieName) });
}
model.push({
id: indicator.getId(),
name: indicator.getLabel(),
values: indicatorSeries as Partial<
Record<keyof Ohlc, { value: number | string | Time; color: string; name?: string }>
>,
isIndicator: true,
remove: () => indicator.delete(),
settings:
indicatorType && indicator.hasSettings()
? () => this.openIndicatorSettings(indicatorType, indicator)
: undefined,
});
}
this.model$.next(model);
};
private handleCrosshairMove = (param: MouseEventParams) => {
// todo: есть одинаковый код с updateWithLastCandle
if (param.point === undefined || !param.time || param.point.x < 0 || param.point.y < 0) {
this.tooltipVisability.next(false);
this.isChartHovered = false;
this.updateWithLastCandle();
return;
}
if (param.seriesData.size === 0) {
this.updateWithLastCandle();
return;
}
if (this.paneIndex() === param.paneIndex) {
this.tooltipVisability.next(true);
} else {
this.tooltipVisability.next(false);
}
this.isChartHovered = true;
const model: LegendModel = [];
if (this.mainSeries) {
const series = new Map();
const serieData = this.mainSeries.getLegendData(param);
Object.entries(serieData).forEach(([key, sd]) => {
series.set(`${key}`, {
...sd,
});
});
model.push({
id: `main-series-${this.paneId}`,
name: this.mainSymbolName,
values: series as Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>,
isIndicator: false,
});
}
if (!this.indicators) {
return;
}
for (const [_, indicator] of this.indicators) {
const indicatorType = indicator.getType();
const indicatorSeries = new Map();
for (const [serieName, serie] of indicator.getSeriesMap()) {
if (!serie.isVisible()) {
continue;
}
const serieData = serie.getLegendData(param);
const value = serieData.value ?? serieData.close;
if (!value) continue;
indicatorSeries.set(serieName, { ...value, name: indicator.getSeriesLabel(serieName) });
}
model.push({
id: indicator.getId(),
name: indicator.getLabel(),
values: indicatorSeries as Partial<
Record<keyof Ohlc, { value: number | string | Time; color: string; name?: string }>
>,
isIndicator: true,
remove: () => indicator.delete(),
settings:
indicatorType && indicator.hasSettings()
? () => this.openIndicatorSettings(indicatorType, indicator)
: undefined,
});
}
this.model$.next(model);
this.tooltipPos.next(param.point);
};
public getConfig(): OHLCConfig {
return this.config;
}
public getLegendViewModel(): Observable<LegendModel> {
return this.model$;
}
public destroy = () => {
this.subscriptions.unsubscribe();
this.indicatorSubscriptions.unsubscribe();
this.mainSeriesSubscription.unsubscribe();
this.model$.complete();
this.tooltipVisability.complete();
this.tooltipPos.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 { 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 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,
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();
this.mainSeries.subscribe(() => {
this.rebindIndicators();
});
} else {
console.error('[Pane]: There is no any mainSerie for new pane');
}
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 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 | 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.legendContainer.remove();
this.paneOverlayContainer.remove();
this.indicatorsMap.complete();
this.mainSerieSub?.unsubscribe();
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 } = 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.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.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.rebindIndicators();
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 { type Observable, Subscription } from 'rxjs';
import { DataSource } from '@core/DataSource';
import { DrawingsManagerSnapshot } from '@core/DrawingsManager';
import { DrawingsManagerCollection } from '@core/DrawingsManagerCollection';
import { Pane, PaneParams } from '@core/Pane';
import { PriceAxisLabels } from '@core/PriceAxisLabels';
import { DrawingsNames } from '@src/constants';
import { ActiveDrawingTool, Direction } from '@src/types';
import { ISerializable, PaneSnapshot, PriceScaleSide, PriceScaleSnapshot } from '@src/types/snapshot';
import type { Indicator } from '@core/Indicator';
import type { IChartApi, LogicalRange, MouseEventParams } from 'lightweight-charts';
interface PaneManagerParams
extends Omit<
PaneParams,
'id' | 'isMainPane' | 'basedOn' | 'onDelete' | 'initialPriceScales' | CollectionDependencies
> {
panesSnapshot: PaneSnapshot[];
}
type CollectionDependencies =
| 'onPriceScaleStateChange'
| 'leftPriceScaleVisible'
| 'rightPriceScaleVisible'
| 'addDrawingManager'
| 'removeDrawingManager'
| 'setActiveTool'
| 'getActiveTool'
| 'getIsEndlessMode'
| 'continueDrawing';
interface PaneManagerStartParams {
compareEntities$: Observable<Indicator[]>;
indicatorEntities$: Observable<Indicator[]>;
}
type SharedPaneParams = Omit<PaneManagerParams, 'panesSnapshot'>;
// todo: PaneManager, регулирует порядок пейнов. Знает про MainPane.
// todo: Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
// todo: в CompareManage, при создании нового пейна для сравнения - инициализируем новый dataSource, принадлежащий только конкретному пейну. Убираем возможность добавлять индикаторы на такие пейны
// todo: на каждый символ свой DataSource (учитывать что есть MainPane и "главный" DataSource, который инициализиурется во время старта moexChart)
// todo: сделать два разных представления для compare, в зависимости от отображения на главном пейне или на второстепенном
export class PaneManager implements ISerializable<PaneSnapshot[]> {
private readonly sharedPaneParams: SharedPaneParams;
private readonly lwcChart: IChartApi;
private readonly panesMap = new Map<number, Pane>();
private mainPane: Pane;
private nextPaneId: number;
private priceAxisLabels: PriceAxisLabels | null = null;
private leftPriceScaleVisible = false;
private rightPriceScaleVisible = true;
private drawingsManagerCollection: DrawingsManagerCollection;
private readonly subscriptions = new Subscription();
constructor({ panesSnapshot, ...sharedPaneParams }: PaneManagerParams) {
this.sharedPaneParams = {
...sharedPaneParams,
};
this.lwcChart = sharedPaneParams.lwcChart;
const mainPaneSnapshot = panesSnapshot.find((paneSnapshot) => paneSnapshot.isMain);
const mainPaneId = mainPaneSnapshot?.id ?? 0;
this.drawingsManagerCollection = new DrawingsManagerCollection({
hotkeys: sharedPaneParams.hotkeys,
});
this.mainPane = new Pane({
...this.sharedPaneParams,
...this.getCollectionDependencies(),
id: mainPaneId,
isMainPane: true,
onDelete: () => {},
initialPriceScales: mainPaneSnapshot?.priceScales, // todo: add to sharedParams?
});
this.panesMap.set(mainPaneId, this.mainPane);
if (mainPaneSnapshot) {
this.mainPane.setDrawingsSnapshot(mainPaneSnapshot.drawings);
}
const greatestPaneId = panesSnapshot.reduce(
(greatestId, paneSnapshot) => Math.max(greatestId, paneSnapshot.id),
mainPaneId,
);
this.nextPaneId = greatestPaneId + 1;
panesSnapshot.forEach((paneSnapshot) => {
if (paneSnapshot.isMain) {
return;
}
const pane = this.addPane(undefined, paneSnapshot.id, paneSnapshot.priceScales);
pane.setDrawingsSnapshot(paneSnapshot.drawings);
});
this.initClickListener();
this.syncPaneContainers();
}
public start({ compareEntities$, indicatorEntities$ }: PaneManagerStartParams): void {
this.priceAxisLabels?.destroy();
this.priceAxisLabels = new PriceAxisLabels({
mainSeries$: this.mainPane.getMainSerie().asObservable(),
mainSymbol$: this.sharedPaneParams.eventManager.symbol(),
compareEntities$,
indicatorEntities$,
});
}
public getDrawingsCollectionManager(): DrawingsManagerCollection {
return this.drawingsManagerCollection;
}
public setVisibleLogicalRange(logicalRange: LogicalRange | null): void {
this.priceAxisLabels?.setVisibleLogicalRange(logicalRange);
}
public invalidate(): void {
this.priceAxisLabels?.invalidate();
this.refreshPriceScaleControls();
}
public setPriceScaleSideVisible(side: PriceScaleSide, visible: boolean): void {
if (side === Direction.Left) {
this.leftPriceScaleVisible = visible;
} else {
this.rightPriceScaleVisible = visible;
}
this.panesMap.forEach((pane) => {
pane.getPriceScale(side).setVisible(visible);
});
}
public getPaneByIndex(index: number): Pane | undefined {
for (const pane of this.panesMap.values()) {
if (pane.paneIndex() === index) {
return pane;
}
}
return undefined;
}
public getPaneById(id: number): Pane | undefined {
return this.panesMap.get(id);
}
public getDrawingsSnapshot(): DrawingsManagerSnapshot {
return this.mainPane.getDrawingsSnapshot();
}
public setDrawingsSnapshot(snapshot: DrawingsManagerSnapshot): void {
this.mainPane.setDrawingsSnapshot(snapshot);
}
public getPanes(): Map<number, Pane> {
return this.panesMap;
}
public getMainPane = (): Pane => {
return this.mainPane;
};
public addPane(dataSource?: DataSource, paneId?: number, initialPriceScales?: PriceScaleSnapshot[]): Pane {
const id = paneId ?? this.nextPaneId++;
this.nextPaneId = Math.max(this.nextPaneId, id + 1);
const pane = new Pane({
...this.sharedPaneParams,
...this.getCollectionDependencies(),
id,
isMainPane: false,
dataSource: dataSource ?? null,
basedOn: dataSource ? undefined : this.mainPane,
onDelete: () => this.destroyPane(id),
initialPriceScales,
});
this.panesMap.set(id, pane);
this.syncPaneContainers();
this.priceAxisLabels?.invalidate();
return pane;
}
public resetPriceScalesAutoScale(): void {
this.panesMap.forEach((pane) => {
pane.resetPriceScalesAutoScale();
});
}
public getSnapshot(): PaneSnapshot[] {
const snapshot: PaneSnapshot[] = [];
this.panesMap.forEach((pane) => {
snapshot.push(pane.getSnapshot());
});
return snapshot;
}
public destroy(): void {
this.priceAxisLabels?.destroy();
this.priceAxisLabels = null;
this.subscriptions.unsubscribe();
this.panesMap.forEach((pane) => {
pane.destroy();
});
this.drawingsManagerCollection.destroy();
this.panesMap.clear();
}
private initClickListener(): void {
const handler = (param: MouseEventParams) => {
if (param.paneIndex === undefined) {
return;
}
const clickedPane = this.getPaneByIndex(param.paneIndex);
if (!clickedPane) {
return;
}
this.drawingsManagerCollection.handlePaneClick(clickedPane, param);
};
this.lwcChart.subscribeClick(handler);
this.subscriptions.add(() => this.lwcChart.unsubscribeClick(handler));
}
private refreshPriceScaleControls(): void {
this.panesMap.forEach((pane) => {
pane.refreshPriceScaleControls();
});
}
private destroyPane(id: number): void {
const pane = this.panesMap.get(id);
if (!pane) {
return;
}
const paneIndex = pane.paneIndex();
this.panesMap.delete(id);
pane.destroy();
if (paneIndex >= 0) {
this.sharedPaneParams.lwcChart.removePane(paneIndex);
}
this.syncPaneContainers();
this.priceAxisLabels?.invalidate();
}
private syncPaneContainers(): void {
this.panesMap.forEach((pane) => {
pane.schedulePaneContainerSync();
});
}
private getCollectionDependencies(): Pick<PaneParams, CollectionDependencies> {
return {
onPriceScaleStateChange: this.handlePriceScaleStateChange,
leftPriceScaleVisible: this.leftPriceScaleVisible,
rightPriceScaleVisible: this.rightPriceScaleVisible,
addDrawingManager: (manager, paneId) => this.drawingsManagerCollection.addDrawingManager(manager, paneId),
removeDrawingManager: (paneId: number) => this.drawingsManagerCollection.removeDrawingManager(paneId),
setActiveTool: (name: ActiveDrawingTool) => this.drawingsManagerCollection.setActiveTool(name),
getActiveTool: () => this.drawingsManagerCollection.getActiveToolValue(),
getIsEndlessMode: () => this.drawingsManagerCollection.getIsEndlessMode(),
continueDrawing: (name: DrawingsNames) => this.drawingsManagerCollection.activateDrawingTool(name),
};
}
private handlePriceScaleStateChange = (): void => {
this.priceAxisLabels?.invalidate();
};
}
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;
}
/**
* Менеджер (настроек)*, которые меняются во время использование
* Отвечает за централизованное управление (настроек)
* * имеются в виду настройки, которые пользователь применяет к графику
*/
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);
if (!normalizedSymbolInfo) {
throw new Error('[EventManager] symbolId is required');
}
this.timeframe$ = new BehaviorSubject<Timeframes>(initialTimeframe);
this.seriesSelected$ = new BehaviorSubject<ChartSeriesType>(initialSeries);
this.symbolInfo$ = new BehaviorSubject<SymbolInfo>(normalizedSymbolInfo);
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 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) {
this.setSymbol(symbolInfo, 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 { BehaviorSubject, map, Observable } from 'rxjs';
import { DOM } from '@components/DOM';
import { IDOMObject } from '@core/DOMObject';
import { ModalRenderer } from '@core/ModalRenderer';
import { t } from '@src/translations';
interface DOMModelParams {
modalRenderer: ModalRenderer;
}
/**
* Абстракция над библиотекой для построения графиков
*/
export class DOMModel {
// ∈ symbol&pane
private modalRenderer: ModalRenderer;
private lastZIndex = 0;
// todo: заменить на мапу, где ключами будут id пейнов
private entities: BehaviorSubject<IDOMObject[]> = new BehaviorSubject<IDOMObject[]>([]); // drawings/indicators/series
// private entitiesMap: BehaviorSubject<Map<number, IDOMObject[]>> = new BehaviorSubject<Map<number, IDOMObject[]>>(new Map());
constructor({ modalRenderer }: DOMModelParams) {
this.modalRenderer = modalRenderer;
}
public removeEntity = <T extends IDOMObject>(entity: T): void => {
this.entities.next(this.entities.value.filter((item) => item.id !== entity.id));
};
public setEntity = <T extends IDOMObject>(
callback: (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) => T,
zIndex?: number,
): T => {
const entityZIndex = zIndex ?? this.lastZIndex;
const entity = callback(entityZIndex, this.moveUp, this.moveDown);
this.lastZIndex = Math.max(this.lastZIndex, entityZIndex + 1);
this.entities.next([...this.entities.value, entity].sort((left, right) => left.zIndex - right.zIndex));
return entity;
};
private moveUp = (id: string): void => {
this.moveEntity(id, 1);
};
private moveDown = (id: string): void => {
this.moveEntity(id, -1);
};
private moveEntity(id: string, direction: -1 | 1): void {
const entities = [...this.entities.value].sort((left, right) => left.zIndex - right.zIndex);
const currentIndex = entities.findIndex((entity) => entity.id === id);
if (currentIndex === -1) {
return;
}
const target = entities[currentIndex + direction];
if (!target) {
return;
}
const current = entities[currentIndex];
const currentZIndex = current.zIndex;
current.setZIndex(target.zIndex);
target.setZIndex(currentZIndex);
this.entities.next(entities.sort((left, right) => left.zIndex - right.zIndex));
}
public getEntitiesByPanes = (): Observable<[number, IDOMObject[]][]> => {
return this.entities.pipe(
map((entities) => {
const mapByPanes = new Map();
entities
.filter((entity) => entity.shouldShowInObjectTree())
.forEach((entity) => {
if (mapByPanes.has(entity.paneId)) {
mapByPanes.set(entity.paneId, [...mapByPanes.get(entity.paneId), entity]);
} else {
mapByPanes.set(entity.paneId, [entity]);
}
});
return Array.from(mapByPanes).sort(([a1, a2], [b1, b2]) => a1 - b1);
}),
);
};
public getEntities = (): Observable<IDOMObject[]> => {
return this.entities.pipe(map((entities) => entities.filter((entity) => entity.shouldShowInObjectTree())));
};
public refreshEntities = (): void => {
this.entities.next([...this.entities.value].sort((left, right) => left.zIndex - right.zIndex));
};
public toggleDOM = () => {
this.modalRenderer.renderComponent(<DOM elementsObs={this.getEntitiesByPanes()} />, {
title: t('DOM tree'),
onSave: () => console.warn('dom state saved'),
acceptLabel: '',
rejectLabel: '',
});
};
public destroy(): void {
// todo implement
}
}
import { BehaviorSubject } from 'rxjs';
import { DOMObjectSnapshot, ISerializable } from '@src/types/snapshot';
enum DOMObjectType {
Drawing = 'Drawing',
Indicator = 'Indicator',
}
export interface IDOMObject {
id: string;
hidden: BehaviorSubject<boolean>;
zIndex: number;
type: DOMObjectType;
name: string;
paneId: number;
delete(): void;
hide(): void;
show(): void;
lastUpdated(): void;
moveUp(): void;
moveDown(): void;
setZIndex(next: number): void;
shouldShowInObjectTree(): boolean;
}
export interface DOMObjectParams {
id: string;
paneId: number; // todo: implement for drawings
zIndex: number;
onDelete: (id: string) => void;
moveUp: (id: string) => void;
moveDown: (id: string) => void;
name?: string;
}
export class DOMObject implements IDOMObject, ISerializable<DOMObjectSnapshot> {
public readonly id: string;
public name: string;
public zIndex: number;
public hidden = new BehaviorSubject(false);
public moveUp: () => void;
public moveDown: () => void;
public paneId: number;
protected onDelete: (id: string) => void;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
type: DOMObjectType;
constructor({ id, name, zIndex, onDelete, moveUp, moveDown, paneId }: DOMObjectParams) {
this.id = id;
this.name = name ?? id;
this.zIndex = zIndex;
this.paneId = paneId;
this.onDelete = onDelete;
this.moveUp = () => moveUp(this.id);
this.moveDown = () => moveDown(this.id);
}
delete(): void {
this.onDelete(this.id);
}
hide(): void {
this.hidden.next(true);
}
show(): void {
this.hidden.next(false);
}
lastUpdated(): void {}
setZIndex(next: number): void {
this.zIndex = next;
}
shouldShowInObjectTree(): boolean {
return true;
}
public getSnapshot(): DOMObjectSnapshot {
return {
id: this.id,
name: this.name,
zIndex: this.zIndex,
hidden: this.hidden.value,
paneId: this.paneId,
};
}
}