Загрузка данных
diff --git a/src/store/selectors/__tests__/widgets.test.ts b/src/store/selectors/__tests__/widgets.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2dc4eb291956ae050b428a8a468fb4a22520b3e6
--- /dev/null
+++ b/src/store/selectors/__tests__/widgets.test.ts
@@ -0,0 +1,31 @@
+import { RootState } from '@store/setupStore';
+
+import { masterWidgetSelector, widgetByIdSelector } from '../widgets';
+
+const createState = (widgets: { id: number; master?: number | null }[]) => ({ widgets: { widgets } }) as unknown as RootState;
+
+describe('widgetByIdSelector', () => {
+ it('находит виджет по id', () => {
+ expect(widgetByIdSelector(2)(createState([{ id: 1 }, { id: 2 }]))).toEqual({ id: 2 });
+ });
+
+ it('возвращает undefined для неизвестного id', () => {
+ expect(widgetByIdSelector(3)(createState([{ id: 1 }]))).toBeUndefined();
+ });
+});
+
+describe('masterWidgetSelector', () => {
+ it('возвращает мастер-виджет слейва', () => {
+ const state = createState([{ id: 1 }, { id: 2, master: 1 }]);
+
+ expect(masterWidgetSelector(2)(state)).toEqual({ id: 1 });
+ });
+
+ it('возвращает undefined, если у виджета нет мастера', () => {
+ expect(masterWidgetSelector(2)(createState([{ id: 1 }, { id: 2, master: null }]))).toBeUndefined();
+ });
+
+ it('возвращает undefined, если виджет не найден', () => {
+ expect(masterWidgetSelector(3)(createState([{ id: 1 }]))).toBeUndefined();
+ });
+});
diff --git a/src/store/selectors/widgets.ts b/src/store/selectors/widgets.ts
index be376d2ec5aa37b622bb59c8778ec79b84eccadc..9b262a2daa0cb4a478a1a0c45bb7b52e04867cf9 100644
--- a/src/store/selectors/widgets.ts
+++ b/src/store/selectors/widgets.ts
@@ -18,6 +18,11 @@ export const createWidgetsSelectorByWorkspaceId = (workspaceId: number) =>
export const widgetByIdSelector = (id: number) => (state: RootState) =>
state.widgets.widgets.find((item) => item.id === id);
+export const masterWidgetSelector = (widgetId: number) => (state: RootState) => {
+ const masterId = widgetByIdSelector(widgetId)(state)?.master;
+ return masterId ? widgetByIdSelector(masterId)(state) : undefined;
+};
+
export const widgetsByWorkspaceIdAndTypeSelector = (workspaceId: number, type: WidgetContentType) =>
createSelector([widgetsSelector], (widgets) =>
widgets.filter((item) => item.workspaceId === workspaceId && item.type === type),
diff --git a/src/widgets/Chart/__tests__/MoexChart.test.tsx b/src/widgets/Chart/__tests__/MoexChart.test.tsx
index fb55dd842eade476e1edba1aec2178ae9a1b80a0..8fca4235ecdb310c14f5e481ecee4491e799f058 100644
--- a/src/widgets/Chart/__tests__/MoexChart.test.tsx
+++ b/src/widgets/Chart/__tests__/MoexChart.test.tsx
@@ -101,6 +101,25 @@ describe('MoexChart', () => {
});
});
+ it('передаёт набор серий сравнения в хук графика', () => {
+ const compareSymbols = [{ symbolId: 'NTBVTFC:NTBVCT:B', symbol: '(180, 190]' }];
+
+ render(
+ <MoexChartComponent
+ symbolInfo={symbolInfo}
+ compareSymbols={compareSymbols}
+ widgetId={42}
+ addInstrumentFromModal={mockAddInstrumentFromModal}
+ />,
+ );
+
+ expect(mockUseMoexChart).toHaveBeenCalledWith({
+ symbolInfo,
+ indicativeData: undefined,
+ compareSymbols,
+ });
+ });
+
it('should attach chart container ref', () => {
// Arrange & Act
renderComponent();
diff --git a/src/widgets/Chart/__tests__/getSnapshotCompareSymbolIds.test.ts b/src/widgets/Chart/__tests__/getSnapshotCompareSymbolIds.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3a3311f56c086afc0ad2cdf175c194a6e87dd4c3
--- /dev/null
+++ b/src/widgets/Chart/__tests__/getSnapshotCompareSymbolIds.test.ts
@@ -0,0 +1,25 @@
+import { getSnapshotCompareSymbolIds } from '../utils/getSnapshotCompareSymbolIds';
+
+import type { MoexChartSnapshotInput } from '@mx-charts/charts';
+
+type Indicator = MoexChartSnapshotInput['charts'][number]['panes'][number]['indicators'][number];
+
+const createSnapshot = (indicators: Indicator[]) => ({ charts: [{ panes: [{ indicators }] }] });
+
+const compareIndicator = (symbolId: string): Indicator =>
+ ({ symbolInfo: { symbolId }, seriesName: 'Line', scale: 'right' }) as unknown as Indicator;
+
+const volumeIndicator = { indicatorType: 'Volume' } as unknown as Indicator;
+
+describe('getSnapshotCompareSymbolIds', () => {
+ it('собирает symbolId серий сравнения из всех панелей', () => {
+ const snapshot = createSnapshot([volumeIndicator, compareIndicator('A'), compareIndicator('B')]);
+
+ expect(getSnapshotCompareSymbolIds(snapshot)).toEqual(['A', 'B']);
+ });
+
+ it('возвращает пустой список без серий сравнения', () => {
+ expect(getSnapshotCompareSymbolIds(createSnapshot([volumeIndicator]))).toEqual([]);
+ expect(getSnapshotCompareSymbolIds({ charts: [] })).toEqual([]);
+ });
+});
diff --git a/src/widgets/Chart/__tests__/isSameSymbolSet.test.ts b/src/widgets/Chart/__tests__/isSameSymbolSet.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d167398ac8f772172d5a53da2b5e4af0c4dfc617
--- /dev/null
+++ b/src/widgets/Chart/__tests__/isSameSymbolSet.test.ts
@@ -0,0 +1,19 @@
+import { isSameSymbolSet } from '../utils/isSameSymbolSet';
+
+describe('isSameSymbolSet', () => {
+ it('считает одинаковыми наборы с теми же символами в другом порядке', () => {
+ expect(isSameSymbolSet(['A', 'B'], ['B', 'A'])).toBe(true);
+ });
+
+ it('считает пустые наборы одинаковыми', () => {
+ expect(isSameSymbolSet([], [])).toBe(true);
+ });
+
+ it('различает наборы разной длины', () => {
+ expect(isSameSymbolSet(['A'], ['A', 'B'])).toBe(false);
+ });
+
+ it('различает наборы с разными символами', () => {
+ expect(isSameSymbolSet(['A', 'B'], ['A', 'C'])).toBe(false);
+ });
+});
diff --git a/src/widgets/Chart/__tests__/useChartComponentFacade.test.tsx b/src/widgets/Chart/__tests__/useChartComponentFacade.test.tsx
index fa0a746264403db63b5a68741bbba554510719b3..58414bd973be26a594ef9f8856333c75011a550a 100644
--- a/src/widgets/Chart/__tests__/useChartComponentFacade.test.tsx
+++ b/src/widgets/Chart/__tests__/useChartComponentFacade.test.tsx
@@ -158,6 +158,19 @@ describe('useChartComponentFacade', () => {
});
});
+ it('прокидывает набор серий сравнения из контекста привязки', () => {
+ const compareSymbols = [{ symbolId: 'NTBVTFC:NTBVCT:B', symbol: '(180, 190]' }];
+
+ mockUseChartPublicContext.mockReturnValue({
+ issKey: undefined,
+ compareSymbols,
+ });
+
+ const { result } = renderFacade();
+
+ expect(result.current.compareSymbols).toBe(compareSymbols);
+ });
+
it('should update and save instrument selected from modal', () => {
// Arrange
const { result } = renderFacade();
diff --git a/src/widgets/Chart/__tests__/useChartPublicContext.test.tsx b/src/widgets/Chart/__tests__/useChartPublicContext.test.tsx
index 2b9711f0c62a608eb2112bcac73e7e776d8a4b2e..ae2701ec8356002a2db6af84fb7034ae3353c055 100644
--- a/src/widgets/Chart/__tests__/useChartPublicContext.test.tsx
+++ b/src/widgets/Chart/__tests__/useChartPublicContext.test.tsx
@@ -87,6 +87,10 @@ describe('useChartPublicContext', () => {
// Mock the useAppSelect to return our test data
mockUseAppSelect.mockImplementation((selector) => {
+ if (selector.toString().includes('selectedChartSymbols')) {
+ return undefined;
+ }
+
if (selector.toString().includes('publicContext')) {
return mockPublicContext;
}
@@ -257,6 +261,81 @@ describe('useChartPublicContext', () => {
});
});
+ it('при привязке берёт имена основного инструмента из набора мастера, а не из справочника', () => {
+ const selectedChartSymbols = [
+ { symbolId: 'MOEX:TEST1', symbol: '≤ 10000', symbolName: 'Ячмень, ≤ 10000' },
+ { symbolId: 'MOEX:TEST2', symbol: '(10000, 20000]', symbolName: 'Ячмень, (10000, 20000]' },
+ ];
+ const mockSetCurrInstrument = jest.fn();
+ mockUseAppSelect.mockImplementation((selector) => {
+ if (selector.toString().includes('selectedChartSymbols')) {
+ return selectedChartSymbols;
+ }
+ if (selector.toString().includes('publicContext')) {
+ return mockPublicContext;
+ }
+ return mockWidget;
+ });
+ renderHook(() =>
+ useChartPublicContext({
+ widgetId: 1,
+ setCurrInstrument: mockSetCurrInstrument,
+ getMasterInstrumentFromPublicContext: jest.fn().mockReturnValue('MOEX:TEST1'),
+ }),
+ );
+ expect(mockSetCurrInstrument).toHaveBeenCalledWith({
+ symbolId: 'MOEX:TEST1',
+ symbol: '≤ 10000',
+ symbolName: 'Ячмень, ≤ 10000',
+ });
+ });
+
+ it('отдаёт набор символов графика из properties мастера', () => {
+ const selectedChartSymbols = [{ symbolId: 'NTBVTFC:NTBVCT:B', symbol: '(180, 190]' }];
+
+ mockUseAppSelect.mockImplementation((selector) => {
+ if (selector.toString().includes('selectedChartSymbols')) {
+ return selectedChartSymbols;
+ }
+
+ if (selector.toString().includes('publicContext')) {
+ return mockPublicContext;
+ }
+
+ return mockWidget;
+ });
+
+ const { result } = renderHook(() =>
+ useChartPublicContext({
+ widgetId: 1,
+ setCurrInstrument: jest.fn(),
+ getMasterInstrumentFromPublicContext: jest.fn().mockReturnValue('MOEX:TEST1'),
+ }),
+ );
+
+ expect(result.current.compareSymbols).toEqual(selectedChartSymbols);
+ });
+
+ it('не отдаёт набор символов графика без мастера', () => {
+ mockUseAppSelect.mockImplementation((selector) => {
+ if (selector.toString().includes('publicContext')) {
+ return mockPublicContext;
+ }
+
+ return { ...mockWidget, master: undefined };
+ });
+
+ const { result } = renderHook(() =>
+ useChartPublicContext({
+ widgetId: 1,
+ setCurrInstrument: jest.fn(),
+ getMasterInstrumentFromPublicContext: jest.fn().mockReturnValue('MOEX:TEST1'),
+ }),
+ );
+
+ expect(result.current.compareSymbols).toBeUndefined();
+ });
+
it('should not set instrument when contracts array is empty', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
diff --git a/src/widgets/Chart/__tests__/useMoexchart.test.tsx b/src/widgets/Chart/__tests__/useMoexchart.test.tsx
index 06dbdd6fa3e39067b05585bd6fb5065deaede1a7..8f9dd2dca562878331563db85014ba1164428d33 100644
--- a/src/widgets/Chart/__tests__/useMoexchart.test.tsx
+++ b/src/widgets/Chart/__tests__/useMoexchart.test.tsx
@@ -18,6 +18,9 @@ jest.mock('../components/MoexChart/dataSourceProvide', () => ({
jest.mock('@mx-charts/charts', () => ({
MoexChart: jest.fn(),
+ CompareMode: {
+ Absolute: 'ABS',
+ },
Timeframes: {
'10s': '10s',
'1m': '1m',
@@ -126,6 +129,7 @@ type TimeframeChangeCallback = (timeframe: TimeframeValue) => void;
interface TestComponentProps {
symbolInfo?: SymbolInfoInput;
indicativeData?: ChartIndicativeData;
+ compareSymbols?: SymbolInfoInput[];
}
const defaultSymbolInfo: SymbolInfoInput = {
@@ -153,10 +157,22 @@ describe('useMoexChart', () => {
const mockGetDataSource = jest.fn();
const mockRealtimeUnsubscribe = jest.fn();
+ const mockCompareClear = jest.fn();
+ const mockCompareSetSymbolMode = jest.fn();
+ const mockCompareGetAllEntities = jest.fn();
+
const mockCompareManager = {
id: 'compare-manager',
+ clear: mockCompareClear,
+ setSymbolMode: mockCompareSetSymbolMode,
+ getAllEntities: mockCompareGetAllEntities,
};
+ const compareSymbols: SymbolInfoInput[] = [
+ { symbolId: 'NTBVTFC:NTBVCT:A', symbol: '≤ 180', symbolName: 'Пшеница, ≤ 180' },
+ { symbolId: 'NTBVTFC:NTBVCT:B', symbol: '(180, 190]', symbolName: 'Пшеница, (180, 190]' },
+ ];
+
const mockSnapshot: MockChartSnapshot = {
charts: [
{
@@ -181,10 +197,12 @@ describe('useMoexChart', () => {
const TestComponent = ({
symbolInfo = defaultSymbolInfo,
indicativeData,
+ compareSymbols: compareSymbolsProp,
}: TestComponentProps): React.ReactElement => {
hookResult = useMoexChart({
symbolInfo,
indicativeData,
+ compareSymbols: compareSymbolsProp,
});
return <div ref={hookResult.containerRef} />;
@@ -229,6 +247,7 @@ describe('useMoexChart', () => {
mockGetSnapshot.mockReturnValue(mockSnapshot);
mockGetCompareManager.mockReturnValue(mockCompareManager);
+ mockCompareGetAllEntities.mockReturnValue([]);
mockMoexChart.mockImplementation((config: MockMoexChartConfig) => {
lastMoexChartConfig = config;
@@ -875,6 +894,96 @@ describe('useMoexChart', () => {
expect(mockState.moexChartState?.initialInterval).toBeUndefined();
});
+ it('не трогает серии сравнения без набора от мастера', () => {
+ render(<TestComponent />);
+
+ expect(mockCompareClear).not.toHaveBeenCalled();
+ expect(mockCompareSetSymbolMode).not.toHaveBeenCalled();
+ });
+
+ it('применяет набор мастера после создания графика, пропуская основной инструмент', () => {
+ const symbolInfo: SymbolInfoInput = { symbolId: 'NTBVTFC:NTBVCT:A', symbol: '≤ 180' };
+
+ render(
+ <TestComponent
+ symbolInfo={symbolInfo}
+ compareSymbols={compareSymbols}
+ />,
+ );
+
+ expect(mockCompareClear).toHaveBeenCalledTimes(1);
+ expect(mockCompareSetSymbolMode).toHaveBeenCalledTimes(1);
+ expect(mockCompareSetSymbolMode).toHaveBeenCalledWith('Line', compareSymbols[1], 'ABS');
+ });
+
+ it('не пересобирает серии, если на графике уже тот же набор', () => {
+ mockCompareGetAllEntities.mockReturnValue([
+ { symbolId: 'NTBVTFC:NTBVCT:B' },
+ { symbolId: 'NTBVTFC:NTBVCT:A' },
+ ]);
+
+ render(<TestComponent compareSymbols={compareSymbols} />);
+
+ expect(mockCompareClear).not.toHaveBeenCalled();
+ expect(mockCompareSetSymbolMode).not.toHaveBeenCalled();
+ });
+
+ it('перестраивает серии при смене набора мастера', () => {
+ const { rerender } = render(<TestComponent compareSymbols={compareSymbols} />);
+
+ expect(mockCompareSetSymbolMode).toHaveBeenCalledTimes(2);
+
+ const nextSymbols: SymbolInfoInput[] = [{ symbolId: 'NTBVTFC:NTBVCT:C', symbol: '(190, 200]' }];
+
+ rerender(<TestComponent compareSymbols={nextSymbols} />);
+
+ expect(mockCompareClear).toHaveBeenCalledTimes(2);
+ expect(mockCompareSetSymbolMode).toHaveBeenCalledTimes(3);
+ expect(mockCompareSetSymbolMode).toHaveBeenLastCalledWith('Line', nextSymbols[0], 'ABS');
+ });
+
+ it('снимает серии мастера при отвязке графика', () => {
+ const { rerender } = render(<TestComponent compareSymbols={compareSymbols} />);
+
+ expect(mockCompareClear).toHaveBeenCalledTimes(1);
+
+ rerender(<TestComponent />);
+
+ expect(mockCompareClear).toHaveBeenCalledTimes(2);
+ expect(mockCompareSetSymbolMode).toHaveBeenCalledTimes(2);
+ });
+
+ it('не снимает серии без мастера, если сам их не добавлял', () => {
+ const { rerender } = render(<TestComponent />);
+
+ rerender(<TestComponent symbolInfo={{ symbolId: 'MOEX:GAZP' }} />);
+
+ expect(mockCompareClear).not.toHaveBeenCalled();
+ });
+
+ it('не пересобирает серии, восстановленные из сохранённого снапшота', () => {
+ mockMoexChartState = {
+ timeframe: Timeframes['1d'],
+ savedData: JSON.stringify({
+ charts: [
+ {
+ ...mockSnapshot.charts[0],
+ panes: [
+ {
+ indicators: compareSymbols.map((symbolInfo) => ({ symbolInfo, seriesName: 'Line', scale: 'right' })),
+ },
+ ],
+ },
+ ],
+ }),
+ };
+
+ render(<TestComponent compareSymbols={compareSymbols} />);
+
+ expect(mockCompareClear).not.toHaveBeenCalled();
+ expect(mockCompareSetSymbolMode).not.toHaveBeenCalled();
+ });
+
it('should destroy chart on unmount', () => {
const { unmount } = render(<TestComponent />);
diff --git a/src/widgets/Chart/components/MoexChart/MoexChart.tsx b/src/widgets/Chart/components/MoexChart/MoexChart.tsx
index 1d939d059543be4da49736fb48e064ef363fee26..6adf91254a6a8abf34beef480508e9d090d03034 100644
--- a/src/widgets/Chart/components/MoexChart/MoexChart.tsx
+++ b/src/widgets/Chart/components/MoexChart/MoexChart.tsx
@@ -5,7 +5,7 @@ import { SymbolSearchModal } from '@widgets/Chart/components/MoexChart/component
import { CompareModal } from './components/CompareModal';
import { useMoexChart } from './hooks';
-import type { ChartIndicativeData, SelectedInstrument } from '../../types';
+import type { ChartCompareSymbol, ChartIndicativeData, SelectedInstrument } from '../../types';
import type { SymbolInfoInput } from '@mx-charts/charts';
@@ -14,11 +14,12 @@ import '@mx-charts/charts/dist/styles.css';
interface TRProps {
symbolInfo: SymbolInfoInput;
indicativeData?: ChartIndicativeData;
+ compareSymbols?: ChartCompareSymbol[];
widgetId: number;
addInstrumentFromModal: (instruments: SelectedInstrument[]) => void;
}
-function MoexChart({ symbolInfo, indicativeData, widgetId, addInstrumentFromModal }: TRProps) {
+function MoexChart({ symbolInfo, indicativeData, compareSymbols, widgetId, addInstrumentFromModal }: TRProps) {
const {
containerRef,
isCompareOpen,
@@ -29,6 +30,7 @@ function MoexChart({ symbolInfo, indicativeData, widgetId, addInstrumentFromModa
} = useMoexChart({
symbolInfo,
indicativeData,
+ compareSymbols,
});
const handleSymbolChange = (instrument: SelectedInstrument): void => {
diff --git a/src/widgets/Chart/components/MoexChart/hooks/useMoexchart.ts b/src/widgets/Chart/components/MoexChart/hooks/useMoexchart.ts
index f39b336ed4d643f205362a03c79ef2d136ad2c54..fd04e4e30ab85884c5ea1b53249b17b6600ccd53 100644
--- a/src/widgets/Chart/components/MoexChart/hooks/useMoexchart.ts
+++ b/src/widgets/Chart/components/MoexChart/hooks/useMoexchart.ts
@@ -1,27 +1,33 @@
-import { Intervals, MoexChart, Timeframes } from '@mx-charts/charts';
+import { CompareMode, Intervals, MoexChart, Timeframes } from '@mx-charts/charts';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useChangeProperties, useSelectProperties } from '@modules/widgetProperties';
import { WidgetProperties } from '@widgets/Chart/properties/types';
import { ChartIndicativeData } from '@widgets/Chart/types';
+import { getSnapshotCompareSymbolIds } from '../../../utils/getSnapshotCompareSymbolIds';
+import { isSameSymbolSet } from '../../../utils/isSameSymbolSet';
import { MOEX_CHART_CONFIG } from '../constants';
import { DataSourceProvider } from '../dataSourceProvide';
+import type { ChartCompareSymbol } from '../../../types';
import type { __CompareManager__, ChartSeriesType, IMoexChart, SymbolInfoInput } from '@mx-charts/charts';
interface TUseMoexChartProps {
symbolInfo: SymbolInfoInput;
indicativeData?: ChartIndicativeData;
+ compareSymbols?: ChartCompareSymbol[];
}
+const COMPARE_SERIES_TYPE: ChartSeriesType = 'Line';
+
interface SnapshotSettings {
timeframe: Timeframes;
interval?: Intervals;
chartSeriesType?: ChartSeriesType;
}
-export const useMoexChart = ({ symbolInfo, indicativeData }: TUseMoexChartProps) => {
+export const useMoexChart = ({ symbolInfo, indicativeData, compareSymbols }: TUseMoexChartProps) => {
const moexChartState = useSelectProperties(
(widgetProperties: Partial<WidgetProperties>) => widgetProperties.moexChartState,
);
@@ -41,6 +47,8 @@ export const useMoexChart = ({ symbolInfo, indicativeData }: TUseMoexChartProps)
const initialIntervalRef = useRef<Intervals | undefined>(moexChartState?.initialInterval);
const initialChartSeriesTypeRef = useRef<ChartSeriesType | undefined>(moexChartState?.initialChartSeriesType);
const updateTimeframeRef = useRef<((timeframe: Timeframes) => void) | null>(null);
+ const compareSymbolsRef = useRef<ChartCompareSymbol[] | undefined>(compareSymbols);
+ const compareAppliedRef = useRef(false);
useEffect(() => {
const currentSymbolInfo = currentSymbolInfoRef.current;
@@ -77,6 +85,47 @@ export const useMoexChart = ({ symbolInfo, indicativeData }: TUseMoexChartProps)
});
};
+ const applyCompareSymbols = useCallback((restoredSymbolIds?: string[]): void => {
+ const manager = compareManagerRef.current;
+ const symbols = compareSymbolsRef.current;
+
+ if (!manager) {
+ return;
+ }
+
+ if (!symbols) {
+ if (compareAppliedRef.current) {
+ manager.clear();
+ compareAppliedRef.current = false;
+ }
+
+ return;
+ }
+
+ const mainSymbolId = currentSymbolInfoRef.current.symbolId;
+ const nextSymbols = symbols.filter((symbol) => symbol.symbolId !== mainSymbolId);
+ const currentSymbolIds = restoredSymbolIds ?? manager.getAllEntities().map((entity) => entity.symbolId);
+
+ compareAppliedRef.current = true;
+
+ if (isSameSymbolSet(currentSymbolIds, nextSymbols.map((symbol) => symbol.symbolId))) {
+ return;
+ }
+
+ manager.clear();
+
+ Promise.all(
+ nextSymbols.map((symbol) => manager.setSymbolMode(COMPARE_SERIES_TYPE, symbol, CompareMode.Absolute)),
+ ).catch((error) => {
+ console.warn(error);
+ });
+ }, []);
+
+ useEffect(() => {
+ compareSymbolsRef.current = compareSymbols;
+ applyCompareSymbols();
+ }, [applyCompareSymbols, compareSymbols, symbolInfo.symbolId]);
+
const saveSnapshot = useCallback((): void => {
const snapshot = chartRef.current?.getSnapshot();
@@ -171,6 +220,7 @@ export const useMoexChart = ({ symbolInfo, indicativeData }: TUseMoexChartProps)
chartRef.current = chart;
compareManagerRef.current = chart.getCompareManager();
+ applyCompareSymbols(savedDataRef.current ? getSnapshotCompareSymbolIds(savedSnapshot) : undefined);
if (initialInterval || initialChartSeriesType) {
initialIntervalRef.current = undefined;
diff --git a/src/widgets/Chart/hooks/useChartComponentFacade.tsx b/src/widgets/Chart/hooks/useChartComponentFacade.tsx
index 4213d5932b3cf8d3316e48d68bad0e62e88a3a63..75ce158f00b6f4532425c83b2cd4b30f8036c12a 100644
--- a/src/widgets/Chart/hooks/useChartComponentFacade.tsx
+++ b/src/widgets/Chart/hooks/useChartComponentFacade.tsx
@@ -14,7 +14,7 @@ import { DEFAULT_SYMBOL } from '../const';
import { useChartPublicContext } from './useChartPublicContext';
import type { WidgetProperties } from '../properties/types';
-import type { ChartContainerProps, SelectedInstrument } from '../types';
+import type { ChartCompareSymbol, ChartContainerProps, SelectedInstrument } from '../types';
import type { SymbolInfoInput } from '@mx-charts/charts';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
@@ -29,6 +29,7 @@ interface UseChartComponentFacadeReturn {
onDropInstrument: (symbolId: string, withUpdate?: boolean) => void;
addInstrumentFromModal: (instruments: SelectedInstrument[]) => void;
isOver: boolean;
+ compareSymbols: ChartCompareSymbol[] | undefined;
}
type SaveContentPropsOptions = Partial<WidgetProperties['chartState']> & {
@@ -218,7 +219,7 @@ export default function useChartComponentFacade(props: ChartContainerProps): Use
widgetId
})
- useChartPublicContext({
+ const { compareSymbols } = useChartPublicContext({
setCurrInstrument: onInstrumentChangeFromBind,
widgetId,
getMasterInstrumentFromPublicContext,
@@ -270,5 +271,6 @@ export default function useChartComponentFacade(props: ChartContainerProps): Use
onDropInstrument,
addInstrumentFromModal,
isOver,
+ compareSymbols,
};
}
diff --git a/src/widgets/Chart/hooks/useChartPublicContext.ts b/src/widgets/Chart/hooks/useChartPublicContext.ts
index a50be700ce7b824360688d648ff5f1a792a9b15c..c1fa80252ab5df671306d9c3e217ac12e3e5c514 100644
--- a/src/widgets/Chart/hooks/useChartPublicContext.ts
+++ b/src/widgets/Chart/hooks/useChartPublicContext.ts
@@ -3,10 +3,12 @@ import { useEffect, useMemo } from 'react';
import { useAppSelect } from '@hooks/useAppSelector';
import { Contract, useContracts } from '@modules/contracts';
+import { masterWidgetSelector } from '@store/selectors/widgets';
import { filterByUniqIssKey } from '@utils/filterByUniqIssKey';
import { DEFAULT_SYMBOL } from '@widgets/Chart/const';
import { Widget } from 'types/Widgets';
+import type { ChartCompareSymbol } from '../types';
import type { SymbolInfoInput } from '@mx-charts/charts';
interface UseChartPublicContextArg {
@@ -17,6 +19,7 @@ interface UseChartPublicContextArg {
interface UseChartPublicContextReturn {
issKey: Contract['issKey'] | undefined;
+ compareSymbols: ChartCompareSymbol[] | undefined;
}
export function useChartPublicContext({
@@ -27,6 +30,13 @@ export function useChartPublicContext({
const publicContext = useAppSelect((state) => state.publicContext.publicContext);
const widget = useAppSelect((state) => state.widgets.widgets.find(({ id }) => id === widgetId));
+ const compareSymbols = useAppSelect(
+ (state) =>
+ masterWidgetSelector(widgetId)(state)?.widgetContentProps?.selectedChartSymbols as
+ | ChartCompareSymbol[]
+ | undefined,
+ );
+
const { contracts } = useContracts();
const instruments = useMemo(() => (contracts ? filterByUniqIssKey(contracts, ['issKey']) : []), [contracts]);
@@ -53,15 +63,18 @@ export function useChartPublicContext({
return;
}
+ const masterSymbol = compareSymbols?.find((symbol) => symbol.symbolId === instrument.issKey);
+
setCurrInstrument({
symbolId: instrument.issKey,
- symbol: instrument.symbol ?? undefined,
- symbolName: instrument.displayName ?? undefined,
+ symbol: masterSymbol?.symbol ?? instrument.symbol ?? undefined,
+ symbolName: masterSymbol?.symbolName ?? instrument.displayName ?? undefined,
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- Посмотреть этот момент.
- }, [publicContext, instruments, widget?.externalProperties, setCurrInstrument]);
+ }, [publicContext, instruments, widget?.externalProperties, compareSymbols, setCurrInstrument]);
return {
issKey,
+ compareSymbols: widget?.master ? compareSymbols : undefined,
};
}
diff --git a/src/widgets/Chart/index.tsx b/src/widgets/Chart/index.tsx
index f015ae64ac84d6241fc033f3ce692336eef2e73b..0f959170002538736a8f9d9b831c2c0036864480 100644
--- a/src/widgets/Chart/index.tsx
+++ b/src/widgets/Chart/index.tsx
@@ -28,6 +28,7 @@ export const Chart: FC<ChartContainerProps> = function (props): JSX.Element {
onDropInstrument,
addInstrumentFromModal,
isOver,
+ compareSymbols,
} = useChartComponentFacade(props);
/* на дроп обновляем название бумаги в сторе
@@ -86,6 +87,7 @@ export const Chart: FC<ChartContainerProps> = function (props): JSX.Element {
<MoexChart
symbolInfo={symbolInfo}
indicativeData={indicativeData}
+ compareSymbols={compareSymbols}
widgetId={props.widgetId}
addInstrumentFromModal={addInstrumentFromModal}
/>
diff --git a/src/widgets/Chart/types.ts b/src/widgets/Chart/types.ts
index 2a4a5aae338ec7f621e1ed9d9f14435fa8fdc1f8..b5b9f4a66f83230c884ddc1004228a659b792ad1 100644
--- a/src/widgets/Chart/types.ts
+++ b/src/widgets/Chart/types.ts
@@ -19,4 +19,10 @@ export interface SelectedInstrument {
symbol: Contract['symbol'];
}
+export interface ChartCompareSymbol {
+ symbolId: string;
+ symbol?: string;
+ symbolName?: string;
+}
+
export type ChartContainerProps = WidgetContentBasicProps<WidgetProperties>;
diff --git a/src/widgets/Chart/utils/getSnapshotCompareSymbolIds.ts b/src/widgets/Chart/utils/getSnapshotCompareSymbolIds.ts
new file mode 100644
index 0000000000000000000000000000000000000000..843fd2cb6b2bca8f40a65e6a598ddd1acded8231
--- /dev/null
+++ b/src/widgets/Chart/utils/getSnapshotCompareSymbolIds.ts
@@ -0,0 +1,14 @@
+import type { MoexChartSnapshotInput } from '@mx-charts/charts';
+
+type SnapshotIndicators = MoexChartSnapshotInput['charts'][number]['panes'][number]['indicators'];
+
+type SnapshotWithIndicators = {
+ charts: { panes: { indicators: SnapshotIndicators }[] }[];
+};
+
+export const getSnapshotCompareSymbolIds = (snapshot: SnapshotWithIndicators): string[] =>
+ snapshot.charts.flatMap((chart) =>
+ chart.panes.flatMap((pane) =>
+ pane.indicators.flatMap((indicator) => ('symbolInfo' in indicator ? [indicator.symbolInfo.symbolId] : [])),
+ ),
+ );
diff --git a/src/widgets/Chart/utils/isSameSymbolSet.ts b/src/widgets/Chart/utils/isSameSymbolSet.ts
new file mode 100644
index 0000000000000000000000000000000000000000..371baae985aa57b0f81751f18d03ae9302ad0f9f
--- /dev/null
+++ b/src/widgets/Chart/utils/isSameSymbolSet.ts
@@ -0,0 +1,4 @@
+const toSortedKey = (symbolIds: string[]) => [...symbolIds].sort().join('\n');
+
+export const isSameSymbolSet = (current: string[], next: string[]): boolean =>
+ current.length === next.length && toSortedKey(current) === toSortedKey(next);
diff --git a/src/widgets/ntb/AnalyticsContracts/components/ContractsTable/ContractsTable.tsx b/src/widgets/ntb/AnalyticsContracts/components/ContractsTable/ContractsTable.tsx
index f030ca1f2c677d2074e5ab4e0602ca371ff6d891..018b1af9a1ea52300972a457d97d951debfb8508 100644
--- a/src/widgets/ntb/AnalyticsContracts/components/ContractsTable/ContractsTable.tsx
+++ b/src/widgets/ntb/AnalyticsContracts/components/ContractsTable/ContractsTable.tsx
@@ -18,6 +18,7 @@ import { useContracts } from './hooks/useContracts';
import { prepareContractsRows } from './utils/prepareContractsRows';
import type { ContractsTableDataItem } from './types';
+import type { WidgetProperties } from '../../properties/types';
import type { NTB_CONTRACTS_SOURCE_GROUP } from '@modules/ntb/types';
type ContractsTableProps = {
@@ -29,6 +30,8 @@ type ContractsTableProps = {
const getRowKey = (record: ContractsTableDataItem) => `${record.productName}:${record.range}`;
+const getChartSymbols = (record: ContractsTableDataItem) => record.chartSymbols;
+
export const ContractsTable: FC<ContractsTableProps> = ({
sourceGroup,
hidden,
@@ -43,7 +46,7 @@ export const ContractsTable: FC<ContractsTableProps> = ({
const columns = useMemo(() => buildColumns(contracts), [contracts]);
const dataSource = useMemo(() => prepareContractsRows(contracts), [contracts]);
- const { handleRowClick } = useHandleRowClick();
+ const { handleRowClick } = useHandleRowClick<ContractsTableDataItem, WidgetProperties>({ getChartSymbols });
const { onContextMenu, menuProps, items } = useContextMenu({ sourceGroup, onShowHistory });
diff --git a/src/widgets/ntb/AnalyticsContracts/components/ContractsTable/hooks/__tests__/useContextMenu.test.ts b/src/widgets/ntb/AnalyticsContracts/components/ContractsTable/hooks/__tests__/useContextMenu.test.ts
index e87b9e097d655753961fa9532597ffb8a8cdac61..ff6433383f69925625226f0d39a5269d6f8fcef0 100644
--- a/src/widgets/ntb/AnalyticsContracts/components/ContractsTable/hooks/__tests__/useContextMenu.test.ts
+++ b/src/widgets/ntb/AnalyticsContracts/components/ContractsTable/hooks/__tests__/useContextMenu.test.ts
@@ -1,25 +1,29 @@
import { act, renderHook } from '@testing-library/react';
import { NTB_CONTRACTS_SOURCE_GROUP } from '@modules/ntb/types';
-import { openNtbChart } from '@widgets/ntb/shared/services/openChart';
-
import { downloadHistory } from '../../../../utils/downloadHistory';
+import { openContractsChart } from '../../../../utils/openContractsChart';
import { useContextMenu } from '../useContextMenu';
import type { ContractsTableDataItem } from '../../types';
import type { ContextMenuItem } from '@uikit/ContextMenu/types';
import type { MouseEvent } from 'react';
-jest.mock('@widgets/ntb/shared/services/openChart', () => ({ openNtbChart: jest.fn() }));
jest.mock('../../../../utils/downloadHistory', () => ({ downloadHistory: jest.fn() }));
+jest.mock('../../../../utils/openContractsChart', () => ({ openContractsChart: jest.fn() }));
-const mockedOpenNtbChart = openNtbChart as jest.Mock;
+const mockedOpenContractsChart = openContractsChart as jest.Mock;
const mockedDownloadHistory = downloadHistory as jest.Mock;
const createRow = (partial: Partial<ContractsTableDataItem>): ContractsTableDataItem =>
partial as ContractsTableDataItem;
-const rowWithKey = createRow({ productName: 'Пшеница', issKey: 'NTBVTFC:NTBVCT:11A12C22T33Z3' });
+const chartSymbols = [
+ { symbolId: 'NTBVTFC:NTBVCT:11A12C22T33Z3', symbol: 'до 50', symbolName: 'Пшеница, до 50' },
+ { symbolId: 'NTBVTFC:NTBVCT:11A12C22T33Z4', symbol: '50-100', symbolName: 'Пшеница, 50-100' },
+];
+
+const rowWithKey = createRow({ productName: 'Пшеница', issKey: 'NTBVTFC:NTBVCT:11A12C22T33Z3', chartSymbols });
const createContextMenuEvent = () =>
({
@@ -48,14 +52,14 @@ describe('useContextMenu', () => {
jest.clearAllMocks();
});
- it('открывает график по issKey строки', () => {
+ it('открывает график по набору символов строки', () => {
const result = renderContextMenu(rowWithKey);
act(() => {
findItem(result.current.items, 'openChart')?.onClick?.();
});
- expect(mockedOpenNtbChart).toHaveBeenCalledWith('NTBVTFC:NTBVCT:11A12C22T33Z3');
+ expect(mockedOpenContractsChart).toHaveBeenCalledWith(chartSymbols);
});
it('не показывает пункт графика для строки без issKey', () => {
diff --git a/src/widgets/ntb/AnalyticsContracts/components/ContractsTable/hooks/useContextMenu.ts b/src/widgets/ntb/AnalyticsContracts/components/ContractsTable/hooks/useContextMenu.ts
index 8417c1de32c034cb27e06fb972d8d3d312479b31..3a46a941d6be6d13226483a809b3335e3b4dbc2d 100644
--- a/src/widgets/ntb/AnalyticsContracts/components/ContractsTable/hooks/useContextMenu.ts
+++ b/src/widgets/ntb/AnalyticsContracts/components/ContractsTable/hooks/useContextMenu.ts
@@ -1,7 +1,7 @@
import { useContextMenuOverlay } from '@uikit/ContextMenuOverlay';
-import { openNtbChart } from '@widgets/ntb/shared/services/openChart';
import { downloadHistory } from '../../../utils/downloadHistory';
+import { openContractsChart } from '../../../utils/openContractsChart';
import type { ContractsTableDataItem } from '../types';
import type { NTB_CONTRACTS_SOURCE_GROUP } from '@modules/ntb/types';
@@ -23,7 +23,13 @@ export const useContextMenu = ({ sourceGroup, onShowHistory }: UseContextMenuPro
const items: ContextMenuItem[] = [
...(row?.issKey
- ? [{ key: 'openChart', label: 'Отобразить в виджете График', onClick: () => openNtbChart(row.issKey) }]
+ ? [
+ {
+ key: 'openChart',
+ label: 'Отобразить в виджете График',
+ onClick: () => openContractsChart(row.chartSymbols),
+ },
+ ]
: []),
{
key: 'showHistory',
diff --git a/src/widgets/ntb/AnalyticsContracts/components/ContractsTable/utils/__tests__/prepareContractsRows.test.ts b/src/widgets/ntb/AnalyticsContracts/components/ContractsTable/utils/__tests__/prepareContractsRows.test.ts
index dccbbf6cbe1e060c0582f6cc395a84caaf851597..a5e3c34ca115b71420c27c195d3328b263c56ca9 100644
--- a/src/widgets/ntb/AnalyticsContracts/components/ContractsTable/utils/__tests__/prepareContractsRows.test.ts
+++ b/src/widgets/ntb/AnalyticsContracts/components/ContractsTable/utils/__tests__/prepareContractsRows.test.ts
@@ -20,12 +20,23 @@ describe('prepareContractsRows', () => {
range: '23.02-01.03.26',
date: '27.02.2026',
issKey: 'NTBVTFC:NTBVCT:11A12C22T33Z3',
+ chartSymbols: [
+ { symbolId: 'NTBVTFC:NTBVCT:11A12C22T33Z3', symbol: 'до 50', symbolName: 'Пшеница, до 50' },
+ { symbolId: 'NTBVTFC:NTBVCT:11A12C22T33Z4', symbol: '50-100', symbolName: 'Пшеница, 50-100' },
+ ],
'до 50': 214,
'50-100': 303,
},
]);
});
+ it('собирает символы графика по всем колонкам в порядке order', () => {
+ expect(prepareContractsRows([contract])?.[0].chartSymbols?.map((symbol) => symbol.symbolId)).toEqual([
+ 'NTBVTFC:NTBVCT:11A12C22T33Z3',
+ 'NTBVTFC:NTBVCT:11A12C22T33Z4',
+ ]);
+ });
+
it('берёт issKey первой колонки по order', () => {
expect(prepareContractsRows([contract])?.[0].issKey).toBe('NTBVTFC:NTBVCT:11A12C22T33Z3');
});
diff --git a/src/widgets/ntb/AnalyticsContracts/components/ContractsTable/utils/prepareContractsRows.ts b/src/widgets/ntb/AnalyticsContracts/components/ContractsTable/utils/prepareContractsRows.ts
index bf8166bf0d5333467b509d6734e5a83987c760be..ae42723c62380f2e808174de1a6998604baec3dc 100644
--- a/src/widgets/ntb/AnalyticsContracts/components/ContractsTable/utils/prepareContractsRows.ts
+++ b/src/widgets/ntb/AnalyticsContracts/components/ContractsTable/utils/prepareContractsRows.ts
@@ -1,7 +1,14 @@
import { flattenColumnData, sortColumnData } from '../../../utils/columnData';
-import type { NormalizedContract } from '../../../types';
+import type { NormalizedContract, NormalizedContractColumnData } from '../../../types';
import type { ContractsTableDataItem } from '../types';
+import type { ChartSymbol } from '@widgets/ntb/shared/types/chart';
+
+const toChartSymbol = (productName: string, { key, name }: NormalizedContractColumnData): ChartSymbol => ({
+ symbolId: key,
+ symbol: name,
+ symbolName: `${productName}, ${name}`,
+});
export const prepareContractsRows = (
contracts: NormalizedContract[] | undefined,
@@ -15,5 +22,6 @@ export const prepareContractsRows = (
range,
date,
issKey: sortedColumnData[0]?.key ?? null,
+ chartSymbols: sortedColumnData.map((column) => toChartSymbol(productName, column)),
};
});
diff --git a/src/widgets/ntb/AnalyticsContracts/const.ts b/src/widgets/ntb/AnalyticsContracts/const.ts
index aa8905550c8de6882e8b99fbc70e75e16f84ee5c..0c8fb9f3dfce0ef9ab3e1abd836298d7fccf4d77 100644
--- a/src/widgets/ntb/AnalyticsContracts/const.ts
+++ b/src/widgets/ntb/AnalyticsContracts/const.ts
@@ -11,6 +11,8 @@ export const CONTRACTS_CHART_BOARD = 'NTBVCT';
export const CONTRACTS_ISS_KEY_PREFIX = `${MARKET_SEGMENT_CODES.NTB_VTFC}:${CONTRACTS_CHART_BOARD}`;
+export const CONTRACTS_CHART_TIMEFRAME = '1w';
+
export const DEFAULT_SOURCE_GROUP = NTB_CONTRACTS_SOURCE_GROUP.AVG;
export const SOURCE_GROUP_TABS: Tab[] = [
diff --git a/src/widgets/ntb/AnalyticsContracts/properties/defaultProperties.ts b/src/widgets/ntb/AnalyticsContracts/properties/defaultProperties.ts
index 4f20609db7003105987ce89d7ac37193241da70f..471999504e334884211951c601c5db9535f438ed 100644
--- a/src/widgets/ntb/AnalyticsContracts/properties/defaultProperties.ts
+++ b/src/widgets/ntb/AnalyticsContracts/properties/defaultProperties.ts
@@ -4,6 +4,7 @@ import type { WidgetProperties } from './types';
const DEFAULT_PROPERTIES: WidgetProperties = {
selectedInstrument: null,
+ selectedChartSymbols: null,
sourceGroup: DEFAULT_SOURCE_GROUP,
};
diff --git a/src/widgets/ntb/AnalyticsContracts/properties/types.ts b/src/widgets/ntb/AnalyticsContracts/properties/types.ts
index 4159d86e411fb504c6fdd43f13316f8102b0d4d0..41842a71cec4861fa73b08fc3dc3068bf4054685 100644
--- a/src/widgets/ntb/AnalyticsContracts/properties/types.ts
+++ b/src/widgets/ntb/AnalyticsContracts/properties/types.ts
@@ -1,6 +1,8 @@
import type { NTB_CONTRACTS_SOURCE_GROUP } from '@modules/ntb/types';
+import type { ChartSymbol } from '@widgets/ntb/shared/types/chart';
export type WidgetProperties = {
selectedInstrument: string | null;
+ selectedChartSymbols: ChartSymbol[] | null;
sourceGroup: NTB_CONTRACTS_SOURCE_GROUP;
};
diff --git a/src/widgets/ntb/AnalyticsContracts/types.ts b/src/widgets/ntb/AnalyticsContracts/types.ts
index 57bbcf4aac90d3949b71713baeb86c3ca20a437c..cb8b0709b1d04f696f2f81c2a042aaa5dc2a2c29 100644
--- a/src/widgets/ntb/AnalyticsContracts/types.ts
+++ b/src/widgets/ntb/AnalyticsContracts/types.ts
@@ -1,10 +1,12 @@
import type { ResizableColumnType } from '@components/Table';
+import type { ChartSymbol } from '@widgets/ntb/shared/types/chart';
export type ContractsRowStaticFields = {
productName: string;
range: string;
date: string;
issKey?: string | null;
+ chartSymbols?: ChartSymbol[];
};
export type ContractsRow = ContractsRowStaticFields & Record<string, unknown>;
diff --git a/src/widgets/ntb/AnalyticsContracts/utils/__tests__/openContractsChart.test.ts b/src/widgets/ntb/AnalyticsContracts/utils/__tests__/openContractsChart.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f6b4e44d66f935165071820e0f941bee93f88007
--- /dev/null
+++ b/src/widgets/ntb/AnalyticsContracts/utils/__tests__/openContractsChart.test.ts
@@ -0,0 +1,42 @@
+import { openNtbChart } from '@widgets/ntb/shared/services/openChart';
+
+import { openContractsChart } from '../openContractsChart';
+
+jest.mock('@widgets/ntb/shared/services/openChart', () => ({ openNtbChart: jest.fn() }));
+
+const mockedOpenNtbChart = openNtbChart as jest.Mock;
+
+const chartSymbols = [
+ { symbolId: 'NTBVTFC:NTBVCT:A', symbol: '≤ 180', symbolName: 'Пшеница, ≤ 180' },
+ { symbolId: 'NTBVTFC:NTBVCT:B', symbol: '(180, 190]', symbolName: 'Пшеница, (180, 190]' },
+ { symbolId: 'NTBVTFC:NTBVCT:C', symbol: '(190, 200]', symbolName: 'Пшеница, (190, 200]' },
+];
+
+describe('openContractsChart', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('открывает первую колонку основной серией, остальные сериями сравнения, таймфрейм неделя', () => {
+ openContractsChart(chartSymbols);
+
+ expect(mockedOpenNtbChart).toHaveBeenCalledWith('NTBVTFC:NTBVCT:A', {
+ symbol: '≤ 180',
+ symbolName: 'Пшеница, ≤ 180',
+ compareSymbols: chartSymbols.slice(1),
+ timeframe: '1w',
+ });
+ });
+
+ it('для одной колонки открывает график без серий сравнения', () => {
+ openContractsChart(chartSymbols.slice(0, 1));
+
+ expect(mockedOpenNtbChart).toHaveBeenCalledWith('NTBVTFC:NTBVCT:A', { compareSymbols: [], timeframe: '1w' });
+ });
+
+ it('без символов передаёт пустой инструмент, и график не открывается', () => {
+ openContractsChart(undefined);
+
+ expect(mockedOpenNtbChart).toHaveBeenCalledWith(undefined, { compareSymbols: [], timeframe: '1w' });
+ });
+});
diff --git a/src/widgets/ntb/AnalyticsContracts/utils/openContractsChart.ts b/src/widgets/ntb/AnalyticsContracts/utils/openContractsChart.ts
new file mode 100644
index 0000000000000000000000000000000000000000..10a780f0636a43e8d0ce7ad4b3ed664cf74d17dd
--- /dev/null
+++ b/src/widgets/ntb/AnalyticsContracts/utils/openContractsChart.ts
@@ -0,0 +1,16 @@
+import { openNtbChart } from '@widgets/ntb/shared/services/openChart';
+
+import { CONTRACTS_CHART_TIMEFRAME } from '../const';
+
+import type { ChartSymbol } from '@widgets/ntb/shared/types/chart';
+
+export const openContractsChart = (chartSymbols: ChartSymbol[] | undefined) => {
+ const [mainSymbol, ...compareSymbols] = chartSymbols ?? [];
+
+ openNtbChart(mainSymbol?.symbolId, {
+ symbol: mainSymbol?.symbol,
+ symbolName: mainSymbol?.symbolName,
+ compareSymbols,
+ timeframe: CONTRACTS_CHART_TIMEFRAME,
+ });
+};
diff --git a/src/widgets/ntb/shared/hooks/__tests__/useHandleRowClick.test.ts b/src/widgets/ntb/shared/hooks/__tests__/useHandleRowClick.test.ts
index c2554f330e3362f7b3bc2ae5f7ca972ed83f0979..338d8a50b8fbf737407f19139bbc1f82b6e1f142 100644
--- a/src/widgets/ntb/shared/hooks/__tests__/useHandleRowClick.test.ts
+++ b/src/widgets/ntb/shared/hooks/__tests__/useHandleRowClick.test.ts
@@ -23,6 +23,12 @@ const WIDGET_ID = 42;
type DataRecord = { issKey: string | null };
+type ChartSymbols = { symbolId: string; symbol: string; symbolName: string }[];
+
+type RowWithChartSymbols = DataRecord & { chartSymbols: ChartSymbols };
+
+type BindableProperties = { selectedInstrument: string | null; selectedChartSymbols?: ChartSymbols | null };
+
const createRow = (issKey: string | null): DataRecord => ({ issKey }) as Partial<DataRecord> as DataRecord;
describe('useHandleRowClick', () => {
@@ -65,6 +71,39 @@ describe('useHandleRowClick', () => {
expect(draft.selectedInstrument).toBe('ISS:1');
});
+ it('пишет набор символов графика через getChartSymbols', () => {
+ const chartSymbols: ChartSymbols = [{ symbolId: 'ISS:1', symbol: 'A', symbolName: 'Продукт, A' }];
+ const { result } = renderHookWithProviders(
+ () =>
+ useHandleRowClick<RowWithChartSymbols, BindableProperties>({
+ getChartSymbols: (record) => record.chartSymbols,
+ }),
+ { mockDispatch: true },
+ );
+
+ act(() => {
+ result.current.handleRowClick({ issKey: 'ISS:1', chartSymbols });
+ });
+
+ const draft: BindableProperties = { selectedInstrument: null, selectedChartSymbols: null };
+ updateProperties.mock.calls[0][0](draft);
+
+ expect(draft.selectedChartSymbols).toEqual(chartSymbols);
+ });
+
+ it('не трогает набор символов графика, если getChartSymbols не передан', () => {
+ const { result } = renderHookWithProviders(() => useHandleRowClick(), { mockDispatch: true });
+
+ act(() => {
+ result.current.handleRowClick(createRow('ISS:1'));
+ });
+
+ const draft = { selectedInstrument: null, selectedChartSymbols: null };
+ updateProperties.mock.calls[0][0](draft);
+
+ expect(draft.selectedChartSymbols).toBeNull();
+ });
+
it('подставляет null, если у строки нет issKey', () => {
const { result, store } = renderHookWithProviders(() => useHandleRowClick(), { mockDispatch: true });
diff --git a/src/widgets/ntb/shared/hooks/useBindAndSaveInstrument.ts b/src/widgets/ntb/shared/hooks/useBindAndSaveInstrument.ts
index d940d7f920e098d95132a9088c566b43b2893aea..35ec77b8b09e98ce3d88ce267ee264b4fb1a35f5 100644
--- a/src/widgets/ntb/shared/hooks/useBindAndSaveInstrument.ts
+++ b/src/widgets/ntb/shared/hooks/useBindAndSaveInstrument.ts
@@ -6,7 +6,9 @@ import { addValuesToPublicContext } from '@store/slices/publicContext';
import { markAsActiveMaster } from '@store/slices/widgets';
import { useWidgetIdContext } from '@terminal/desktop/workspaces/default/components/Widget/context';
-export const useBindAndSaveInstrument = <WP extends { selectedInstrument: string | null }>() => {
+import type { BindableProperties, ChartSymbol } from '../types/chart';
+
+export const useBindAndSaveInstrument = <WP extends BindableProperties>() => {
const widgetId = useWidgetIdContext();
const dispatch = useDispatch();
@@ -14,7 +16,7 @@ export const useBindAndSaveInstrument = <WP extends { selectedInstrument: string
const { updateProperties } = useChangeProperties<WP>();
const bindAndSaveInstrument = useCallback(
- (issKey: string | undefined | null) => {
+ (issKey: string | undefined | null, chartSymbols?: ChartSymbol[]) => {
dispatch(
addValuesToPublicContext({
id: widgetId,
@@ -29,6 +31,10 @@ export const useBindAndSaveInstrument = <WP extends { selectedInstrument: string
updateProperties((state) => {
state.selectedInstrument = issKey ?? null;
+
+ if (chartSymbols) {
+ state.selectedChartSymbols = chartSymbols;
+ }
});
dispatch(markAsActiveMaster({ widgetId }));
diff --git a/src/widgets/ntb/shared/hooks/useHandleRowClick.ts b/src/widgets/ntb/shared/hooks/useHandleRowClick.ts
index f3f972218a37552c9c8b57eb001febe1a33cbd80..5f0dd445f1880ad7851a9a82064b0f7b30975594 100644
--- a/src/widgets/ntb/shared/hooks/useHandleRowClick.ts
+++ b/src/widgets/ntb/shared/hooks/useHandleRowClick.ts
@@ -2,25 +2,31 @@ import { useCallback } from 'react';
import { useBindAndSaveInstrument } from '@widgets/ntb/shared/hooks/useBindAndSaveInstrument';
+import type { BindableProperties, ChartSymbol } from '../types/chart';
+
type UseHandleRowClickProps<T> = {
/** Получает `issKey` из данных строки (record)\
* Если не передан в качестве issKey по умолчанию используется `record.issKey`
*/
getIssKey?: (record: T) => string | null | undefined;
+ /** Получает набор символов графика из данных строки (record)
+ * Мастер сохраняет его в properties, слейв-график открывает по нему серии сравнения
+ */
+ getChartSymbols?: (record: T) => ChartSymbol[] | undefined;
};
export const useHandleRowClick = <
T extends { issKey?: string | null } & Record<string, unknown>,
- WP extends { selectedInstrument: string | null },
->({ getIssKey }: UseHandleRowClickProps<T> = {}) => {
+ WP extends BindableProperties,
+>({ getIssKey, getChartSymbols }: UseHandleRowClickProps<T> = {}) => {
const { bindAndSaveInstrument } = useBindAndSaveInstrument<WP>();
const handleRowClick = useCallback(
(record: T) => {
const selectedInstrument = getIssKey?.(record) ?? record.issKey;
- bindAndSaveInstrument(selectedInstrument);
+ bindAndSaveInstrument(selectedInstrument, getChartSymbols?.(record));
},
- [bindAndSaveInstrument, getIssKey],
+ [bindAndSaveInstrument, getChartSymbols, getIssKey],
);
return {
diff --git a/src/widgets/ntb/shared/services/__tests__/openChart.test.ts b/src/widgets/ntb/shared/services/__tests__/openChart.test.ts
index 4616313b7c75d836d15195427ca15a5a8ed4aa8d..19abe40721b693ebb10fecb89bbf7366249d749d 100644
--- a/src/widgets/ntb/shared/services/__tests__/openChart.test.ts
+++ b/src/widgets/ntb/shared/services/__tests__/openChart.test.ts
@@ -8,6 +8,8 @@ jest.mock('@widgets/Chart/creator', () => ({
const mockedCreateGraphicWidget = createGraphicWidget as jest.Mock;
+const getSavedSnapshot = () => JSON.parse(mockedCreateGraphicWidget.mock.calls[0][0].moexChartState.savedData);
+
describe('openNtbChart', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -27,4 +29,66 @@ describe('openNtbChart', () => {
expect(mockedCreateGraphicWidget).not.toHaveBeenCalled();
});
+
+ it('передаёт таймфрейм из опций', () => {
+ openNtbChart('NTBVTFC:NTBVCT:A', { timeframe: '1w' });
+
+ expect(mockedCreateGraphicWidget.mock.calls[0][0].moexChartState).toEqual({
+ timeframe: '1w',
+ initialInterval: '1Y',
+ initialChartSeriesType: 'Line',
+ });
+ });
+
+ it('кладёт серии сравнения в стартовый снапшот графика', () => {
+ const compareSymbols = [
+ { symbolId: 'NTBVTFC:NTBVCT:B', symbol: '(180, 190]', symbolName: 'Пшеница, (180, 190]' },
+ { symbolId: 'NTBVTFC:NTBVCT:C', symbol: '(190, 200]', symbolName: 'Пшеница, (190, 200]' },
+ ];
+
+ openNtbChart('NTBVTFC:NTBVCT:A', { compareSymbols, timeframe: '1w' });
+
+ expect(getSavedSnapshot()).toEqual({
+ charts: [
+ {
+ symbolId: 'NTBVTFC:NTBVCT:A',
+ timeframe: '1w',
+ chartSeriesType: 'Line',
+ panes: [
+ {
+ isMain: true,
+ id: 0,
+ drawings: [],
+ indicators: compareSymbols.map((symbolInfo) => ({ symbolInfo, seriesName: 'Line', scale: 'right' })),
+ },
+ ],
+ },
+ ],
+ });
+ });
+
+ it('передаёт имена основного инструмента в chartState и в снапшот', () => {
+ openNtbChart('NTBVTFC:NTBVCT:A', {
+ symbol: '≤ 10000',
+ symbolName: 'Ячмень, ≤ 10000',
+ compareSymbols: [{ symbolId: 'NTBVTFC:NTBVCT:B' }],
+ });
+
+ expect(mockedCreateGraphicWidget.mock.calls[0][0].chartState).toEqual({
+ savedInstrument: 'NTBVTFC:NTBVCT:A',
+ savedSymbol: '≤ 10000',
+ savedSymbolName: 'Ячмень, ≤ 10000',
+ });
+ expect(getSavedSnapshot().charts[0]).toMatchObject({
+ symbolId: 'NTBVTFC:NTBVCT:A',
+ symbol: '≤ 10000',
+ symbolName: 'Ячмень, ≤ 10000',
+ });
+ });
+
+ it('не добавляет снапшот без серий сравнения', () => {
+ openNtbChart('NTBVTFC:NTBVCT:A', { compareSymbols: [] });
+
+ expect(mockedCreateGraphicWidget.mock.calls[0][0].moexChartState.savedData).toBeUndefined();
+ });
});
diff --git a/src/widgets/ntb/shared/services/openChart.ts b/src/widgets/ntb/shared/services/openChart.ts
index 2b4cf31f6f6c9d2eeb01018ecb192c47eb6b6aee..3c0ef218030627b4ffafc3afb65a230bd1eb96c3 100644
--- a/src/widgets/ntb/shared/services/openChart.ts
+++ b/src/widgets/ntb/shared/services/openChart.ts
@@ -1,22 +1,78 @@
import { createGraphicWidget } from '@widgets/Chart/creator';
-import type { ChartSeriesType } from '@mx-charts/charts';
+import type { ChartSymbol } from '../types/chart';
+import type { ChartSeriesType, Intervals, MoexChartSnapshotInput, Timeframes } from '@mx-charts/charts';
+
+type ChartSnapshotInput = MoexChartSnapshotInput['charts'][number];
+type PaneSnapshotInput = ChartSnapshotInput['panes'][number];
+type CompareSnapshotInput = Extract<PaneSnapshotInput['indicators'][number], { symbolInfo: ChartSymbol }>;
+
+type MainSymbolNames = Pick<ChartSymbol, 'symbol' | 'symbolName'>;
+
+type OpenNtbChartOptions = MainSymbolNames & {
+ compareSymbols?: ChartSymbol[];
+ timeframe?: `${Timeframes}`;
+};
const NTB_CHART_SERIES_TYPE: ChartSeriesType = 'Line';
+const NTB_CHART_TIMEFRAME: `${Timeframes}` = '1d';
+const NTB_CHART_INTERVAL: `${Intervals}` = '1Y';
+const MAIN_PANE_ID = 0;
+const COMPARE_SCALE = 'right' as CompareSnapshotInput['scale'];
+
+const createCompareSnapshot = (symbolInfo: ChartSymbol): CompareSnapshotInput => ({
+ symbolInfo,
+ seriesName: NTB_CHART_SERIES_TYPE,
+ scale: COMPARE_SCALE,
+});
-export const openNtbChart = (instrumentId: string | null | undefined) => {
+const createSnapshot = (
+ mainSymbol: ChartSymbol,
+ timeframe: `${Timeframes}`,
+ compareSymbols: ChartSymbol[],
+): MoexChartSnapshotInput => ({
+ charts: [
+ {
+ ...mainSymbol,
+ timeframe: timeframe as Timeframes,
+ chartSeriesType: NTB_CHART_SERIES_TYPE,
+ panes: [
+ {
+ isMain: true,
+ id: MAIN_PANE_ID,
+ indicators: compareSymbols.map(createCompareSnapshot),
+ drawings: [],
+ },
+ ],
+ },
+ ],
+});
+
+export const openNtbChart = (
+ instrumentId: string | null | undefined,
+ { compareSymbols = [], timeframe = NTB_CHART_TIMEFRAME, symbol, symbolName }: OpenNtbChartOptions = {},
+) => {
if (!instrumentId) {
return;
}
+ const mainSymbol: ChartSymbol = { symbolId: instrumentId, symbol, symbolName };
+
+ const savedData = compareSymbols.length
+ ? JSON.stringify(createSnapshot(mainSymbol, timeframe, compareSymbols))
+ : undefined;
+
createGraphicWidget({
chartState: {
savedInstrument: instrumentId,
+ ...(symbol ? { savedSymbol: symbol } : {}),
+ ...(symbolName ? { savedSymbolName: symbolName } : {}),
},
moexChartState: {
- timeframe: '1d',
- initialInterval: '1Y',
+ timeframe,
+ initialInterval: NTB_CHART_INTERVAL,
initialChartSeriesType: NTB_CHART_SERIES_TYPE,
+ ...(savedData ? { savedData } : {}),
},
});
};
diff --git a/src/widgets/ntb/shared/types/chart.ts b/src/widgets/ntb/shared/types/chart.ts
new file mode 100644
index 0000000000000000000000000000000000000000..22eeecc7f28caf3bdf6517a4969f3f6f21975398
--- /dev/null
+++ b/src/widgets/ntb/shared/types/chart.ts
@@ -0,0 +1,10 @@
+export type ChartSymbol = {
+ symbolId: string;
+ symbol?: string;
+ symbolName?: string;
+};
+
+export type BindableProperties = {
+ selectedInstrument: string | null;
+ selectedChartSymbols?: ChartSymbol[] | null;
+};