Загрузка данных
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 350244472a449dcbda4a0f23aab70e8262db69eb..164add7ec5722b354f9c9f0148b2a74519e9dfd8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# latest
+- Добавлен empty state для индикаторов при отсутствии основного инструмента
+
+# 0.1.26
+
- Исправлены расчёты и начальный размер короткой и длинной позиций
# 0.1.25
diff --git a/jest.config.ts b/jest.config.ts
index a679886f216e6a9dd480a8040300cfde2b33628c..6098bca898cb6c4907c9779ebb3ec934a8989196 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -36,7 +36,7 @@ const config: Config.InitialOptions = {
'^lightweight-charts$': '<rootDir>/node_modules/lightweight-charts/dist/lightweight-charts.development.mjs',
'^exchange-elements/v2$': '<rootDir>/node_modules/exchange-elements/dist/v2.cjs.js',
'\\.module\\.(?:css|scss|sass)$': 'identity-obj-proxy',
- '\\.(?:css|scss|sass)$': '<rootDir>/__mocks__/styleMock.cjs',
+ '\\.(?:css|scss|sass)$': '<rootDir>/__mocks__/styleMock.js',
},
coverageDirectory: '<rootDir>/coverage',
coverageReporters: ['text', 'lcov'],
diff --git a/src/components/Footer/index.tsx b/src/components/Footer/index.tsx
index 1fb40c1338213bc812320ddb4c0df0478e0fd04c..47e0a3b6b9fe9d2c39d358f82b126d8662592026 100644
--- a/src/components/Footer/index.tsx
+++ b/src/components/Footer/index.tsx
@@ -5,14 +5,13 @@ import { Button, Tooltip } from 'exchange-elements/v2';
import { useEffect, useState } from 'react';
import { Observable } from 'rxjs';
+import { CHART_SCROLLABLE_CLASSNAME } from '@src/constants';
import { t } from '@src/translations';
import { Intervals, IntervalsToTimeframe, Timeframes } from '@src/types';
import { formatUtcOffset, useObservable } from '@src/utils';
-import coreStyles from '../../core/styles.module.scss';
-
import styles from './index.module.scss';
interface FooterProps {
@@ -35,7 +34,7 @@ export function Footer({ setInterval: setIntervalValue, intervalObs, supportedTi
return (
<footer className={styles.footer}>
- <div className={classNames(styles.intervals, coreStyles.scrollableBox)}>
+ <div className={classNames(styles.intervals, CHART_SCROLLABLE_CLASSNAME)}>
{(Object.keys(Intervals) as Intervals[])
.filter((interval: Intervals) => {
if (!IntervalsToTimeframe[interval]) {
diff --git a/src/constants/dom.ts b/src/constants/dom.ts
index 4faddbff9f9c6ec04272dca67f5c0650631b7799..440bce1a8e6732f81dffc1a2171ed1459cac21b0 100644
--- a/src/constants/dom.ts
+++ b/src/constants/dom.ts
@@ -4,6 +4,7 @@ export const CHART_PORTAL_HOST_CLASSNAME = 'moex-chart-portal-host';
export const CHART_MODAL_CONTAINER_CLASSNAME = 'moex-chart-modal-container';
export const CHART_PANE_OVERLAY_CONTAINER = 'moex-chart-pane-overlay-container';
+export const CHART_PANE_EMPTY_STATE_CONTAINER = 'moex-chart-pane-empty-state-container';
export const CHART_DRAWING_TOOLBAR_CONTAINER = 'moex-chart-drawing-toolbar-container';
export const CHART_DRAWING_TOOLTIP = 'moex-chart-drawing-tooltip';
@@ -11,3 +12,6 @@ export const CHART_DRAWING_TOOLTIP = 'moex-chart-drawing-tooltip';
export const CHART_PRICE_SCALE_CONTROLS = 'moex-price-scale-controls';
export const CHART_PRICE_SCALE_CONTROLS_CONTENT = `${CHART_PRICE_SCALE_CONTROLS}-content`;
export const CHART_PRICE_SCALE_CONTROLS_VISIBLE = `${CHART_PRICE_SCALE_CONTROLS}_visible`;
+
+export const CHART_FULLSCREEN_SAFARI_CLASSNAME = 'moex-chart-fullscreen-safari';
+export const CHART_SCROLLABLE_CLASSNAME = 'moex-chart-scrollable';
diff --git a/src/core/Chart.ts b/src/core/Chart.ts
index 9f9fab81bdd77a6ca66af5497f0170c5e42b723d..c614d810d1569e3d7b7ebb5db924a7bdd5aa0723 100644
--- a/src/core/Chart.ts
+++ b/src/core/Chart.ts
@@ -382,7 +382,9 @@ export class Chart implements ISerializable<ChartSnapshot> {
}
private scheduleHistoryBatch = () => {
- if (this.historyBatchRunning) return;
+ if (this.historyBatchRunning || this.activeSymbolIds.length === 0) {
+ return;
+ }
this.historyBatchRunning = true;
@@ -427,7 +429,11 @@ export class Chart implements ISerializable<ChartSnapshot> {
};
const symbolIds$ = combineLatest([this.eventManager.symbolId(), this.compareManager.itemsObs()]).pipe(
- map(([mainSymbolId, items]) => Array.from(new Set([mainSymbolId, ...items.map(({ symbolId }) => symbolId)]))),
+ map(([mainSymbolId, items]) =>
+ Array.from(
+ new Set([mainSymbolId, ...items.map(({ symbolId }) => symbolId)].filter((symbolId) => symbolId !== '')),
+ ),
+ ),
);
this.subscriptions.add(
@@ -437,7 +443,9 @@ export class Chart implements ISerializable<ChartSnapshot> {
.subscribe(([interval, symbolIds]) => {
this.currentInterval = interval;
- if (!interval) return;
+ if (!interval || symbolIds.length === 0) {
+ return;
+ }
if (interval === Intervals.All) {
Promise.all(symbolIds.map((symbolId) => this.dataSource.loadAllHistory(symbolId)))
diff --git a/src/core/CompareManager.ts b/src/core/CompareManager.ts
index e06d6226cc5654ba89b6fb81ddde3333e551942b..761eb13dd7e09a3c832defb93b13e80c60047d42 100644
--- a/src/core/CompareManager.ts
+++ b/src/core/CompareManager.ts
@@ -1,6 +1,4 @@
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';
@@ -12,7 +10,13 @@ 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';
+import {
+ createFallbackColor,
+ getIndicatorColors,
+ normalizeColor,
+ normalizeSymbol,
+ normalizeSymbolInfo,
+} from '@src/utils';
type MainScaleCompareMode = CompareMode.Absolute | CompareMode.Percentage;
@@ -40,7 +44,6 @@ export class CompareManager {
private readonly itemsSubject = new BehaviorSubject<CompareItem[]>([]);
private readonly entitiesSubject = new BehaviorSubject<Indicator[]>([]);
private readonly subscriptions = new Subscription();
-
private percentageComparisonActive = false;
private restoringInitialIndicators = false;
@@ -122,23 +125,12 @@ export class CompareManager {
}
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 usedColors = [...this.entitiesSubject.value, ...existingIndicators].flatMap((indicator) =>
+ getIndicatorColors(indicator.getConfig()),
);
- const usedColorsByIndicators = flatten(usedColorsByIndicatorsRaw).filter((color) => color !== undefined);
- const usedColors = usedColorsByCompare.concat(usedColorsByIndicators);
-
const config = getDefaultCompareIndicatorConfig(symbolInfo, usedColors);
const associatedPane =
@@ -270,7 +262,6 @@ export class CompareManager {
try {
for (const compareIndicator of compareIndicators) {
const { scale, symbolInfo, seriesName, paneId } = compareIndicator;
-
const symbolInfoNormalized = normalizeSymbolInfo(symbolInfo);
if (!symbolInfoNormalized) {
@@ -285,7 +276,7 @@ export class CompareManager {
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!);
+ await this.setSymbolMode(seriesName, symbolInfoNormalized, compareMode, paneId);
}
} finally {
this.restoringInitialIndicators = false;
@@ -368,7 +359,6 @@ export class CompareManager {
const mainRightPriceScale = this.paneManager.getMainPane().getPriceScale(Direction.Right);
this.syncPercentageMode(mainRightPriceScale, percentageComparisonActive);
-
this.paneManager.invalidate();
}
}
diff --git a/src/core/ContainerManager.ts b/src/core/ContainerManager.ts
index 79caa2e4193f11383dd338070449a0e2602da452..4b42ac0ef55869ed9c67435b69abc4b56030694c 100644
--- a/src/core/ContainerManager.ts
+++ b/src/core/ContainerManager.ts
@@ -1,12 +1,12 @@
import {
CHART_DRAWING_TOOLBAR_CONTAINER,
CHART_MODAL_CONTAINER_CLASSNAME,
+ CHART_PANE_EMPTY_STATE_CONTAINER,
CHART_PANE_OVERLAY_CONTAINER,
CHART_ROOT_CLASSNAME,
+ CHART_SCROLLABLE_CLASSNAME,
} from '@src/constants';
-import styles from './styles.module.scss';
-
interface CreateContainersOptions {
parentContainer: HTMLElement;
showBottomPanel?: boolean;
@@ -67,10 +67,10 @@ export class ContainerManager {
: `${headerHeight}px minmax(0, 1fr)`;
const headerContainer = document.createElement('div');
+ headerContainer.classList.add(CHART_SCROLLABLE_CLASSNAME);
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%';
@@ -152,11 +152,9 @@ export class ContainerManager {
return {
headerContainer,
footerContainer,
-
chartContainer,
chartAreaContainer,
toolBarContainer,
-
modalContainer,
controlBarContainer,
drawingToolbarContainer,
@@ -187,9 +185,14 @@ export class ContainerManager {
paneOverlayContainer.style.zIndex = ZIndex.Base;
paneOverlayContainer.style.pointerEvents = 'none';
+ const paneEmptyStateContainer = document.createElement('div');
+ paneEmptyStateContainer.classList.add(CHART_PANE_EMPTY_STATE_CONTAINER);
+ paneEmptyStateContainer.hidden = true;
+
return {
legendContainer,
paneOverlayContainer,
+ paneEmptyStateContainer,
};
}
}
diff --git a/src/core/EventManager.ts b/src/core/EventManager.ts
index 53a9f14bc001b82d515c8edce38be84b42d32e40..0271d545b73e9282d5a075162dafa04528929905 100644
--- a/src/core/EventManager.ts
+++ b/src/core/EventManager.ts
@@ -1,7 +1,6 @@
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';
@@ -22,10 +21,15 @@ interface SetWithHistoryOptions {
history?: boolean;
}
+const EMPTY_SYMBOL_INFO: SymbolInfo = {
+ symbolId: '',
+ symbol: '',
+ symbolName: '',
+};
+
/**
- * Менеджер (настроек)*, которые меняются во время использование
- * Отвечает за централизованное управление (настроек)
- * * имеются в виду настройки, которые пользователь применяет к графику
+ * Менеджер настроек, которые меняются во время использования.
+ * Отвечает за централизованное управление настройками графика.
*/
export class EventManager {
private timeframe$: BehaviorSubject<Timeframes>;
@@ -34,9 +38,7 @@ export class EventManager {
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({
@@ -49,13 +51,9 @@ export class EventManager {
}: 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.symbolInfo$ = new BehaviorSubject<SymbolInfo>(normalizedSymbolInfo ?? EMPTY_SYMBOL_INFO);
this.timeFormat$ = new BehaviorSubject<TimeFormat>(initialTimeFormat ?? Defaults.timeFormat);
this.dateFormat$ = new BehaviorSubject<DateFormat>(initialDateFormat ?? Defaults.dateFormat);
this.interval$ = new BehaviorSubject<Intervals | null>(initialInterval);
@@ -135,6 +133,21 @@ export class EventManager {
this.setWithHistory('symbolInfo', this.symbolInfo$, nextSymbolInfo, options);
};
+ public clearSymbol = (options?: SetWithHistoryOptions): void => {
+ if (!this.symbolInfo$.value.symbolId) {
+ return;
+ }
+
+ this.setWithHistory('symbolInfo', this.symbolInfo$, EMPTY_SYMBOL_INFO, options);
+ };
+
+ public hasSymbol(): Observable<boolean> {
+ return this.symbolInfo$.pipe(
+ map(({ symbolId }) => Boolean(symbolId)),
+ distinctUntilChanged(),
+ );
+ }
+
public symbolId(): Observable<string> {
return this.symbolInfo$.pipe(
map(({ symbolId }) => symbolId),
@@ -223,29 +236,38 @@ export class EventManager {
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 (symbolInfo.symbolId) {
+ this.setSymbol(symbolInfo, setOptions);
+ } else {
+ this.clearSymbol(setOptions);
+ }
}
+
if (seriesSelected) {
this.setSeriesSelected(seriesSelected, setOptions);
}
+
if (timeFormat) {
this.setTimeFormat(timeFormat, setOptions);
}
+
if (dateFormat) {
this.setDateFormat(dateFormat, setOptions);
}
+
if (interval != null) {
this.setInterval(interval, setOptions);
return;
}
+
if (timeframe) {
this.setTimeframe(timeframe, setOptions);
return;
}
+
if (interval === null) {
this.resetInterval(setOptions);
}
diff --git a/src/core/Fullscreen.ts b/src/core/Fullscreen.ts
index 354a4f954475a05abdf8551de00fa9825a77c703..67dc8adca1c9ac6477d3a43ec3df45dc102dbbd7 100644
--- a/src/core/Fullscreen.ts
+++ b/src/core/Fullscreen.ts
@@ -1,4 +1,4 @@
-import styles from './styles.module.scss';
+import { CHART_FULLSCREEN_SAFARI_CLASSNAME } from '@src/constants';
export class FullscreenController {
private isFull = false;
@@ -33,7 +33,7 @@ export class FullscreenController {
if (this.isFullscreen) return;
if (this.isSafari()) {
- this.element.classList.add(styles.safariFullscreen);
+ this.element.classList.add(CHART_FULLSCREEN_SAFARI_CLASSNAME);
} else {
await this.element.requestFullscreen();
}
@@ -45,7 +45,7 @@ export class FullscreenController {
if (!this.isFullscreen) return;
if (this.isSafari()) {
- this.element.classList.remove(styles.safariFullscreen);
+ this.element.classList.remove(CHART_FULLSCREEN_SAFARI_CLASSNAME);
} else {
await document.exitFullscreen();
}
diff --git a/src/core/IndicatorManager.ts b/src/core/IndicatorManager.ts
index ee4b55953d180a47d20a31188cc5bf91aa742d98..2b44eabafcd0a1846631a45fbff9b46f2c22df72 100644
--- a/src/core/IndicatorManager.ts
+++ b/src/core/IndicatorManager.ts
@@ -1,5 +1,5 @@
import { IChartApi } from 'lightweight-charts';
-import { BehaviorSubject, Observable } from 'rxjs';
+import { BehaviorSubject, map, Observable } from 'rxjs';
import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
@@ -13,7 +13,7 @@ import { ChartTypeOptions, IndicatorConfig } from '@src/types';
import { IndicatorSnapshot } from '@src/types/snapshot';
import { applyNextIndicatorColors, getIndicatorColors } from '@src/utils';
-interface SeriesParams {
+interface IndicatorManagerParams {
eventManager: EventManager;
dataSource: DataSource;
lwcChart: IChartApi;
@@ -24,17 +24,23 @@ interface SeriesParams {
}
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) {
+ private readonly eventManager: EventManager;
+ private readonly lwcChart: IChartApi;
+ private readonly chartOptions?: ChartTypeOptions;
+ private readonly indicatorsMap$ = new BehaviorSubject<Map<string, Indicator>>(new Map());
+ private readonly DOM: DOMModel;
+ private readonly dataSource: DataSource;
+ private readonly paneManager: PaneManager;
+
+ constructor({
+ eventManager,
+ dataSource,
+ lwcChart,
+ DOM,
+ chartOptions,
+ initialIndicators,
+ paneManager,
+ }: IndicatorManagerParams) {
this.eventManager = eventManager;
this.lwcChart = lwcChart;
this.chartOptions = chartOptions;
@@ -42,10 +48,8 @@ export class IndicatorManager {
this.dataSource = dataSource;
this.paneManager = paneManager;
- this.indicatorsMap$ = new BehaviorSubject<Map<string, Indicator>>(new Map());
-
- initialIndicators?.forEach((ind) => {
- this.addIndicator(ind);
+ initialIndicators?.forEach((indicator) => {
+ this.addIndicator(indicator);
});
}
@@ -60,10 +64,9 @@ export class IndicatorManager {
console.error('[IndicatorManager] Не был получен тип индиктора');
return;
}
- const indicatorsMap = new Map(this.indicatorsMap$.value);
+ 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,
@@ -72,39 +75,35 @@ export class IndicatorManager {
const associatedPane =
snap.paneId !== undefined
? (this.paneManager.getPaneById(snap.paneId) ?? this.paneManager.addPane())
- : config?.newPane
+ : 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,
- });
- },
- );
+ const indicatorToSet = this.addEntity<Indicator>((zIndex, moveUp, moveDown) => {
+ return new Indicator({
+ id,
+ paneId: associatedPane.getId(),
+ zIndex,
+ onDelete: this.deleteIndicator,
+ moveUp,
+ moveDown,
+ mainSymbolId$: this.eventManager.symbolId(),
+ mainSymbol$: this.eventManager.symbol(),
+ lwcChart: this.lwcChart,
+ dataSource: this.dataSource,
+ associatedPane,
+ config,
+ settings: snap.settings,
+ type: snap.indicatorType,
+ chartOptions: this.chartOptions,
+ });
+ });
indicatorsMap.set(id, indicatorToSet);
-
this.indicatorsMap$.next(indicatorsMap);
- this.entities$.next(Array.from(indicatorsMap.values()));
}
- public getIndicators() {
+ public getIndicators(): BehaviorSubject<Map<string, Indicator>> {
return this.indicatorsMap$;
}
@@ -112,24 +111,24 @@ export class IndicatorManager {
this.DOM.removeEntity(entity);
}
- public deleteIndicator = (id: string) => {
- const indicatorsMap = new Map(this.indicatorsMap$.value);
- const entity = indicatorsMap.get(id);
+ public deleteIndicator = (id: string): void => {
+ const indicator = this.indicatorsMap$.value.get(id);
- if (!entity) {
+ if (!indicator) {
return;
}
- this.removeEntity(entity);
- entity.destroy();
- indicatorsMap.delete(id);
+ const indicators = new Map(this.indicatorsMap$.value);
+ indicators.delete(id);
- this.indicatorsMap$.next(indicatorsMap);
- this.entities$.next(Array.from(indicatorsMap.values()));
+ this.indicatorsMap$.next(indicators);
+
+ this.removeEntity(indicator);
+ indicator.destroy();
};
public entities(): Observable<Indicator[]> {
- return this.entities$.asObservable();
+ return this.indicatorsMap$.pipe(map((indicators) => Array.from(indicators.values())));
}
}
@@ -138,12 +137,15 @@ function getConfigByIndicatorType({
existedIndicators,
}: {
indicatorType: IndicatorsIds;
- existedIndicators: Map<string, Indicator>;
+ existedIndicators: ReadonlyMap<string, Indicator>;
}): IndicatorConfig {
- const configWithAppliesSettings = indicatorsConfigMap()[indicatorType] as IndicatorConfig;
- const usedColors = getUsedIndicatorColorsByType({ indicatorType, existedIndicators });
+ const configWithAppliedSettings = indicatorsConfigMap()[indicatorType] as IndicatorConfig;
+ const usedColors = getUsedIndicatorColorsByType({
+ indicatorType,
+ existedIndicators,
+ });
- return applyNextIndicatorColors(configWithAppliesSettings, usedColors);
+ return applyNextIndicatorColors(configWithAppliedSettings, usedColors);
}
function getUsedIndicatorColorsByType({
@@ -151,7 +153,7 @@ function getUsedIndicatorColorsByType({
existedIndicators,
}: {
indicatorType: IndicatorSnapshot['indicatorType'];
- existedIndicators: Map<string, Indicator>;
+ existedIndicators: ReadonlyMap<string, Indicator>;
}): string[] {
const colors: string[] = [];
diff --git a/src/core/MoexChart.tsx b/src/core/MoexChart.tsx
index 030740b1a3d036fb0cce6aa272cbeefd4b99af61..42eabbd1c8a5d1512b4d6ddb9ce9df491e2fab57 100644
--- a/src/core/MoexChart.tsx
+++ b/src/core/MoexChart.tsx
@@ -139,7 +139,7 @@ export class MoexChart implements ISerializable<MoexChartSnapshot> {
initialTimeframe: timeframe,
initialSeries: chartSeriesType,
initialSymbolInfo: {
- symbolId,
+ symbolId: symbolId ?? '',
symbol,
symbolName,
},
@@ -304,6 +304,10 @@ export class MoexChart implements ISerializable<MoexChartSnapshot> {
this.eventManager.setSymbol(symbolInfo);
}
+ public clearSymbol(): void {
+ this.eventManager.clearSymbol();
+ }
+
private renderAttachments(config: IMoexChart, toggleToolbar: () => boolean) {
const drawingsCollectionManager = this.chart.getDrawingsCollectionManager();
diff --git a/src/core/Pane.tsx b/src/core/Pane.tsx
index a41bb3b0323ad8aaf45fc6bc057ad19e43612fb7..0484870b14f536ffdf4d930fc32eccb47c1a5545 100644
--- a/src/core/Pane.tsx
+++ b/src/core/Pane.tsx
@@ -1,6 +1,5 @@
import { IChartApi, IPaneApi, PriceScaleMode, Time } from 'lightweight-charts';
-
-import { BehaviorSubject, Subscription } from 'rxjs';
+import { BehaviorSubject, combineLatest, Subscription } from 'rxjs';
import { ChartTooltip } from '@components/ChartTooltip';
import { LegendComponent } from '@components/Legend';
@@ -70,7 +69,6 @@ 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
@@ -84,13 +82,13 @@ export class Pane implements ISerializable<PaneSnapshot> {
private readonly drawingsManager: DrawingsManager;
private legendContainer!: HTMLElement;
private paneOverlayContainer!: HTMLElement;
+ private paneEmptyStateContainer!: HTMLElement;
private legendRenderer!: UIRenderer;
private tooltipRenderer: UIRenderer | undefined;
private readonly modalRenderer: ModalRenderer;
private readonly leftPriceScale: PriceScale;
private readonly rightPriceScale: PriceScale;
private readonly priceScaleControls: PriceScaleControls;
- private mainSerieSub?: Subscription;
private readonly subscribeChartEvent: ChartMouseEvents['subscribe'];
private readonly onDelete: () => void;
private readonly onPriceScaleStateChange: () => void;
@@ -157,7 +155,6 @@ export class Pane implements ISerializable<PaneSnapshot> {
});
this.tooltipRenderer = new ReactRenderer(this.paneOverlayContainer);
-
this.tooltipRenderer.renderComponent(
<ChartTooltip
formatObs={this.eventManager.getChartOptionsModel()}
@@ -171,13 +168,26 @@ export class Pane implements ISerializable<PaneSnapshot> {
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.subscriptions.add(
+ this.mainSeries.subscribe(() => {
+ this.rebindIndicators();
+ }),
+ );
+
+ this.subscriptions.add(
+ combineLatest([this.eventManager.hasSymbol(), this.indicatorsMap]).subscribe(([hasMainSymbol, indicators]) => {
+ const hasIndicator = Array.from(indicators.values()).some(
+ (indicator) => indicator.getIndicatorType() !== undefined,
+ );
+
+ this.paneEmptyStateContainer.hidden = hasMainSymbol || !hasIndicator;
+ }),
+ );
+
this.drawingsManager = new DrawingsManager({
// todo: менеджер дровингов должен быть один на чарт, не на пейн
pane: this,
@@ -198,6 +208,7 @@ export class Pane implements ISerializable<PaneSnapshot> {
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);
@@ -264,21 +275,27 @@ export class Pane implements ISerializable<PaneSnapshot> {
}
public setIndicator(indicatorId: string, indicator: Indicator): void {
- const map = this.indicatorsMap.value;
+ const indicators = new Map(this.indicatorsMap.value);
- map.set(indicatorId, indicator);
- this.indicatorsMap.next(map);
+ indicators.set(indicatorId, indicator);
+
+ this.indicatorsMap.next(indicators);
this.priceScaleControls.refresh();
}
public removeIndicator(indicatorId: string): void {
- const map = this.indicatorsMap.value;
+ if (!this.indicatorsMap.value.has(indicatorId)) {
+ return;
+ }
+
+ const indicators = new Map(this.indicatorsMap.value);
- map.delete(indicatorId);
- this.indicatorsMap.next(map);
+ indicators.delete(indicatorId);
+
+ this.indicatorsMap.next(indicators);
this.priceScaleControls.refresh();
- if (map.size === 0 && !this.isMain) {
+ if (indicators.size === 0 && !this.isMain) {
this.onDelete();
}
}
@@ -331,17 +348,16 @@ export class Pane implements ISerializable<PaneSnapshot> {
}
this.drawingsManager.destroy();
-
this.subscriptions.unsubscribe();
this.tooltip?.destroy();
this.legend?.destroy();
this.legendRenderer.destroy();
this.tooltipRenderer?.destroy();
this.priceScaleControls.destroy();
+ this.paneEmptyStateContainer.remove();
this.legendContainer.remove();
this.paneOverlayContainer.remove();
this.indicatorsMap.complete();
- this.mainSerieSub?.unsubscribe();
this.localMainSeries.complete();
if (this.isMain) {
@@ -400,10 +416,12 @@ export class Pane implements ISerializable<PaneSnapshot> {
};
private initializeLegend({ ohlcConfig }: { ohlcConfig: OHLCConfig }): void {
- const { legendContainer, paneOverlayContainer } = ContainerManager.createPaneContainers();
+ const { legendContainer, paneOverlayContainer, paneEmptyStateContainer } = ContainerManager.createPaneContainers();
this.legendContainer = legendContainer;
this.paneOverlayContainer = paneOverlayContainer;
+ this.paneEmptyStateContainer = paneEmptyStateContainer;
+ this.paneEmptyStateContainer.textContent = t('Add a main instrument to display indicators');
this.legendRenderer = new ReactRenderer(legendContainer);
this.schedulePaneContainerSync();
@@ -460,22 +478,23 @@ export class Pane implements ISerializable<PaneSnapshot> {
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();
- });
+ this.subscriptions.add(
+ this.eventManager.subscribeSeriesSelected((nextSeries) => {
+ this.mainSeries.value?.destroy();
+
+ const next = ensureDefined(SeriesFactory.create(nextSeries))({
+ lwcChart,
+ dataSource,
+ mainSymbolId$: this.eventManager.symbolId(),
+ mainSymbol$: this.eventManager.symbol(),
+ mainSerie$: this.mainSeries,
+ });
+
+ this.mainSeries.next(next);
+ this.priceScaleControls.refresh();
+ }),
+ );
}
private syncPaneContainers(): void {
@@ -483,7 +502,6 @@ export class Pane implements ISerializable<PaneSnapshot> {
if (!lwcPaneElement) {
this.schedulePaneContainerSync();
-
return;
}
@@ -502,6 +520,7 @@ export class Pane implements ISerializable<PaneSnapshot> {
chartCell.style.position = 'relative';
chartCell.appendChild(this.legendContainer);
+ chartCell.appendChild(this.paneEmptyStateContainer);
chartCell.appendChild(this.paneOverlayContainer);
this.priceScaleControls.mount(lwcPaneElement);
diff --git a/src/core/PaneManager.ts b/src/core/PaneManager.ts
index 8c8609f094b542b0aa8bd071b522e485515942a8..a2e59cbc7ffd88a421c57287c24a23be18694fa3 100644
--- a/src/core/PaneManager.ts
+++ b/src/core/PaneManager.ts
@@ -96,6 +96,10 @@ export class PaneManager implements ISerializable<PaneSnapshot[]> {
return;
}
+ if (paneSnapshot.indicators.length === 0 && paneSnapshot.drawings.length === 0) {
+ return;
+ }
+
const pane = this.addPane(undefined, paneSnapshot.id, paneSnapshot.priceScales);
pane.setDrawingsSnapshot(paneSnapshot.drawings);
diff --git a/src/core/__tests__/Chart.test.ts b/src/core/__tests__/Chart.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d79c2f42d2604b7c7ce088e6db85ee4d7113c76c
--- /dev/null
+++ b/src/core/__tests__/Chart.test.ts
@@ -0,0 +1,70 @@
+import { BehaviorSubject, Subscription } from 'rxjs';
+
+import { Chart } from '@core/Chart';
+
+interface ChartInternals {
+ eventManager: {
+ symbolId: () => BehaviorSubject<string>;
+ getInterval: () => BehaviorSubject<null>;
+ };
+ compareManager: {
+ itemsObs: () => BehaviorSubject<{ symbolId: string }[]>;
+ };
+ dataSource: {
+ setSymbols: jest.Mock;
+ loadTill: jest.Mock;
+ };
+ lwcChart: {
+ timeScale: () => {
+ getVisibleRange: () => null;
+ };
+ };
+ subscriptions: Subscription;
+ activeSymbolIds: string[];
+ currentInterval: null;
+ setupDataSourceSubs: () => void;
+}
+
+describe('Chart', () => {
+ function createChartInternals(): ChartInternals {
+ return Object.assign(Object.create(Chart.prototype), {
+ eventManager: {
+ symbolId: () => new BehaviorSubject(''),
+ getInterval: () => new BehaviorSubject(null),
+ },
+ compareManager: {
+ itemsObs: () =>
+ new BehaviorSubject([
+ {
+ symbolId: 'SBER',
+ },
+ {
+ symbolId: 'SBER',
+ },
+ ]),
+ },
+ dataSource: {
+ setSymbols: jest.fn(),
+ loadTill: jest.fn(() => Promise.resolve()),
+ },
+ lwcChart: {
+ timeScale: () => ({
+ getVisibleRange: () => null,
+ }),
+ },
+ subscriptions: new Subscription(),
+ activeSymbolIds: [],
+ currentInterval: null,
+ }) as ChartInternals;
+ }
+
+ it('должен исключать пустой основной symbolId и удалять дубликаты', () => {
+ const chart = createChartInternals();
+
+ chart.setupDataSourceSubs();
+
+ expect(chart.dataSource.setSymbols).toHaveBeenLastCalledWith(['SBER']);
+
+ chart.subscriptions.unsubscribe();
+ });
+});
diff --git a/src/core/__tests__/CompareManager.test.ts b/src/core/__tests__/CompareManager.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2d74b26ad73169714e107275177954abdf069c77
--- /dev/null
+++ b/src/core/__tests__/CompareManager.test.ts
@@ -0,0 +1,284 @@
+import { IChartApi, PriceScaleMode, SeriesType } from 'lightweight-charts';
+import { BehaviorSubject } from 'rxjs';
+
+import { DataSource } from '@core/DataSource';
+import { EventManager } from '@core/EventManager';
+import { Indicator } from '@core/Indicator';
+import { IndicatorManager } from '@core/IndicatorManager';
+import { Pane } from '@core/Pane';
+import { PaneManager } from '@core/PaneManager';
+import { PriceScale } from '@core/PriceScale';
+
+import { CompareManager } from '@src/core/CompareManager';
+import { CompareMode, Direction } from '@src/types';
+import { PriceScaleSide } from '@src/types/snapshot';
+import { Timeframes } from '@src/types/timeframes';
+
+jest.mock('@core/Indicator');
+
+type IndicatorFactory = (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) => Indicator;
+
+describe('CompareManager', () => {
+ const IndicatorMock = Indicator as jest.MockedClass<typeof Indicator>;
+
+ let manager: CompareManager;
+ let mainPane: Pane;
+ let secondaryPane: Pane;
+ let rightPriceScale: PriceScale;
+
+ let addEntity: jest.Mock;
+ let getIndicators: jest.Mock;
+ let removeEntity: jest.Mock;
+
+ let getMainPane: jest.Mock;
+ let getPaneById: jest.Mock;
+ let addPane: jest.Mock;
+ let setPriceScaleSideVisible: jest.Mock;
+ let invalidate: jest.Mock;
+
+ let waitUntilReady: jest.Mock;
+
+ beforeAll(() => {
+ Object.defineProperty(globalThis, 'crypto', {
+ configurable: true,
+ value: {
+ randomUUID: () => 'test-uuid',
+ },
+ });
+ });
+
+ beforeEach(() => {
+ IndicatorMock.mockImplementation(
+ (params) =>
+ ({
+ id: params.id,
+ destroy: jest.fn(),
+ getConfig: jest.fn(() => params.config),
+ getIndicatorType: jest.fn(() => params.type),
+ }) as unknown as Indicator,
+ );
+
+ rightPriceScale = {
+ getMode: jest.fn(() => PriceScaleMode.Normal),
+ setMode: jest.fn(),
+ } as unknown as PriceScale;
+
+ mainPane = {
+ getId: jest.fn(() => 0),
+ getPriceScale: jest.fn((_side: PriceScaleSide) => rightPriceScale),
+ } as unknown as Pane;
+
+ secondaryPane = {
+ getId: jest.fn(() => 1),
+ } as unknown as Pane;
+
+ addEntity = jest.fn((factory: IndicatorFactory) => factory(1, jest.fn(), jest.fn()));
+ getIndicators = jest.fn(() => new BehaviorSubject<Map<string, Indicator>>(new Map()));
+ removeEntity = jest.fn();
+
+ getMainPane = jest.fn(() => mainPane);
+ getPaneById = jest.fn();
+ addPane = jest.fn(() => secondaryPane);
+ setPriceScaleSideVisible = jest.fn();
+ invalidate = jest.fn();
+
+ waitUntilReady = jest.fn((_symbolRaw: string) => Promise.resolve());
+
+ const indicatorManager = {
+ addEntity,
+ getIndicators,
+ removeEntity,
+ } as unknown as IndicatorManager;
+
+ const paneManager = {
+ getMainPane,
+ getPaneById,
+ addPane,
+ setPriceScaleSideVisible,
+ invalidate,
+ } as unknown as PaneManager;
+
+ const dataSource = {
+ waitUntilReady,
+ } as unknown as DataSource;
+
+ const eventManager = {
+ timeframe: jest.fn(() => new BehaviorSubject(Timeframes['10s'])),
+ } as unknown as EventManager;
+
+ manager = new CompareManager({
+ chart: {} as IChartApi,
+ eventManager,
+ dataSource,
+ indicatorManager,
+ paneManager,
+ });
+ });
+
+ afterEach(() => {
+ manager.destroy();
+ });
+
+ it('должен добавлять compare-серию типа Line', async () => {
+ await manager.setSymbolMode(
+ 'Line' as SeriesType,
+ {
+ symbolId: 'SBER',
+ symbol: 'SBER',
+ symbolName: 'Sberbank',
+ },
+ CompareMode.Absolute,
+ );
+
+ expect(IndicatorMock).toHaveBeenCalledTimes(1);
+
+ const params = IndicatorMock.mock.calls[0][0];
+
+ expect(params.config.series[0]?.name).toBe('Line');
+ expect(params.config.series[0]?.id).toBe('compare-test-uuid');
+ expect(params.associatedPane).toBe(mainPane);
+ expect(waitUntilReady).toHaveBeenCalledWith('SBER');
+ expect(manager.getAllEntities()).toHaveLength(1);
+ });
+
+ it('не должен добавлять невалидный инструмент', async () => {
+ await manager.setSymbolMode(
+ 'Line' as SeriesType,
+ {
+ symbolId: '',
+ },
+ CompareMode.Absolute,
+ );
+
+ expect(addEntity).not.toHaveBeenCalled();
+ expect(manager.getAllEntities()).toHaveLength(0);
+ });
+
+ it('должен создавать отдельный pane для режима NewPane', async () => {
+ await manager.setSymbolMode(
+ 'Line' as SeriesType,
+ {
+ symbolId: 'SBER',
+ },
+ CompareMode.NewPane,
+ );
+
+ expect(addPane).toHaveBeenCalledTimes(1);
+ expect(IndicatorMock.mock.calls[0][0].associatedPane).toBe(secondaryPane);
+ });
+
+ it('должен использовать существующий pane при восстановлении NewPane', async () => {
+ getPaneById.mockReturnValue(secondaryPane);
+
+ await manager.setSymbolMode(
+ 'Line' as SeriesType,
+ {
+ symbolId: 'SBER',
+ },
+ CompareMode.NewPane,
+ 1,
+ );
+
+ expect(getPaneById).toHaveBeenCalledWith(1);
+ expect(addPane).not.toHaveBeenCalled();
+ });
+
+ it('не должен повторно добавлять одинаковый инструмент в одном режиме', async () => {
+ await manager.setSymbolMode(
+ 'Line' as SeriesType,
+ {
+ symbolId: 'SBER',
+ },
+ CompareMode.Absolute,
+ );
+
+ await manager.setSymbolMode(
+ 'Line' as SeriesType,
+ {
+ symbolId: 'SBER',
+ },
+ CompareMode.Absolute,
+ );
+
+ expect(addEntity).toHaveBeenCalledTimes(1);
+ });
+
+ it('не должен добавлять NewScale если compare уже существует', async () => {
+ await manager.setSymbolMode(
+ 'Line' as SeriesType,
+ {
+ symbolId: 'SBER',
+ },
+ CompareMode.Absolute,
+ );
+
+ await manager.setSymbolMode(
+ 'Line' as SeriesType,
+ {
+ symbolId: 'GAZP',
+ },
+ CompareMode.NewScale,
+ );
+
+ expect(addEntity).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен включать левую шкалу для NewScale', async () => {
+ await manager.setSymbolMode(
+ 'Line' as SeriesType,
+ {
+ symbolId: 'SBER',
+ },
+ CompareMode.NewScale,
+ );
+
+ expect(setPriceScaleSideVisible).toHaveBeenCalledWith(Direction.Left, true);
+ });
+
+ it('должен удалять compare-сущность', async () => {
+ await manager.setSymbolMode(
+ 'Line' as SeriesType,
+ {
+ symbolId: 'SBER',
+ },
+ CompareMode.Absolute,
+ );
+
+ const [entry] = manager.getAllEntities();
+
+ if (!entry) {
+ throw new Error('Compare-сущность не была добавлена');
+ }
+
+ manager.removeSymbol('SBER');
+
+ expect(removeEntity).toHaveBeenCalledWith(entry.entity);
+ expect(entry.entity.destroy).toHaveBeenCalledTimes(1);
+ expect(manager.getAllEntities()).toHaveLength(0);
+ });
+
+ it('должен очищать все compare-сущности', async () => {
+ await manager.setSymbolMode(
+ 'Line' as SeriesType,
+ {
+ symbolId: 'SBER',
+ },
+ CompareMode.Absolute,
+ );
+
+ await manager.setSymbolMode(
+ 'Line' as SeriesType,
+ {
+ symbolId: 'GAZP',
+ },
+ CompareMode.NewPane,
+ );
+
+ expect(manager.getAllEntities()).toHaveLength(2);
+
+ manager.clear();
+
+ expect(manager.getAllEntities()).toHaveLength(0);
+ expect(removeEntity).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/src/core/__tests__/ContainerManager.test.ts b/src/core/__tests__/ContainerManager.test.ts
index 3c55fa4ebd21a5356f69f36848b776cd3d6eada4..9c548bf493d47f6bcde84da4c5c417afb87d50a6 100644
--- a/src/core/__tests__/ContainerManager.test.ts
+++ b/src/core/__tests__/ContainerManager.test.ts
@@ -1,6 +1,7 @@
import {
CHART_DRAWING_TOOLBAR_CONTAINER,
CHART_MODAL_CONTAINER_CLASSNAME,
+ CHART_PANE_EMPTY_STATE_CONTAINER,
CHART_PANE_OVERLAY_CONTAINER,
CHART_ROOT_CLASSNAME,
} from '@src/constants';
@@ -10,6 +11,7 @@ import { ContainerManager } from '../ContainerManager';
jest.mock('@src/constants', () => ({
CHART_DRAWING_TOOLBAR_CONTAINER: 'chart-drawing-toolbar-container',
CHART_MODAL_CONTAINER_CLASSNAME: 'chart-modal-container',
+ CHART_PANE_EMPTY_STATE_CONTAINER: 'chart-pane-empty-state-container',
CHART_PANE_OVERLAY_CONTAINER: 'chart-pane-overlay-container',
CHART_ROOT_CLASSNAME: 'chart-root',
}));
@@ -46,7 +48,6 @@ describe('ContainerManager', () => {
});
expect(parentContainer.classList.contains(CHART_ROOT_CLASSNAME)).toBe(true);
-
expect(parentContainer.children[0]).toBe(headerContainer);
expect(parentContainer.children[1]).toBe(chartContainer);
expect(parentContainer.children[2]).toBe(footerContainer);
@@ -59,9 +60,7 @@ describe('ContainerManager', () => {
expect(chartAreaContainer.contains(drawingToolbarContainer)).toBe(true);
expect(drawingToolbarContainer.classList.contains(CHART_DRAWING_TOOLBAR_CONTAINER)).toBe(true);
-
expect(modalContainer.classList.contains(CHART_MODAL_CONTAINER_CLASSNAME)).toBe(true);
-
expect(modalContainer.hidden).toBe(true);
});
@@ -121,9 +120,8 @@ describe('ContainerManager', () => {
expect(chartContainer.style.gridTemplateColumns).toBe('42px minmax(0, 1fr)');
});
- it('должен удалять старое содержимое родительского контейнера перед созданием графика', () => {
+ it('должен удалять старое содержимое перед созданием контейнеров', () => {
const oldElement = document.createElement('div');
-
parentContainer.appendChild(oldElement);
ContainerManager.createContainers({
@@ -148,48 +146,38 @@ describe('ContainerManager', () => {
const fontLinks = document.querySelectorAll('link[href*="fonts.googleapis.com"]');
expect(fontLinks).toHaveLength(1);
- expect(fontLinks[0]?.getAttribute('href')).toBe(
- 'https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,100..900&display=swap',
- );
});
});
describe('createPaneContainers', () => {
- it('должен создавать контейнеры легенды и оверлея панели', () => {
- const { legendContainer, paneOverlayContainer } = ContainerManager.createPaneContainers();
+ it('должен создавать контейнеры pane с корректными настройками', () => {
+ const { legendContainer, paneOverlayContainer, paneEmptyStateContainer } =
+ ContainerManager.createPaneContainers();
expect(legendContainer.style.position).toBe('absolute');
expect(legendContainer.style.pointerEvents).toBe('none');
expect(paneOverlayContainer.classList.contains(CHART_PANE_OVERLAY_CONTAINER)).toBe(true);
-
expect(paneOverlayContainer.style.position).toBe('absolute');
+ expect(paneOverlayContainer.style.inset).toBe('0');
expect(paneOverlayContainer.style.pointerEvents).toBe('none');
- });
- it('должен размещать слои графика в корректном порядке', () => {
- const { controlBarContainer, drawingToolbarContainer, modalContainer } = ContainerManager.createContainers({
- parentContainer,
- showMenuButton: true,
- });
-
- const { legendContainer, paneOverlayContainer } = ContainerManager.createPaneContainers();
+ expect(paneEmptyStateContainer.classList.contains(CHART_PANE_EMPTY_STATE_CONTAINER)).toBe(true);
+ expect(paneEmptyStateContainer.hidden).toBe(true);
+ });
- const controlBarZIndex = Number(controlBarContainer.style.zIndex);
- const paneOverlayZIndex = Number(paneOverlayContainer.style.zIndex);
- const drawingZIndex = Number(drawingToolbarContainer.style.zIndex);
- const legendZIndex = Number(legendContainer.style.zIndex);
- const modalZIndex = Number(modalContainer.style.zIndex);
+ it('должен создавать отдельные контейнеры для каждого pane', () => {
+ const first = ContainerManager.createPaneContainers();
+ const second = ContainerManager.createPaneContainers();
- expect(controlBarZIndex).toBe(paneOverlayZIndex);
- expect(legendZIndex).toBeGreaterThan(paneOverlayZIndex);
- expect(drawingZIndex).toBeGreaterThan(legendZIndex);
- expect(modalZIndex).toBeGreaterThan(drawingZIndex);
+ expect(first.legendContainer).not.toBe(second.legendContainer);
+ expect(first.paneOverlayContainer).not.toBe(second.paneOverlayContainer);
+ expect(first.paneEmptyStateContainer).not.toBe(second.paneEmptyStateContainer);
});
});
describe('clearContainers', () => {
- it('должен очищать содержимое родительского контейнера', () => {
+ it('должен очищать содержимое контейнера', () => {
parentContainer.innerHTML = '<div>content</div>';
ContainerManager.clearContainers(parentContainer);
diff --git a/src/core/__tests__/EventManager.test.ts b/src/core/__tests__/EventManager.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..779b6546147d2884ed6b2050a9f661d2b0e54876
--- /dev/null
+++ b/src/core/__tests__/EventManager.test.ts
@@ -0,0 +1,95 @@
+import { Timeframes } from '@src/types/timeframes';
+
+import { EventManager } from '../EventManager';
+
+describe('EventManager', () => {
+ function createEventManager(symbolId = ''): EventManager {
+ return new EventManager({
+ initialTimeframe: Timeframes['10s'],
+ initialSeries: 'Candlestick',
+ initialSymbolInfo: {
+ symbolId,
+ symbol: symbolId ? 'SBER' : '',
+ symbolName: symbolId ? 'Sberbank' : '',
+ },
+ });
+ }
+
+ it('должен поддерживать состояние без основного инструмента', () => {
+ const eventManager = createEventManager();
+ const hasSymbolValues: boolean[] = [];
+
+ const subscription = eventManager.hasSymbol().subscribe((hasSymbol) => {
+ hasSymbolValues.push(hasSymbol);
+ });
+
+ expect(hasSymbolValues).toEqual([false]);
+
+ let symbolId = 'initial';
+
+ const symbolSubscription = eventManager.symbolId().subscribe((value) => {
+ symbolId = value;
+ });
+
+ expect(symbolId).toBe('');
+
+ subscription.unsubscribe();
+ symbolSubscription.unsubscribe();
+ eventManager.destroy();
+ });
+
+ it('должен менять состояние наличия основного инструмента', () => {
+ const eventManager = createEventManager();
+ const values: boolean[] = [];
+
+ const subscription = eventManager.hasSymbol().subscribe((value) => {
+ values.push(value);
+ });
+
+ eventManager.setSymbol({
+ symbolId: 'SBER',
+ symbol: 'SBER',
+ symbolName: 'Sberbank',
+ });
+
+ eventManager.clearSymbol();
+
+ expect(values).toEqual([false, true, false]);
+ expect(eventManager.exportChartSettings().symbolInfo).toEqual({
+ symbolId: '',
+ symbol: '',
+ symbolName: '',
+ });
+
+ subscription.unsubscribe();
+ eventManager.destroy();
+ });
+
+ it('не должен изменять состояние при установке невалидного инструмента', () => {
+ const eventManager = createEventManager();
+
+ eventManager.setSymbol({
+ symbolId: '',
+ });
+
+ expect(eventManager.exportChartSettings().symbolInfo).toEqual({
+ symbolId: '',
+ symbol: '',
+ symbolName: '',
+ });
+
+ eventManager.destroy();
+ });
+
+ it('не должен повторно очищать уже пустой основной инструмент', () => {
+ const eventManager = createEventManager();
+ const undoRedo = eventManager.getUndoRedo();
+ const pushSpy = jest.spyOn(undoRedo, 'push');
+
+ eventManager.clearSymbol();
+
+ expect(pushSpy).not.toHaveBeenCalled();
+
+ eventManager.destroy();
+ });
+});
diff --git a/src/core/__tests__/IndicatorManager.test.ts b/src/core/__tests__/IndicatorManager.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4a2bcae02881a2252df5617dbe4db988816b4c57
--- /dev/null
+++ b/src/core/__tests__/IndicatorManager.test.ts
@@ -0,0 +1,191 @@
+import { IChartApi } from 'lightweight-charts';
+import { of } from 'rxjs';
+
+import { DataSource } from '@core/DataSource';
+import { DOMModel } from '@core/DOMModel';
+import { EventManager } from '@core/EventManager';
+import { Indicator } from '@core/Indicator';
+import { IndicatorManager } from '@core/IndicatorManager';
+import { Pane } from '@core/Pane';
+import { PaneManager } from '@core/PaneManager';
+
+import { IndicatorsIds } from '@src/constants';
+
+jest.mock('@core/Indicator');
+
+jest.mock('@src/core/Indicators', () => ({
+ indicatorsMap: () => ({
+ ema: {
+ newPane: false,
+ label: 'EMA',
+ series: [],
+ },
+ }),
+}));
+
+jest.mock('@src/utils', () => {
+ const actual = jest.requireActual<typeof import('@src/utils')>('@src/utils');
+
+ return {
+ ...actual,
+ applyNextIndicatorColors: (config: unknown) => config,
+ getIndicatorColors: () => [],
+ };
+});
+
+type EntityFactory = (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) => Indicator;
+
+describe('IndicatorManager', () => {
+ const IndicatorMock = Indicator as jest.MockedClass<typeof Indicator>;
+
+ let manager: IndicatorManager;
+ let mainPane: Pane;
+
+ let setEntity: jest.Mock;
+ let removeEntity: jest.Mock;
+ let getMainPane: jest.Mock;
+ let getPaneById: jest.Mock;
+ let addPane: jest.Mock;
+
+ beforeEach(() => {
+ IndicatorMock.mockImplementation(
+ (params) =>
+ ({
+ id: params.id,
+ destroy: jest.fn(),
+ getConfig: jest.fn(() => params.config),
+ getIndicatorType: jest.fn(() => params.type),
+ }) as unknown as Indicator,
+ );
+
+ mainPane = {
+ getId: jest.fn(() => 0),
+ } as unknown as Pane;
+
+ getMainPane = jest.fn(() => mainPane);
+ getPaneById = jest.fn();
+ addPane = jest.fn(() => mainPane);
+
+ setEntity = jest.fn((factory: EntityFactory) => factory(1, jest.fn(), jest.fn()));
+ removeEntity = jest.fn();
+
+ const paneManager = {
+ getMainPane,
+ getPaneById,
+ addPane,
+ } as unknown as PaneManager;
+
+ const eventManager = {
+ symbolId: jest.fn(() => of('SBER')),
+ symbol: jest.fn(() => of('SBER')),
+ } as unknown as EventManager;
+
+ const DOM = {
+ setEntity,
+ removeEntity,
+ } as unknown as DOMModel;
+
+ manager = new IndicatorManager({
+ eventManager,
+ dataSource: {} as DataSource,
+ lwcChart: {} as IChartApi,
+ paneManager,
+ DOM,
+ });
+ });
+
+ it('не должен добавлять индикатор без типа', () => {
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
+
+ manager.addIndicator({});
+
+ expect(setEntity).not.toHaveBeenCalled();
+ expect(manager.getIndicators().value.size).toBe(0);
+
+ consoleSpy.mockRestore();
+ });
+
+ it('должен добавлять индикатор', () => {
+ manager.addIndicator({
+ id: 'ema-1',
+ indicatorType: IndicatorsIds.EMA,
+ });
+
+ expect(manager.getIndicators().value.has('ema-1')).toBe(true);
+ expect(getMainPane).toHaveBeenCalledTimes(1);
+ expect(IndicatorMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен публиковать добавленные индикаторы', () => {
+ let entities: Indicator[] = [];
+
+ const subscription = manager.entities().subscribe((value) => {
+ entities = value;
+ });
+
+ manager.addIndicator({
+ id: 'ema-1',
+ indicatorType: IndicatorsIds.EMA,
+ });
+
+ expect(entities).toHaveLength(1);
+ expect(entities[0]).toBe(manager.getIndicators().value.get('ema-1'));
+
+ subscription.unsubscribe();
+ });
+
+ it('должен использовать pane из snapshot', () => {
+ const pane = {
+ getId: jest.fn(() => 3),
+ } as unknown as Pane;
+
+ getPaneById.mockReturnValue(pane);
+
+ manager.addIndicator({
+ id: 'ema-1',
+ indicatorType: IndicatorsIds.EMA,
+ paneId: 3,
+ });
+
+ expect(getPaneById).toHaveBeenCalledWith(3);
+ expect(addPane).not.toHaveBeenCalled();
+ });
+
+ it('должен создать pane если pane из snapshot отсутствует', () => {
+ getPaneById.mockReturnValue(undefined);
+
+ manager.addIndicator({
+ id: 'ema-1',
+ indicatorType: IndicatorsIds.EMA,
+ paneId: 3,
+ });
+
+ expect(getPaneById).toHaveBeenCalledWith(3);
+ expect(addPane).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен удалять индикатор из состояния и DOM', () => {
+ manager.addIndicator({
+ id: 'ema-1',
+ indicatorType: IndicatorsIds.EMA,
+ });
+
+ const indicator = manager.getIndicators().value.get('ema-1');
+
+ if (!indicator) {
+ throw new Error('Индикатор не был добавлен');
+ }
+
+ manager.deleteIndicator('ema-1');
+
+ expect(manager.getIndicators().value.has('ema-1')).toBe(false);
+ expect(removeEntity).toHaveBeenCalledWith(indicator);
+ expect(indicator.destroy).toHaveBeenCalledTimes(1);
+ });
+
+ it('не должен удалять несуществующий индикатор', () => {
+ manager.deleteIndicator('unknown');
+
+ expect(removeEntity).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/core/__tests__/MoexChart.test.ts b/src/core/__tests__/MoexChart.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..390c5fd405f585cf8e27f1dcad59c106f628c4fe
--- /dev/null
+++ b/src/core/__tests__/MoexChart.test.ts
@@ -0,0 +1,39 @@
+import { MoexChart } from '@core/MoexChart';
+
+describe('MoexChart', () => {
+ it('должен очищать основной инструмент через EventManager', () => {
+ const clearSymbol = jest.fn();
+
+ const chart = Object.create(MoexChart.prototype) as MoexChart;
+
+ (chart as unknown as { eventManager: { clearSymbol: () => void } }).eventManager = {
+ clearSymbol,
+ };
+
+ chart.clearSymbol();
+
+ expect(clearSymbol).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен устанавливать основной инструмент через EventManager', () => {
+ const setSymbol = jest.fn();
+
+ const chart = Object.create(MoexChart.prototype) as MoexChart;
+
+ (chart as unknown as { eventManager: { setSymbol: typeof setSymbol } }).eventManager = {
+ setSymbol,
+ };
+
+ chart.setSymbol({
+ symbolId: 'SBER',
+ symbol: 'SBER',
+ symbolName: 'Sberbank',
+ });
+
+ expect(setSymbol).toHaveBeenCalledWith({
+ symbolId: 'SBER',
+ symbol: 'SBER',
+ symbolName: 'Sberbank',
+ });
+ });
+});
diff --git a/src/core/__tests__/Pane.test.ts b/src/core/__tests__/Pane.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d425fb524776ca1157d81e679d9f23f978d1ffd2
--- /dev/null
+++ b/src/core/__tests__/Pane.test.ts
@@ -0,0 +1,225 @@
+import { BehaviorSubject, of } from 'rxjs';
+
+import { ContainerManager } from '@core/ContainerManager';
+import { DrawingsManager } from '@core/DrawingsManager';
+import { Indicator } from '@core/Indicator';
+import { Legend } from '@core/Legend';
+import { Pane } from '@core/Pane';
+import { PriceScale, PriceScaleControls } from '@core/PriceScale';
+import { ReactRenderer } from '@core/ReactRenderer';
+import { TooltipService } from '@core/Tooltip';
+
+import { IndicatorsIds } from '@src/constants';
+
+jest.mock('@core/ContainerManager');
+jest.mock('@core/DrawingsManager');
+jest.mock('@core/Legend');
+jest.mock('@core/PriceScale');
+jest.mock('@core/ReactRenderer');
+jest.mock('@core/Tooltip');
+
+jest.mock('@src/translations', () => {
+ const actual = jest.requireActual<typeof import('@src/translations')>('@src/translations');
+
+ return {
+ ...actual,
+ t: (key: string) => key,
+ };
+});
+
+describe('Pane', () => {
+ const createPaneContainersMock = ContainerManager.createPaneContainers as jest.MockedFunction<
+ typeof ContainerManager.createPaneContainers
+ >;
+
+ let hasSymbol$: BehaviorSubject<boolean>;
+ let paneElement: HTMLTableRowElement;
+ let emptyStateContainer: HTMLDivElement;
+ let onDelete: jest.Mock;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ Object.defineProperty(global, 'requestAnimationFrame', {
+ writable: true,
+ value: (callback: FrameRequestCallback) => {
+ callback(0);
+ return 1;
+ },
+ });
+
+ Object.defineProperty(global, 'cancelAnimationFrame', {
+ writable: true,
+ value: jest.fn(),
+ });
+
+ emptyStateContainer = document.createElement('div');
+ emptyStateContainer.hidden = true;
+
+ createPaneContainersMock.mockReturnValue({
+ legendContainer: document.createElement('div'),
+ paneOverlayContainer: document.createElement('div'),
+ paneEmptyStateContainer: emptyStateContainer,
+ });
+
+ (ReactRenderer as jest.MockedClass<typeof ReactRenderer>).mockImplementation(
+ () =>
+ ({
+ renderComponent: jest.fn(),
+ destroy: jest.fn(),
+ }) as unknown as ReactRenderer,
+ );
+
+ (Legend as jest.MockedClass<typeof Legend>).mockImplementation(
+ () =>
+ ({
+ getConfig: jest.fn(() => ({})),
+ getLegendViewModel: jest.fn(() => of([])),
+ destroy: jest.fn(),
+ }) as unknown as Legend,
+ );
+
+ (TooltipService as jest.MockedClass<typeof TooltipService>).mockImplementation(
+ () =>
+ ({
+ getTooltipViewModel: jest.fn(() => of({})),
+ getConfig: jest.fn(() => ({})),
+ destroy: jest.fn(),
+ }) as unknown as TooltipService,
+ );
+
+ (PriceScale as jest.MockedClass<typeof PriceScale>).mockImplementation(
+ () =>
+ ({
+ getSnapshot: jest.fn(() => ({})),
+ enableAutoScale: jest.fn(),
+ }) as unknown as PriceScale,
+ );
+
+ (PriceScaleControls as jest.MockedClass<typeof PriceScaleControls>).mockImplementation(
+ () =>
+ ({
+ refresh: jest.fn(),
+ mount: jest.fn(),
+ destroy: jest.fn(),
+ }) as unknown as PriceScaleControls,
+ );
+
+ (DrawingsManager as jest.MockedClass<typeof DrawingsManager>).mockImplementation(
+ () =>
+ ({
+ entities: jest.fn(() => of([])),
+ destroy: jest.fn(),
+ getSnapshot: jest.fn(() => []),
+ setSnapshot: jest.fn(),
+ }) as unknown as DrawingsManager,
+ );
+
+ hasSymbol$ = new BehaviorSubject(false);
+ onDelete = jest.fn();
+
+ paneElement = document.createElement('tr');
+ paneElement.append(document.createElement('td'), document.createElement('td'), document.createElement('td'));
+ });
+
+ function createPane(): Pane {
+ const mainSeries$ = new BehaviorSubject(null);
+
+ return new Pane({
+ id: 1,
+ isMainPane: false,
+ lwcChart: {
+ addPane: jest.fn(() => ({
+ getHTMLElement: jest.fn(() => paneElement),
+ paneIndex: jest.fn(() => 1),
+ })),
+ } as never,
+ eventManager: {
+ hasSymbol: jest.fn(() => hasSymbol$),
+ getChartOptionsModel: jest.fn(() => of({})),
+ getTimeframeObs: jest.fn(() => of('10s')),
+ } as never,
+ DOM: {} as never,
+ ohlcConfig: {} as never,
+ dataSource: null,
+ basedOn: {
+ getMainSerie: jest.fn(() => mainSeries$),
+ } as never,
+ subscribeChartEvent: jest.fn() as never,
+ tooltipConfig: {},
+ onDelete,
+ chartContainer: document.createElement('div'),
+ modalRenderer: {} as never,
+ onPriceScaleStateChange: jest.fn(),
+ leftPriceScaleVisible: false,
+ rightPriceScaleVisible: true,
+ hotkeys: {} as never,
+ addDrawingManager: jest.fn(),
+ removeDrawingManager: jest.fn(),
+ setActiveTool: jest.fn(),
+ getActiveTool: jest.fn(),
+ getIsEndlessMode: jest.fn(),
+ continueDrawing: jest.fn(),
+ });
+ }
+
+ it('должен показывать empty state для индикатора без основного инструмента', () => {
+ const pane = createPane();
+
+ const indicator = {
+ getIndicatorType: jest.fn(() => IndicatorsIds.RSI),
+ recreateSeries: jest.fn(),
+ } as unknown as Indicator;
+
+ pane.setIndicator('rsi', indicator);
+
+ expect(emptyStateContainer.hidden).toBe(false);
+
+ pane.destroy();
+ });
+
+ it('должен скрывать empty state после добавления основного инструмента', () => {
+ const pane = createPane();
+
+ pane.setIndicator('rsi', {
+ getIndicatorType: jest.fn(() => IndicatorsIds.RSI),
+ recreateSeries: jest.fn(),
+ } as unknown as Indicator);
+
+ expect(emptyStateContainer.hidden).toBe(false);
+
+ hasSymbol$.next(true);
+
+ expect(emptyStateContainer.hidden).toBe(true);
+
+ pane.destroy();
+ });
+
+ it('не должен показывать empty state только для compare-сущности', () => {
+ const pane = createPane();
+
+ pane.setIndicator('compare', {
+ getIndicatorType: jest.fn(() => undefined),
+ recreateSeries: jest.fn(),
+ } as unknown as Indicator);
+
+ expect(emptyStateContainer.hidden).toBe(true);
+
+ pane.destroy();
+ });
+
+ it('должен удалить secondary pane после удаления последнего индикатора', () => {
+ const pane = createPane();
+
+ pane.setIndicator('rsi', {
+ getIndicatorType: jest.fn(() => IndicatorsIds.RSI),
+ recreateSeries: jest.fn(),
+ } as unknown as Indicator);
+
+ pane.removeIndicator('rsi');
+
+ expect(onDelete).toHaveBeenCalledTimes(1);
+
+ pane.destroy();
+ });
+});
diff --git a/src/core/__tests__/PaneManager.test.ts b/src/core/__tests__/PaneManager.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..79adee94c2eee2d7958599a338f0ce1f4a08b7e9
--- /dev/null
+++ b/src/core/__tests__/PaneManager.test.ts
@@ -0,0 +1,128 @@
+import { PriceScaleMode } from 'lightweight-charts';
+import { BehaviorSubject, of } from 'rxjs';
+
+import { DrawingsManagerCollection } from '@core/DrawingsManagerCollection';
+import { Pane } from '@core/Pane';
+import { PaneManager } from '@core/PaneManager';
+import { PriceAxisLabels } from '@core/PriceAxisLabels';
+
+import { IndicatorsIds } from '@src/constants';
+import { PaneSnapshot } from '@src/types/snapshot';
+
+jest.mock('@core/Pane');
+jest.mock('@core/DrawingsManagerCollection');
+jest.mock('@core/PriceAxisLabels');
+
+describe('PaneManager', () => {
+ const PaneMock = Pane as jest.MockedClass<typeof Pane>;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ PaneMock.mockImplementation(
+ (params) =>
+ ({
+ getId: jest.fn(() => params.id),
+ paneIndex: jest.fn(() => params.id),
+ getMainSerie: jest.fn(() => new BehaviorSubject(null)),
+ setDrawingsSnapshot: jest.fn(),
+ getDrawingsSnapshot: jest.fn(() => []),
+ schedulePaneContainerSync: jest.fn(),
+ refreshPriceScaleControls: jest.fn(),
+ resetPriceScalesAutoScale: jest.fn(),
+ destroy: jest.fn(),
+ getSnapshot: jest.fn(() => ({
+ isMain: params.isMainPane,
+ id: params.id,
+ indicators: [],
+ drawings: [],
+ })),
+ getPriceScale: jest.fn(() => ({
+ getMode: jest.fn(() => PriceScaleMode.Normal),
+ setVisible: jest.fn(),
+ })),
+ }) as unknown as Pane,
+ );
+
+ (DrawingsManagerCollection as jest.MockedClass<typeof DrawingsManagerCollection>).mockImplementation(
+ () =>
+ ({
+ destroy: jest.fn(),
+ handlePaneClick: jest.fn(),
+ addDrawingManager: jest.fn(),
+ removeDrawingManager: jest.fn(),
+ setActiveTool: jest.fn(),
+ getActiveToolValue: jest.fn(),
+ getIsEndlessMode: jest.fn(),
+ activateDrawingTool: jest.fn(),
+ }) as unknown as DrawingsManagerCollection,
+ );
+
+ (PriceAxisLabels as jest.MockedClass<typeof PriceAxisLabels>).mockImplementation(
+ () =>
+ ({
+ destroy: jest.fn(),
+ invalidate: jest.fn(),
+ setVisibleLogicalRange: jest.fn(),
+ }) as unknown as PriceAxisLabels,
+ );
+ });
+
+ function createManager(panesSnapshot: PaneSnapshot[]): PaneManager {
+ const params = {
+ panesSnapshot,
+ lwcChart: {
+ subscribeClick: jest.fn(),
+ unsubscribeClick: jest.fn(),
+ removePane: jest.fn(),
+ },
+ eventManager: {
+ symbol: jest.fn(() => of('')),
+ },
+ dataSource: null,
+ DOM: {},
+ ohlcConfig: {},
+ subscribeChartEvent: jest.fn(),
+ tooltipConfig: {},
+ chartContainer: document.createElement('div'),
+ modalRenderer: {},
+ hotkeys: {},
+ } as unknown as ConstructorParameters<typeof PaneManager>[0];
+
+ return new PaneManager(params);
+ }
+
+ it('не должен восстанавливать пустой secondary pane', () => {
+ const manager = createManager([
+ {
+ isMain: true,
+ id: 0,
+ indicators: [],
+ drawings: [],
+ },
+ {
+ isMain: false,
+ id: 1,
+ indicators: [],
+ drawings: [],
+ },
+ {
+ isMain: false,
+ id: 2,
+ indicators: [
+ {
+ indicatorType: IndicatorsIds.RSI,
+ },
+ ],
+ drawings: [],
+ },
+ ]);
+
+ expect(manager.getPanes().has(0)).toBe(true);
+ expect(manager.getPanes().has(1)).toBe(false);
+ expect(manager.getPanes().has(2)).toBe(true);
+ expect(manager.getPanes().size).toBe(2);
+
+ manager.destroy();
+ });
+});
diff --git a/src/core/__tests__/ReactRenderer.test.ts b/src/core/__tests__/ReactRenderer.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..35441bfd06e6a4e19ca271588f2cae7b62bca071
--- /dev/null
+++ b/src/core/__tests__/ReactRenderer.test.ts
@@ -0,0 +1,144 @@
+import React from 'react';
+import { createRoot, Root } from 'react-dom/client';
+
+import { ReactRenderer } from '../ReactRenderer';
+
+jest.mock('react-dom/client', () => ({
+ createRoot: jest.fn(),
+}));
+
+describe('ReactRenderer', () => {
+ const createRootMock = createRoot as jest.MockedFunction<typeof createRoot>;
+
+ let container: HTMLDivElement;
+ let root: jest.Mocked<Pick<Root, 'render' | 'unmount'>>;
+ let requestAnimationFrameSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ container = document.createElement('div');
+
+ root = {
+ render: jest.fn(),
+ unmount: jest.fn(),
+ };
+
+ createRootMock.mockReturnValue(root as unknown as Root);
+
+ requestAnimationFrameSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => {
+ callback(0);
+
+ return 1;
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ requestAnimationFrameSpy.mockRestore();
+ });
+
+ it('не должен создавать React root до первого рендера', () => {
+ const renderer = new ReactRenderer(container);
+
+ expect(renderer.isInitialized()).toBe(false);
+ expect(createRootMock).not.toHaveBeenCalled();
+ });
+
+ it('должен создавать React root при первом рендере', () => {
+ const renderer = new ReactRenderer(container);
+ const component = React.createElement('div', null, 'content');
+
+ renderer.render(component);
+
+ expect(createRootMock).toHaveBeenCalledTimes(1);
+ expect(createRootMock).toHaveBeenCalledWith(container);
+ expect(root.render).toHaveBeenCalledWith(component);
+ expect(renderer.isInitialized()).toBe(true);
+ });
+
+ it('не должен повторно создавать React root при последующих рендерах', () => {
+ const renderer = new ReactRenderer(container);
+
+ const firstComponent = React.createElement('div', null, 'first');
+ const secondComponent = React.createElement('div', null, 'second');
+
+ renderer.render(firstComponent);
+ renderer.render(secondComponent);
+
+ expect(createRootMock).toHaveBeenCalledTimes(1);
+ expect(root.render).toHaveBeenCalledTimes(2);
+ expect(root.render).toHaveBeenNthCalledWith(1, firstComponent);
+ expect(root.render).toHaveBeenNthCalledWith(2, secondComponent);
+ });
+
+ it('должен делегировать renderComponent в render', () => {
+ const renderer = new ReactRenderer(container);
+ const component = React.createElement('div', null, 'content');
+
+ renderer.renderComponent(component);
+
+ expect(createRootMock).toHaveBeenCalledTimes(1);
+ expect(root.render).toHaveBeenCalledWith(component);
+ });
+
+ it('должен очищать React root', () => {
+ const renderer = new ReactRenderer(container);
+
+ renderer.render(React.createElement('div'));
+
+ expect(renderer.isInitialized()).toBe(true);
+
+ renderer.clear();
+
+ expect(renderer.isInitialized()).toBe(false);
+ expect(root.unmount).toHaveBeenCalledTimes(1);
+ });
+
+ it('не должен выполнять unmount если renderer ещё не инициализирован', () => {
+ const renderer = new ReactRenderer(container);
+
+ renderer.clear();
+
+ expect(root.unmount).not.toHaveBeenCalled();
+ expect(renderer.isInitialized()).toBe(false);
+ });
+
+ it('должен очищаться через clearComponents', () => {
+ const renderer = new ReactRenderer(container);
+
+ renderer.render(React.createElement('div'));
+ renderer.clearComponents();
+
+ expect(renderer.isInitialized()).toBe(false);
+ expect(root.unmount).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен очищаться при destroy', () => {
+ const renderer = new ReactRenderer(container);
+
+ renderer.render(React.createElement('div'));
+ renderer.destroy();
+
+ expect(renderer.isInitialized()).toBe(false);
+ expect(root.unmount).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен позволять повторную инициализацию после clear', () => {
+ const renderer = new ReactRenderer(container);
+
+ renderer.render(React.createElement('div', null, 'first'));
+ renderer.clear();
+
+ const secondRoot = {
+ render: jest.fn(),
+ unmount: jest.fn(),
+ };
+
+ createRootMock.mockReturnValue(secondRoot as unknown as Root);
+
+ renderer.render(React.createElement('div', null, 'second'));
+
+ expect(createRootMock).toHaveBeenCalledTimes(2);
+ expect(secondRoot.render).toHaveBeenCalledTimes(1);
+ expect(renderer.isInitialized()).toBe(true);
+ });
+});
diff --git a/src/core/__tests__/TimescaleHoverController.test.ts b/src/core/__tests__/TimescaleHoverController.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0737823d99bbe793f99674f46342d93b8a457b9d
--- /dev/null
+++ b/src/core/__tests__/TimescaleHoverController.test.ts
@@ -0,0 +1,81 @@
+import { EventManager } from '@core/EventManager';
+import { TimeScaleHoverController } from '@core/TimescaleHoverController';
+
+describe('TimeScaleHoverController', () => {
+ let chartContainer: HTMLDivElement;
+ let controlBarContainer: HTMLDivElement;
+ let eventManager: jest.Mocked<Pick<EventManager, 'setControlBarVisible'>>;
+
+ beforeEach(() => {
+ chartContainer = document.createElement('div');
+ controlBarContainer = document.createElement('div');
+
+ jest.spyOn(chartContainer, 'getBoundingClientRect').mockReturnValue({
+ x: 0,
+ y: 0,
+ top: 0,
+ left: 0,
+ right: 1000,
+ bottom: 500,
+ width: 1000,
+ height: 500,
+ toJSON: jest.fn(),
+ });
+
+ eventManager = {
+ setControlBarVisible: jest.fn(),
+ };
+ });
+
+ it('должен показывать ControlBar при наведении на нижнюю центральную область', () => {
+ const controller = new TimeScaleHoverController({
+ eventManager: eventManager as unknown as EventManager,
+ chartContainer,
+ controlBarContainer,
+ });
+
+ chartContainer.dispatchEvent(
+ new MouseEvent('mousemove', {
+ clientX: 500,
+ clientY: 480,
+ }),
+ );
+
+ expect(eventManager.setControlBarVisible).toHaveBeenLastCalledWith(true);
+
+ controller.destroy();
+ });
+
+ it('должен скрывать ControlBar вне активной области', () => {
+ const controller = new TimeScaleHoverController({
+ eventManager: eventManager as unknown as EventManager,
+ chartContainer,
+ controlBarContainer,
+ });
+
+ chartContainer.dispatchEvent(
+ new MouseEvent('mousemove', {
+ clientX: 100,
+ clientY: 100,
+ }),
+ );
+
+ expect(eventManager.setControlBarVisible).toHaveBeenLastCalledWith(false);
+
+ controller.destroy();
+ });
+
+ it('должен показывать ControlBar при наведении на него', () => {
+ const controller = new TimeScaleHoverController({
+ eventManager: eventManager as unknown as EventManager,
+ chartContainer,
+ controlBarContainer,
+ });
+
+ controlBarContainer.dispatchEvent(new MouseEvent('mouseenter'));
+
+ expect(eventManager.setControlBarVisible).toHaveBeenLastCalledWith(true);
+
+ controller.destroy();
+ });
+});
diff --git a/src/core/styles.module.scss b/src/core/styles.module.scss
deleted file mode 100644
index 685cbe25d7b51cc64a0c94cc11893cdca7ae91f1..0000000000000000000000000000000000000000
--- a/src/core/styles.module.scss
+++ /dev/null
@@ -1,14 +0,0 @@
-@use '../theme/mixins' as m;
-
-.scrollableBox {
- @include m.scrollbar;
-}
-
-.safariFullscreen {
- position: fixed;
- top: 0;
- left: 0;
- width: 100vw;
- height: 100vh;
- z-index: 9999;
-}
diff --git a/src/styles/global.scss b/src/styles/global.scss
index ac4bd31a770cfd0c8cee0c490fbe2e9afa8fb0ae..1d8f87357a7d5fab4272ce9e144d4735fc7bee13 100644
--- a/src/styles/global.scss
+++ b/src/styles/global.scss
@@ -1,3 +1,4 @@
+@use '../theme/mixins' as m;
@use './preflight';
.moex-chart-root {
@@ -41,3 +42,34 @@
}
}
}
+
+.moex-chart-pane-empty-state-container {
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-1000);
+ font-size: var(--space-0875);
+ color: var(--neutral-12);
+ text-align: center;
+ pointer-events: none;
+
+ &[hidden] {
+ display: none;
+ }
+}
+
+.moex-chart-fullscreen-safari {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ z-index: 9999;
+}
+
+.moex-chart-scrollable {
+ @include m.scrollbar;
+}
diff --git a/src/translations/russianDict.ts b/src/translations/russianDict.ts
index df1e548e509953a6239008a4bc75897e75aedcac..1aca52bcf98e528d205e53fef7dd0edbc42e3eb6 100644
--- a/src/translations/russianDict.ts
+++ b/src/translations/russianDict.ts
@@ -222,4 +222,6 @@ export const russian = {
'1М': '1M',
'3М': '3M',
+
+ 'Add a main instrument to display indicators': 'Добавьте основной инструмент для отображения индикаторов',
};
diff --git a/src/types/snapshot.ts b/src/types/snapshot.ts
index 93ee59bb280a0b6c45af8973cee1301b298c8f89..1da50d49e541b0cea7e6544aaffdb0433597fab6 100644
--- a/src/types/snapshot.ts
+++ b/src/types/snapshot.ts
@@ -1,7 +1,7 @@
import { DrawingsManagerSnapshot } from '@core/DrawingsManager';
import { ChartSeriesType, DateFormat, IndicatorsIds, Intervals, Timeframes } from '@lib';
import { Direction } from '@src/types/chart';
-import { SymbolInfo, SymbolInfoInput } from '@src/types/symbol';
+import { MainSymbolSnapshotInput, SymbolInfo, SymbolInfoInput } from '@src/types/symbol';
import { TimeFormat } from '@src/types/timeScale';
import { SettingsValues } from './settings';
@@ -14,7 +14,7 @@ export interface ISerializable<T extends object> {
getSnapshot: () => T;
}
-export interface InitialSnapshot extends SymbolInfoInput {
+export interface InitialSnapshot extends MainSymbolSnapshotInput {
timeframe: Timeframes; // todo: move to snap
chartSeriesType: ChartSeriesType; // todo: move to snap
}
@@ -38,7 +38,7 @@ interface ChartSnapshotBase {
panes: PaneSnapshot[];
}
-export interface ChartSnapshotInput extends ChartSnapshotBase, SymbolInfoInput {}
+export interface ChartSnapshotInput extends ChartSnapshotBase, MainSymbolSnapshotInput {}
export interface ChartSnapshot extends ChartSnapshotBase, SymbolInfo {}
export interface PriceScaleSnapshot {
diff --git a/src/types/symbol.ts b/src/types/symbol.ts
index a79793447aea2198711e265c7c76975c4a7fce62..92a0bcd3df7a147562be8621bbc954f0235fb9a4 100644
--- a/src/types/symbol.ts
+++ b/src/types/symbol.ts
@@ -9,3 +9,7 @@ export interface SymbolInfoInput {
symbol?: string;
symbolName?: string;
}
+
+export interface MainSymbolSnapshotInput extends Omit<SymbolInfoInput, 'symbolId'> {
+ symbolId: string | null;
+}