Загрузка данных
import { createGraphicWidget } from '@widgets/Chart/creator';
import type { ChartSeriesType } from '@mx-charts/charts';
const NTB_CHART_SERIES_TYPE: ChartSeriesType = 'Line';
export const openNtbChart = (instrumentId: string | null | undefined) => {
if (!instrumentId) {
return;
}
createGraphicWidget({
chartState: {
savedInstrument: instrumentId,
},
moexChartState: {
timeframe: '1d',
initialInterval: '1Y',
initialChartSeriesType: NTB_CHART_SERIES_TYPE,
},
});
};
import { useContextMenuOverlay } from '@uikit/ContextMenuOverlay';
import { composeIssKey } from '@widgets/ntb/AnaliticsCommodities/utils/composeIssKey';
import { downloadHistory } from '@widgets/ntb/AnaliticsCommodities/utils/downloadHistory';
import { openNtbChart } from '@widgets/ntb/shared/services/openChart';
import type { CommoditiesTableDataItem } from '../types';
import type { ContextMenuItem } from '@uikit/ContextMenu/types';
type UseContextMenuProps = {
onShowHistory: (secId: string | undefined) => void;
};
export const useContextMenu = ({ onShowHistory }: UseContextMenuProps) => {
const {
onContextMenu,
menuProps,
payload: instrument,
} = useContextMenuOverlay<CommoditiesTableDataItem>({
shouldOpen: (record) => !!record?.securityId,
});
const instrumentId = instrument ? composeIssKey(instrument) : null;
const items: ContextMenuItem[] = [
{ key: 'openChart', label: 'Отобразить в виджете График', onClick: () => openNtbChart(instrumentId) },
{
key: 'showHistory',
label: 'Отобразить исторические данные',
onClick: () => onShowHistory(instrument?.securityId),
},
{
key: 'downloadHistory',
label: 'Экспорт истории в Excel',
onClick: () => downloadHistory(instrument?.securityId),
},
];
return { onContextMenu, menuProps, items };
};
import classNames from 'classnames';
import React, { FC } from 'react';
import { type ResizableTableProps, Table } from '@components/Table';
import { useWidgetIdContext } from '@terminal/desktop/workspaces/default/components/Widget/context';
import { ContextMenuOverlay } from '@uikit/ContextMenuOverlay';
import { Filters } from '@widgets/ntb/shared/components/Filters';
import { useFilters, useHandleRowClick } from '@widgets/ntb/shared/hooks';
import { TableKeys } from '../../const';
import { composeIssKey } from '../../utils/composeIssKey';
import styles from './CommoditiesTable.module.scss';
import { commoditiesColumnsConfig } from './config';
import {
COMMODITIES_FILTERS_CONFIG,
COMMODITIES_TABLE_CONFIGURABLE,
DEFAULT_FILTERS_STATE,
INITIAL_TABLE_STATE,
} from './const';
import { useCommodities } from './hooks/useCommodities';
import { useContextMenu } from './hooks/useContextMenu';
import { useFilterOptions } from './hooks/useFilterOptions';
import type { CommoditiesTableDataItem } from './types';
type CommoditiesTableProps = {
hidden: boolean;
setSettingsComponent: ResizableTableProps<CommoditiesTableDataItem>['setSettingsComponent'];
onShowHistory: (secId: string | undefined) => void;
showFilters: boolean;
};
export const CommoditiesTable: FC<CommoditiesTableProps> = ({
onShowHistory,
showFilters,
hidden,
setSettingsComponent,
}) => {
const widgetId = useWidgetIdContext();
const { filters, onChangeFilter, onResetFilters, hasActiveFilters } = useFilters(DEFAULT_FILTERS_STATE);
const { options, loading: optionsLoading } = useFilterOptions(filters);
const { data, loading } = useCommodities(filters);
const { handleRowClick } = useHandleRowClick({
getIssKey: composeIssKey,
});
const { onContextMenu, menuProps, items } = useContextMenu({ onShowHistory });
return (
<div className={classNames(styles.tableWrapper, hidden ? styles.hide : styles.show)}>
{showFilters && (
<Filters
config={COMMODITIES_FILTERS_CONFIG}
onChange={onChangeFilter}
onReset={onResetFilters}
hasActiveFilters={hasActiveFilters}
filters={filters}
options={options}
loading={optionsLoading}
/>
)}
<Table<CommoditiesTableDataItem>
isLoading={loading}
dataSource={data}
widgetId={widgetId}
columns={commoditiesColumnsConfig}
externalHash={TableKeys.Main}
initialState={INITIAL_TABLE_STATE}
setSettingsComponent={setSettingsComponent}
onRowClick={handleRowClick}
onContextMenuClick={onContextMenu}
configurable={COMMODITIES_TABLE_CONFIGURABLE}
/>
<ContextMenuOverlay
items={items}
{...menuProps}
/>
</div>
);
};
import React, { FC, lazy, useContext, useState } from 'react';
import { DNDWrapper } from '@components/DNDWrapper';
import { InstrumentSearch } from '@components/InstrumentSearch';
import WidgetContentWrapper from '@components/WidgetContentWrapper';
import WidgetHeader from '@components/WidgetHeader';
import features from '@features';
import { useDropInstrument } from '@hooks/dnd';
import { WidgetEnvContext } from '@terminal/desktop/workspaces/default/components/Widget/context';
import { useWidgetHeaderName } from '@utils/useWidgetName';
import useChartComponentFacade from './hooks/useChartComponentFacade';
import type { ChartContainerProps } from './types';
const MoexChart = lazy(() => import('./components/MoexChart/MoexChart'));
export const Chart: FC<ChartContainerProps> = function (props): JSX.Element {
const indicativeData = props.widgetContentProps?.indicativeData;
const {
dropDownOpen,
setDropdownOpen,
currentSymbolInfo,
setIsWidgetHeaderContextMenuOpen,
isWidgetHeaderContextMenuOpen,
onDropInstrument,
addInstrumentFromModal,
isOver,
} = useChartComponentFacade(props);
/* на дроп обновляем название бумаги в сторе
и делаем апдейт на бэк, чтобы сохранить изменения
при обновлении страницы */
const { dropRef } = useDropInstrument((dragData) => {
onDropInstrument(dragData.properties.issKey, true);
});
const [isOpenEmptyAction, setIsOpenEmptyAction] = useState<boolean>(false);
const openInstrumentModal = () => {
setIsOpenEmptyAction(true);
setIsWidgetHeaderContextMenuOpen(false);
};
// TODO: Зависит от сеток
const { isDraggedOver } = useContext(WidgetEnvContext);
const contractsInstrumentName = useWidgetHeaderName(currentSymbolInfo.symbolId);
const symbolInfo = indicativeData
? {
symbolId: indicativeData.key,
symbol: indicativeData.secId,
symbolName: `${indicativeData.instrumentName} ${indicativeData.settlement} - ${indicativeData.firmName}`,
}
: currentSymbolInfo;
const instrumentName = symbolInfo.symbolName || contractsInstrumentName || symbolInfo.symbol || symbolInfo.symbolId;
return (
<DNDWrapper
ref={dropRef}
isOver={isOver}
canDrop
>
<WidgetHeader
{...props}
dropdownOpen={dropDownOpen}
setDropdownOpen={setDropdownOpen}
handlerSaveAsExcel={features.hideSaveAsExcel ? undefined : null}
addToWidgetNamePrefix={instrumentName}
setIsOpenContextMenuFromWidget={setIsWidgetHeaderContextMenuOpen}
isOpenContextMenuFromWidget={isWidgetHeaderContextMenuOpen}
openInstrumentsModal={openInstrumentModal}
/>
<WidgetContentWrapper {...props}>
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
pointerEvents: isDraggedOver ? 'none' : 'inherit',
}}
>
<MoexChart
symbolInfo={symbolInfo}
indicativeData={indicativeData}
widgetId={props.widgetId}
addInstrumentFromModal={addInstrumentFromModal}
/>
{isOpenEmptyAction && (
<InstrumentSearch
setOpen={setIsOpenEmptyAction}
isOpen={isOpenEmptyAction}
variant="single"
widgetId={props.widgetId}
// withNRD={false}
addInstruments={addInstrumentFromModal}
/>
)}
</div>
</WidgetContentWrapper>
</DNDWrapper>
);
};
import { 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 { MOEX_CHART_CONFIG } from '../constants';
import { DataSourceProvider } from '../dataSourceProvide';
import type { __CompareManager__, ChartSeriesType, IMoexChart, SymbolInfoInput } from '@mx-charts/charts';
interface TUseMoexChartProps {
symbolInfo: SymbolInfoInput;
indicativeData?: ChartIndicativeData;
}
interface SnapshotSettings {
timeframe: Timeframes;
interval?: Intervals;
chartSeriesType?: ChartSeriesType;
}
export const useMoexChart = ({ symbolInfo, indicativeData }: TUseMoexChartProps) => {
const moexChartState = useSelectProperties(
(widgetProperties: Partial<WidgetProperties>) => widgetProperties.moexChartState,
);
const { updateProperties } = useChangeProperties<WidgetProperties>();
const [isCompareOpen, setIsCompareOpen] = useState(false);
const [isSymbolSearchOpen, setIsSymbolSearchOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<MoexChart | null>(null);
const compareManagerRef = useRef<__CompareManager__ | null>(null);
const currentSymbolInfoRef = useRef<SymbolInfoInput>(symbolInfo);
const timeframeRef = useRef<Timeframes | undefined>(moexChartState?.timeframe);
const savedDataRef = useRef<string | undefined>(moexChartState?.savedData);
const initialIntervalRef = useRef<Intervals | undefined>(moexChartState?.initialInterval);
const initialChartSeriesTypeRef = useRef<ChartSeriesType | undefined>(moexChartState?.initialChartSeriesType);
const updateTimeframeRef = useRef<((timeframe: Timeframes) => void) | null>(null);
useEffect(() => {
const currentSymbolInfo = currentSymbolInfoRef.current;
if (
currentSymbolInfo.symbolId === symbolInfo.symbolId &&
currentSymbolInfo.symbol === symbolInfo.symbol &&
currentSymbolInfo.symbolName === symbolInfo.symbolName
) {
return;
}
currentSymbolInfoRef.current = symbolInfo;
chartRef.current?.setSymbol(symbolInfo);
}, [symbolInfo]);
useEffect(() => {
timeframeRef.current = moexChartState?.timeframe;
savedDataRef.current = moexChartState?.savedData;
}, [moexChartState?.savedData, moexChartState?.timeframe]);
updateTimeframeRef.current = (timeframe: Timeframes): void => {
if (timeframeRef.current === timeframe) {
return;
}
timeframeRef.current = timeframe;
updateProperties((state) => {
state.moexChartState = {
...state.moexChartState,
timeframe,
};
});
};
const saveSnapshot = useCallback((): void => {
const snapshot = chartRef.current?.getSnapshot();
if (!snapshot) {
return;
}
const savedData = JSON.stringify(snapshot);
if (savedDataRef.current === savedData) {
return;
}
savedDataRef.current = savedData;
updateProperties((state) => {
state.moexChartState = {
...state.moexChartState,
timeframe: timeframeRef.current ?? Timeframes['1m'],
savedData,
};
});
}, [updateProperties]);
const getSnapshotWithCurrentSettings = (
snapshot: typeof MOEX_CHART_CONFIG.snapshot | IMoexChart['snapshot'],
{ timeframe, interval, chartSeriesType }: SnapshotSettings,
): IMoexChart['snapshot'] => ({
...snapshot,
charts: snapshot.charts.map((chartSnapshot) => ({
...chartSnapshot,
...currentSymbolInfoRef.current,
timeframe,
...(interval ? { interval } : {}),
...(chartSeriesType ? { chartSeriesType } : {}),
})),
});
const applySnapshot = (): void => {
if (!savedDataRef.current || !chartRef.current) {
return;
}
const savedSnapshot = JSON.parse(savedDataRef.current) as IMoexChart['snapshot'];
const timeframe = timeframeRef.current ?? Timeframes['1m'];
chartRef.current.setSnapshot(getSnapshotWithCurrentSettings(savedSnapshot, { timeframe }));
compareManagerRef.current = chartRef.current.getCompareManager();
};
useEffect(() => {
const container = containerRef.current;
if (!container) {
return undefined;
}
const timeframe = timeframeRef.current ?? Timeframes['1m'];
const initialInterval = initialIntervalRef.current;
const initialChartSeriesType = initialChartSeriesTypeRef.current;
const savedSnapshot = savedDataRef.current
? (JSON.parse(savedDataRef.current) as IMoexChart['snapshot'])
: MOEX_CHART_CONFIG.snapshot;
const dataProvider = new DataSourceProvider();
const chart = new MoexChart({
...MOEX_CHART_CONFIG,
container,
snapshot: getSnapshotWithCurrentSettings(savedSnapshot, {
timeframe,
interval: initialInterval,
chartSeriesType: initialChartSeriesType,
}),
chartCollectionPreset: {
...MOEX_CHART_CONFIG.chartCollectionPreset,
openCompareModal: () => setIsCompareOpen(true),
openSymbolSearchModal: () => setIsSymbolSearchOpen(true),
getDataSource: dataProvider.getDataSource(indicativeData, (nextTimeframe) => {
updateTimeframeRef.current?.(nextTimeframe);
}),
startRealtime: (getSymbols, getTimeframe, update) =>
dataProvider.startRealtime({
getSymbols,
getTimeframe,
update,
indicativeData,
}),
},
});
chartRef.current = chart;
compareManagerRef.current = chart.getCompareManager();
if (initialInterval || initialChartSeriesType) {
initialIntervalRef.current = undefined;
initialChartSeriesTypeRef.current = undefined;
updateProperties((state) => {
state.moexChartState = {
...state.moexChartState,
initialInterval: undefined,
initialChartSeriesType: undefined,
};
});
}
const intervalId = setInterval(() => {
saveSnapshot();
}, 1000);
return () => {
clearInterval(intervalId);
saveSnapshot();
chartRef.current = null;
compareManagerRef.current = null;
chart.destroy();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- график пересоздаётся только при изменении источника индикативных данных
}, [indicativeData?.key]);
return {
containerRef,
isCompareOpen,
isSymbolSearchOpen,
compareManagerRef,
setIsCompareOpen,
setIsSymbolSearchOpen,
saveSnapshot,
applySnapshot,
hasSavedSnapshot: Boolean(moexChartState?.savedData),
};
};
import { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { communicator } from '@core/comm';
import { useAppSelect } from '@hooks/useAppSelector';
import { CORPACTIONS_OPEN_EXESTED_WIDGET_EVENT, HIGHLIGHT_WIDGET_EVENT } from '@modules/widgets/shared';
import { addContentPropsToWidget, unbindWidgets } from '@store/slices/widgets';
import { getStore } from '@store/store';
import { useWidgetsBind } from '@utils/hooks/useWidgetsBind';
import { DEFAULT_SYMBOL } from '../const';
import { useChartPublicContext } from './useChartPublicContext';
import type { WidgetProperties } from '../properties/types';
import type { ChartContainerProps, SelectedInstrument } from '../types';
import type { SymbolInfoInput } from '@mx-charts/charts';
import type { Dispatch, SetStateAction } from 'react';
import type { Widget } from 'types/Widgets';
interface UseChartComponentFacadeReturn {
dropDownOpen: boolean;
setDropdownOpen: Dispatch<SetStateAction<boolean>>;
currentSymbolInfo: SymbolInfoInput;
isWidgetHeaderContextMenuOpen: boolean;
setIsWidgetHeaderContextMenuOpen: Dispatch<SetStateAction<boolean>>;
onDropInstrument: (symbolId: string, withUpdate?: boolean) => void;
addInstrumentFromModal: (instruments: SelectedInstrument[]) => void;
isOver: boolean;
}
type SaveContentPropsOptions = Partial<WidgetProperties['chartState']> & {
withUpdate?: boolean;
cleanIndicativeData?: boolean;
};
export default function useChartComponentFacade(props: ChartContainerProps): UseChartComponentFacadeReturn {
const { widgetId } = props;
const dispatch = useDispatch();
const [isOver, setIsOver] = useState(false);
const [dropDownOpen, setDropdownOpen] = useState(false);
const [isWidgetHeaderContextMenuOpen, setIsWidgetHeaderContextMenuOpen] = useState(false);
const widgetProperties = useAppSelect(
(state) => state.widgets.widgets.find(({ id }) => id === widgetId)?.widgetContentProps,
) as WidgetProperties | undefined;
// TODO временно реф из-за непредсказуемого изменения если используется useState,
// нужен рефакторинг и вернуть обратно useState
const currentSymbolInfoRef = useRef<SymbolInfoInput>({
symbolId: widgetProperties?.chartState?.savedInstrument ?? DEFAULT_SYMBOL,
symbol: widgetProperties?.chartState?.savedSymbol,
symbolName: widgetProperties?.chartState?.savedSymbolName,
});
const [currentSymbolInfo, setCurrentSymbolInfo] = useState<SymbolInfoInput>(currentSymbolInfoRef.current);
const { triggerRelatedWidgetsToUpdate, getMasterInstrumentFromPublicContext } = useWidgetsBind({
widgetId,
});
const triggerRelatedWidgetsToUpdateRef = useRef(triggerRelatedWidgetsToUpdate);
useEffect(() => {
triggerRelatedWidgetsToUpdateRef.current = triggerRelatedWidgetsToUpdate;
}, [triggerRelatedWidgetsToUpdate]);
const saveContentProps = useCallback(
(value: SaveContentPropsOptions): void => {
const { withUpdate, cleanIndicativeData, ...chartStateValue } = value;
const oldProps = (getStore().getState().widgets.widgets.find(({ id }) => id === widgetId) as Widget | undefined)
?.widgetContentProps;
if (!oldProps) {
return;
}
dispatch(
addContentPropsToWidget({
id: widgetId,
withoutSend: !withUpdate,
widgetContentProps: {
chartState: {
...oldProps.chartState,
...chartStateValue,
},
// при смене инструмента очищаем индикативные данные, переданные из виджета Индикативные котировки
indicativeData: cleanIndicativeData ? undefined : oldProps.indicativeData,
},
}),
);
},
[dispatch, widgetId],
);
const onInstrumentChange = useCallback(
(symbolInfo: SymbolInfoInput, withUpdate?: boolean, unbind = true): void => {
const symbolId = symbolInfo.symbolId.trim();
if (!symbolId) {
return;
}
setIsWidgetHeaderContextMenuOpen(false);
const currentValue = currentSymbolInfoRef.current;
const symbolIdChanged = currentValue.symbolId !== symbolId;
const symbol = symbolInfo.symbol?.trim() || (symbolIdChanged ? undefined : currentValue.symbol);
const symbolName = symbolInfo.symbolName?.trim() || (symbolIdChanged ? undefined : currentValue.symbolName);
const nextSymbolInfo: SymbolInfoInput = {
symbolId,
symbol,
symbolName,
};
const symbolChanged = currentValue.symbol !== symbol;
const symbolNameChanged = currentValue.symbolName !== symbolName;
if (!symbolIdChanged && !symbolChanged && !symbolNameChanged) {
return;
}
currentSymbolInfoRef.current = nextSymbolInfo;
setCurrentSymbolInfo(nextSymbolInfo);
if (symbolIdChanged && unbind) {
dispatch(unbindWidgets({ widgetId }));
}
// при смене инструмента очищаем индикативные данные виджета график
// т.к. логика для графика индикатива построена на наличии в widgetContentProps данных indicativeData
saveContentProps({
savedInstrument: symbolId,
savedSymbol: symbol,
savedSymbolName: symbolName,
withUpdate,
cleanIndicativeData: symbolIdChanged,
});
if (symbolIdChanged) {
triggerRelatedWidgetsToUpdateRef.current(symbolId);
}
},
[dispatch, saveContentProps, widgetId],
);
const addInstrumentFromModal = useCallback(
(instruments: SelectedInstrument[]): void => {
const instrument = instruments[0];
if (!instrument?.issKey) {
return;
}
onInstrumentChange({
symbolId: instrument.issKey,
symbol: instrument.symbol,
symbolName: instrument.displayName,
});
},
[onInstrumentChange],
);
const onInstrumentChangeFromBind = useCallback(
(symbolInfo: SymbolInfoInput): void => {
onInstrumentChange(symbolInfo, true, false);
},
[onInstrumentChange],
);
const onDropInstrument = useCallback(
(symbolId: string, withUpdate?: boolean): void => {
onInstrumentChange({ symbolId }, withUpdate);
},
[onInstrumentChange],
);
useChartPublicContext({
setCurrInstrument: onInstrumentChangeFromBind,
widgetId,
getMasterInstrumentFromPublicContext,
});
useEffect(() => {
triggerRelatedWidgetsToUpdateRef.current(currentSymbolInfoRef.current.symbolId);
}, []);
useEffect(() => {
const unsubscribe = communicator.listen(
{
messageType: CORPACTIONS_OPEN_EXESTED_WIDGET_EVENT,
},
(message) => {
const symbolId = (message as Record<number, string>)[widgetId];
if (symbolId) {
onInstrumentChange({ symbolId });
}
},
);
const unsubscribeHighlighter = communicator.listen(
{
messageType: HIGHLIGHT_WIDGET_EVENT,
},
(message) => {
const typedMessage = message as Record<number, boolean>;
if (Object.keys(typedMessage).includes(String(widgetId))) {
setIsOver(typedMessage[widgetId]);
}
},
);
return () => {
unsubscribe();
unsubscribeHighlighter();
};
}, [onInstrumentChange, widgetId]);
return {
dropDownOpen,
setDropdownOpen,
currentSymbolInfo,
isWidgetHeaderContextMenuOpen,
setIsWidgetHeaderContextMenuOpen,
onDropInstrument,
addInstrumentFromModal,
isOver,
};
}
import React from 'react';
import { SymbolSearchModal } from '@widgets/Chart/components/MoexChart/components/SymbolSearchModal';
import { CompareModal } from './components/CompareModal';
import { useMoexChart } from './hooks';
import type { ChartIndicativeData, SelectedInstrument } from '../../types';
import type { SymbolInfoInput } from '@mx-charts/charts';
import '@mx-charts/charts/dist/styles.css';
interface TRProps {
symbolInfo: SymbolInfoInput;
indicativeData?: ChartIndicativeData;
widgetId: number;
addInstrumentFromModal: (instruments: SelectedInstrument[]) => void;
}
function MoexChart({ symbolInfo, indicativeData, widgetId, addInstrumentFromModal }: TRProps) {
const {
containerRef,
isCompareOpen,
isSymbolSearchOpen,
compareManagerRef,
setIsCompareOpen,
setIsSymbolSearchOpen,
} = useMoexChart({
symbolInfo,
indicativeData,
});
const handleSymbolChange = (instrument: SelectedInstrument): void => {
addInstrumentFromModal([instrument]);
};
return (
<div
style={{
flex: '1 1 0',
minHeight: 0,
minWidth: 0,
}}
>
<div ref={containerRef} />
{isCompareOpen && (
<CompareModal
compareManager={compareManagerRef}
widgetId={widgetId}
isOpen={isCompareOpen}
setOpen={setIsCompareOpen}
/>
)}
{isSymbolSearchOpen && (
<SymbolSearchModal
widgetId={widgetId}
isOpen={isSymbolSearchOpen}
setOpen={setIsSymbolSearchOpen}
onSymbolChange={handleSymbolChange}
/>
)}
</div>
);
}
export default React.memo(MoexChart);