Загрузка данных
import styles from './index.module.scss';
import type { Key, ReactNode } from 'react';
export interface TableColumn<Row> {
id: string;
header: ReactNode;
renderCell: (row: Row) => ReactNode;
align?: 'left' | 'center' | 'right';
rowHeader?: boolean;
className?: string;
getCellClassName?: (row: Row) => string | undefined;
}
export interface TableProps<Row> {
columns: readonly TableColumn<Row>[];
rows: readonly Row[];
getRowKey: (row: Row) => Key;
className?: string;
stickyHeader?: boolean;
stickyFirstColumn?: boolean;
emptyContent?: ReactNode;
}
export function Table<Row>({
columns,
rows,
getRowKey,
className,
stickyHeader = true,
stickyFirstColumn = false,
emptyContent = '—',
}: TableProps<Row>) {
const wrapperClassName = [
styles.table,
stickyHeader && styles.table_stickyHeader,
stickyFirstColumn && styles.table_stickyFirstColumn,
className,
]
.filter(Boolean)
.join(' ');
const columnClassNames = columns.map((column) => [
styles.table_cell,
styles[`table_cell_${column.align ?? 'left'}`],
column.className,
]
.filter(Boolean)
.join(' '));
return (
<div className={wrapperClassName}>
<table className={styles.table_content}>
<thead>
<tr>
{columns.map((column, index) => (
<th
key={column.id}
scope="col"
className={columnClassNames[index]}
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={getRowKey(row)}>
{columns.map((column, index) => {
const Cell = column.rowHeader ? 'th' : 'td';
const cellClassName = [
columnClassNames[index],
column.getCellClassName?.(row),
]
.filter(Boolean)
.join(' ');
return (
<Cell
key={column.id}
scope={column.rowHeader ? 'row' : undefined}
className={cellClassName}
>
{column.renderCell(row)}
</Cell>
);
})}
</tr>
))}
{rows.length === 0 && (
<tr>
<td
colSpan={columns.length}
className={styles.table_empty}
>
{emptyContent}
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
@use '../../theme/mixins' as m;
.table {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
max-width: 100%;
max-height: 100%;
overflow: auto;
box-sizing: border-box;
color: var(--table-text, var(--neutral-12));
background: var(--table-background, var(--neutral-0));
@include m.scrollbar;
&_content {
width: 100%;
min-width: max-content;
border-collapse: separate;
border-spacing: 0;
}
&_cell {
padding: var(--space-0750) var(--space-1000);
border-right: 1px solid var(--table-border, var(--neutral-3));
border-bottom: 1px solid var(--table-border, var(--neutral-3));
background: var(
--table-cell-background,
var(--table-background, var(--neutral-0))
);
white-space: nowrap;
font-weight: var(--table-cell-font-weight, 400);
&:last-child {
border-right: 0;
}
&_left {
text-align: left;
}
&_center {
text-align: center;
}
&_right {
text-align: right;
}
}
&_content thead &_cell {
border-top: 1px solid var(--table-border, var(--neutral-3));
}
&_stickyHeader thead .table_cell {
position: sticky;
top: 0;
z-index: 2;
}
&_stickyFirstColumn .table_cell:first-child {
position: sticky;
left: 0;
z-index: 1;
}
&_stickyHeader.table_stickyFirstColumn thead .table_cell:first-child {
z-index: 3;
}
&_empty {
padding: var(--space-1000);
text-align: center;
}
}
import { PriceScaleMode } from 'lightweight-charts';
import { useMemo } from 'react';
import { Table, type TableColumn } from '@src/components/Table';
import { getLocale, t } from '@src/translations';
import { ChartViewType, SeasonalityViewType } from '@src/types';
import { useObservable } from '@src/utils';
import styles from './index.module.scss';
import type {
SeasonalityChange,
SeasonalityState,
SeasonalityTableRow,
} from '@src/types';
import type { ReactNode } from 'react';
import type { Observable } from 'rxjs';
const MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
] as const;
interface SeasonalityTableProps {
stateObs: Observable<SeasonalityState>;
}
export function SeasonalityTable({ stateObs }: SeasonalityTableProps) {
const state = useObservable(stateObs);
const locale = getLocale();
const formatter = useMemo(
() => new Intl.NumberFormat(locale, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
[locale],
);
if (
!state ||
state.view !== ChartViewType.Seasonality ||
state.displayMode !== SeasonalityViewType.Table ||
state.status !== 'ready'
) {
return null;
}
const valueKey = state.scale === PriceScaleMode.Percentage
? 'percentage'
: 'absolute';
const suffix = state.scale === PriceScaleMode.Percentage ? '%' : '';
const formatChange = (change: SeasonalityChange | null): string => {
const value = change?.[valueKey] ?? null;
return value === null
? '—'
: `${formatter.format(value)}${suffix}`;
};
const getChangeClassName = (
change: SeasonalityChange | null,
): string | undefined => {
const value = change?.[valueKey] ?? null;
if (value !== null && value > 0) {
return styles.positive;
}
if (value !== null && value < 0) {
return styles.negative;
}
return undefined;
};
const createChangeColumn = (
id: string,
header: ReactNode,
getChange: (row: SeasonalityTableRow) => SeasonalityChange | null,
className?: string,
): TableColumn<SeasonalityTableRow> => ({
id,
header,
align: 'right',
className,
renderCell: (row) => formatChange(getChange(row)),
getCellClassName: (row) => getChangeClassName(getChange(row)),
});
const columns: TableColumn<SeasonalityTableRow>[] = [
{
id: 'date',
header: t('Date'),
rowHeader: true,
renderCell: (row) => row.year,
},
...MONTHS.map((month, index) => createChangeColumn(
month,
t(month),
(row) => row.months[index],
)),
createChangeColumn(
'year',
t('Year'),
(row) => row.total,
styles.total,
),
];
return (
<Table
className={styles.seasonality}
columns={columns}
rows={state.tableRows}
getRowKey={(row) => row.year}
stickyFirstColumn
/>
);
}
.seasonality {
--table-background: var(--seasonality-background);
--table-text: var(--seasonality-text);
--table-border: var(--seasonality-border);
font-variant-numeric: tabular-nums;
}
.positive {
--table-cell-background: color-mix(
in srgb,
#089981 25%,
var(--table-background)
);
}
.negative {
--table-cell-background: color-mix(
in srgb,
#f23645 35%,
var(--table-background)
);
}
.total {
--table-cell-font-weight: 600;
}
import {
CHART_DRAWING_TOOLBAR_CONTAINER,
CHART_MODAL_CONTAINER_CLASSNAME,
CHART_PANE_EMPTY_STATE_CONTAINER,
CHART_PANE_LEGEND_CONTAINER,
CHART_PANE_OVERLAY_CONTAINER,
CHART_ROOT_CLASSNAME,
CHART_SCROLLABLE_CLASSNAME,
} from '@src/constants';
interface CreateContainersOptions {
parentContainer: HTMLElement;
showBottomPanel?: boolean;
showMenuButton?: boolean;
}
export enum ZIndex {
Chart = '0',
Base = '1',
Legend = '2',
Floating = '3',
Modal = '1000',
}
const ContainerLayoutConfig = {
headerHeight: 36,
footerHeight: 32,
toolbarWidth: 42,
verticalGap: 8,
};
/**
* Утилита для создания DOM контейнеров
*/
export class ContainerManager {
private static injectFont(): void {
const existingLink = document.querySelector('link[href*="fonts.googleapis.com"]');
if (existingLink) {
return;
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,100..900&display=swap';
document.head.appendChild(link);
}
/**
* Создание контейнеров для графика и UI компонентов
*/
static createContainers({
parentContainer,
showBottomPanel,
showMenuButton,
}: CreateContainersOptions) {
this.injectFont();
const {
headerHeight,
footerHeight,
toolbarWidth,
verticalGap,
} = ContainerLayoutConfig;
parentContainer.innerHTML = '';
parentContainer.classList.add(CHART_ROOT_CLASSNAME);
Object.assign(parentContainer.style, {
height: '100%',
width: '100%',
padding: 'var(--space-1000)',
backgroundColor: 'var(--neutral-0)',
borderRadius: 'var(--space-0500)',
display: 'grid',
rowGap: `${verticalGap}px`,
gridTemplateRows: showBottomPanel
? `${headerHeight}px minmax(0, 1fr) ${footerHeight}px`
: `${headerHeight}px minmax(0, 1fr)`,
});
const headerContainer = document.createElement('div');
headerContainer.classList.add(CHART_SCROLLABLE_CLASSNAME);
Object.assign(headerContainer.style, {
width: '100%',
height: `${headerHeight}px`,
overflow: 'auto hidden',
});
const footerContainer = document.createElement('div');
Object.assign(footerContainer.style, {
width: '100%',
height: `${footerHeight}px`,
});
const chartViewContainer = document.createElement('div');
Object.assign(chartViewContainer.style, {
position: 'relative',
display: 'grid',
minWidth: '0',
minHeight: '0',
});
const chartContainer = document.createElement('div');
Object.assign(chartContainer.style, {
position: 'relative',
gridArea: '1 / 1',
height: '100%',
width: '100%',
minWidth: '0',
minHeight: '0',
display: 'grid',
columnGap: 'var(--space-0500)',
gridTemplateColumns: 'minmax(0, 1fr)',
});
const chartAreaContainer = document.createElement('div');
chartAreaContainer.tabIndex = -1;
Object.assign(chartAreaContainer.style, {
position: 'relative',
height: '100%',
minHeight: '0',
minWidth: '0',
outline: 'none',
cursor: 'crosshair',
});
const seasonalityContainer = document.createElement('div');
seasonalityContainer.tabIndex = -1;
seasonalityContainer.inert = true;
Object.assign(seasonalityContainer.style, {
position: 'relative',
gridArea: '1 / 1',
minWidth: '0',
minHeight: '0',
visibility: 'hidden',
pointerEvents: 'none',
zIndex: ZIndex.Base,
overflow: 'hidden',
display: 'grid',
gridTemplateRows: 'auto minmax(0, 1fr)',
rowGap: 'var(--space-0500)',
outline: 'none',
userSelect: 'none',
});
const seasonalityControlsContainer = document.createElement('div');
Object.assign(seasonalityControlsContainer.style, {
minWidth: '0',
});
const seasonalityContentContainer = document.createElement('div');
Object.assign(seasonalityContentContainer.style, {
position: 'relative',
minWidth: '0',
minHeight: '0',
});
const seasonalityTableContainer = document.createElement('div');
seasonalityTableContainer.hidden = true;
Object.assign(seasonalityTableContainer.style, {
position: 'absolute',
inset: '0',
minWidth: '0',
minHeight: '0',
overflow: 'hidden',
});
seasonalityContentContainer.append(seasonalityTableContainer);
const toolBarContainer = document.createElement('div');
Object.assign(toolBarContainer.style, {
height: '100%',
minHeight: '0',
overflow: 'hidden',
});
const controlBarContainer = document.createElement('div');
Object.assign(controlBarContainer.style, {
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
bottom: 'var(--space-2000)',
zIndex: ZIndex.Floating,
});
const drawingToolbarContainer = document.createElement('div');
drawingToolbarContainer.classList.add(CHART_DRAWING_TOOLBAR_CONTAINER);
Object.assign(drawingToolbarContainer.style, {
position: 'absolute',
inset: '0',
zIndex: ZIndex.Floating,
pointerEvents: 'none',
overflow: 'hidden',
});
const modalContainer = document.createElement('div');
modalContainer.classList.add(CHART_MODAL_CONTAINER_CLASSNAME);
modalContainer.hidden = true;
Object.assign(modalContainer.style, {
position: 'absolute',
inset: '0',
zIndex: ZIndex.Modal,
});
seasonalityContainer.append(
seasonalityControlsContainer,
seasonalityContentContainer,
);
chartAreaContainer.append(
controlBarContainer,
drawingToolbarContainer,
);
chartContainer.append(chartAreaContainer, modalContainer);
chartViewContainer.append(chartContainer, seasonalityContainer);
parentContainer.append(headerContainer, chartViewContainer);
if (showBottomPanel) {
parentContainer.append(footerContainer);
}
const toggleToolbar = () => {
if (toolBarContainer.isConnected) {
toolBarContainer.remove();
Object.assign(chartContainer.style, {
gridTemplateColumns: 'minmax(0, 1fr)',
});
return false;
}
chartContainer.insertBefore(toolBarContainer, chartAreaContainer);
Object.assign(chartContainer.style, {
gridTemplateColumns: `${toolbarWidth}px minmax(0, 1fr)`,
});
return true;
};
if (showMenuButton) {
toggleToolbar();
}
return {
headerContainer,
footerContainer,
chartContainer,
seasonalityContainer,
seasonalityControlsContainer,
seasonalityContentContainer,
seasonalityTableContainer,
chartAreaContainer,
toolBarContainer,
modalContainer,
controlBarContainer,
drawingToolbarContainer,
toggleToolbar,
};
}
/**
* Очистка контейнеров
*/
static clearContainers(parentContainer: HTMLElement): void {
parentContainer.innerHTML = '';
}
static createPaneContainers() {
const legendContainer = document.createElement('div');
legendContainer.classList.add(CHART_PANE_LEGEND_CONTAINER);
Object.assign(legendContainer.style, {
position: 'absolute',
top: '0',
left: '0',
zIndex: ZIndex.Legend,
pointerEvents: 'none',
});
const paneOverlayContainer = document.createElement('div');
paneOverlayContainer.classList.add(CHART_PANE_OVERLAY_CONTAINER);
Object.assign(paneOverlayContainer.style, {
position: 'absolute',
inset: '0',
zIndex: ZIndex.Base,
pointerEvents: 'none',
});
const paneEmptyStateContainer = document.createElement('div');
paneEmptyStateContainer.classList.add(CHART_PANE_EMPTY_STATE_CONTAINER);
paneEmptyStateContainer.hidden = true;
return {
legendContainer,
paneOverlayContainer,
paneEmptyStateContainer,
};
}
}
import { combineLatest, Subscription } from 'rxjs';
import { ControlBar } from '@components/ControlBar';
import { Footer } from '@components/Footer';
import { Header } from '@components/Header';
import { DataSource, DataSourceParams } from '@core/DataSource';
import { Hotkeys, Keys } from '@core/Hotkeys';
import { ModalRenderer } from '@core/ModalRenderer';
import { FloatingDrawingToolbar } from '@src/components/FloatingToolbar';
import { SeasonalityControls } from '@src/components/SeasonalityControls';
import { SeasonalityTable } from '@src/components/SeasonalityTable';
import { SettingsModal } from '@src/components/SettingsModal';
import Toolbar from '@src/components/Toolbar';
import { IndicatorsIds } from '@src/constants';
import { CompareManager } from '@src/core/CompareManager';
import { FullscreenController } from '@src/core/Fullscreen';
import { SeasonalityManager } from '@src/core/SeasonalityManager';
import { configureThemeStore } from '@src/theme/store';
import { ThemeKey, ThemeMode } from '@src/theme/types';
import { Locale, setLocale, t } from '@src/translations';
import {
Candle,
ChartSeriesType,
ChartViewType,
OHLCConfig,
SeasonalityRange,
SeasonalityViewType,
SymbolInfoInput,
TooltipConfig,
} from '@src/types';
import {
ISerializable,
MoexChartSnapshot,
MoexChartSnapshotInput,
} from '@src/types/snapshot';
import { Timeframes } from '@src/types/timeframes';
import { setPricePrecision } from '@src/utils';
import { Chart } from './Chart';
import { ChartSettings, ChartSettingsSource } from './ChartSettings';
import { ContainerManager } from './ContainerManager';
import { EventManager } from './EventManager';
import { ReactRenderer } from './ReactRenderer';
import { TimeScaleHoverController } from './TimescaleHoverController';
import { UIRenderer } from './UIRenderer';
import 'exchange-elements/dist/fonts/inter/font.css';
import 'exchange-elements/dist/style.css';
import 'exchange-elements/dist/tokens/moex.css';
import '../styles/global.scss';
// todo: forbid @lib in /src
export interface ChartCollectionPreset {
undoRedoEnabled?: boolean;
showMenuButton?: boolean;
showBottomPanel?: boolean;
showControlBar?: boolean;
showFullscreenButton?: boolean;
showSettingsButton?: boolean;
showCompareButton?: boolean;
showSymbolSearchButton?: boolean;
showSnapshotButton?: boolean;
showSeasonalityButton?: boolean;
/**
* Дефолтная конфигурация тултипа - всегда показывается по умолчанию.
* При добавлении/изменении полей в конфиге - они объединяются с дефолтными значениями.
*
* Полная кастомизация:
* @example
* ```typescript
* tooltipConfig: {
* time: { visible: true, label: 'Дата и время' },
* symbol: { visible: true, label: 'Инструмент' },
* close: { visible: true, label: 'Курс' },
* change: { visible: true, label: 'Изменение' },
* volume: { visible: true, label: 'Объем' },
* open: { visible: false },
* high: { visible: false },
* low: { visible: false }
* }
*```
*/
tooltipConfig?: TooltipConfig;
size?:
| {
width: number;
height: number;
}
| false;
supportedTimeframes: Timeframes[];
supportedChartSeriesTypes: ChartSeriesType[];
getDataSource: DataSourceParams['getData'];
getSeasonalityDataSource?: DataSourceParams['getData'];
startRealtime: (
getSymbols: () => string[],
getTimeframe: () => Timeframes,
update: (symbolId: string, candle: Candle) => void,
periodMs?: number,
) => () => void;
theme: ThemeKey; // 'mb' | 'mxt' | 'tr'
ohlc: OHLCConfig;
locale: Locale;
mode?: ThemeMode; // 'light' | 'dark'
openCompareModal?: () => void;
openSymbolSearchModal?: () => void;
}
export interface IMoexChart {
snapshot: MoexChartSnapshotInput;
chartCollectionPreset: ChartCollectionPreset;
container: HTMLElement;
}
export class MoexChart implements ISerializable<MoexChartSnapshot> {
private chart!: Chart;
private seasonalityManager!: SeasonalityManager;
private resizeObserver?: ResizeObserver;
private eventManager!: EventManager;
private hotkeys!: Hotkeys;
private rootContainer!: HTMLElement;
private seasonalityControlsRenderer!: UIRenderer;
private seasonalityTableRenderer!: UIRenderer;
private headerRenderer!: UIRenderer;
private modalRenderer!: ModalRenderer;
private toolbarRenderer: UIRenderer | undefined;
private controlBarRenderer?: UIRenderer;
private footerRenderer?: UIRenderer;
private drawingToolbarRenderer!: UIRenderer;
private timeScaleHoverController!: TimeScaleHoverController;
private dataSource!: DataSource;
private subscriptions = new Subscription();
private fullscreen!: FullscreenController;
private chartCollectionPresetSettings!: ChartCollectionPreset;
constructor(config: IMoexChart) {
setLocale(config.chartCollectionPreset.locale);
this.setup(config);
}
private setup = (config: IMoexChart) => {
this.subscriptions = new Subscription();
this.toolbarRenderer = undefined;
this.controlBarRenderer = undefined;
this.footerRenderer = undefined;
this.chartCollectionPresetSettings = config.chartCollectionPreset;
setPricePrecision(config.chartCollectionPreset.ohlc.precision);
const {
chartSeriesType,
symbolId,
symbol,
symbolName,
timeframe,
interval,
dateFormat,
timeFormat,
} = config.snapshot.charts[0];
this.eventManager = new EventManager({
initialTimeframe: timeframe,
initialSeries: chartSeriesType,
initialSymbolInfo: {
symbolId: symbolId ?? '',
symbol,
symbolName,
},
initialTimeFormat: timeFormat,
initialDateFormat: dateFormat,
initialInterval: interval,
});
this.dataSource = new DataSource({
getData: config.chartCollectionPreset.getDataSource,
eventManager: this.eventManager,
});
this.rootContainer = config.container;
this.fullscreen = new FullscreenController(this.rootContainer);
const store = configureThemeStore(config.chartCollectionPreset);
const {
chartAreaContainer,
chartContainer,
seasonalityContainer,
seasonalityControlsContainer,
seasonalityContentContainer,
seasonalityTableContainer,
toolBarContainer,
headerContainer,
modalContainer,
controlBarContainer,
drawingToolbarContainer,
footerContainer,
toggleToolbar, // todo: move this function to toolbarModel
} = ContainerManager.createContainers({
parentContainer: this.rootContainer,
showBottomPanel: config.chartCollectionPreset.showBottomPanel, // todo: apply config.showBottomPanel in FullscreenController
showMenuButton: config.chartCollectionPreset.showMenuButton,
});
this.hotkeys = new Hotkeys(
this.rootContainer,
() => !this.seasonalityManager.isActive(),
);
this.registerUndoRedoHotkeys(
config.chartCollectionPreset.undoRedoEnabled,
);
this.modalRenderer = new ModalRenderer(modalContainer);
this.chart = new Chart({
params: {
dataSource: this.dataSource,
eventManager: this.eventManager,
modalRenderer: this.modalRenderer,
ohlcConfig: config.chartCollectionPreset.ohlc, // todo: omptimize
tooltipConfig: config.chartCollectionPreset.tooltipConfig ?? {},
panes: config.snapshot.charts[0].panes,
hotkeys: this.hotkeys,
},
lwcChartConfig: {
container: chartAreaContainer,
seriesTypes: config.chartCollectionPreset.supportedChartSeriesTypes,
theme: store.theme,
mode: store.mode,
},
});
const chartGridTemplateRows = this.rootContainer.style.gridTemplateRows;
const setSeasonalityVisible = (visible: boolean): void => {
Object.assign(this.rootContainer.style, {
gridTemplateRows: visible
? 'auto minmax(0, 1fr)'
: chartGridTemplateRows,
});
Object.assign(chartContainer.style, {
visibility: visible ? 'hidden' : 'visible',
});
chartContainer.inert = visible;
Object.assign(footerContainer.style, {
display: visible ? 'none' : '',
});
Object.assign(seasonalityContainer.style, {
visibility: visible ? 'visible' : 'hidden',
pointerEvents: visible ? 'auto' : 'none',
});
seasonalityContainer.inert = !visible;
};
this.seasonalityManager = new SeasonalityManager({
container: seasonalityContentContainer,
getData:
config.chartCollectionPreset.getSeasonalityDataSource ??
config.chartCollectionPreset.getDataSource,
precision: config.chartCollectionPreset.ohlc.precision,
onOpen: () => {
this.chart.setActive(false);
setSeasonalityVisible(true);
seasonalityContainer.focus({ preventScroll: true });
},
onBack: () => {
setSeasonalityVisible(false);
this.chart.setActive(true);
chartAreaContainer.focus({ preventScroll: true });
},
});
this.seasonalityControlsRenderer = new ReactRenderer(
seasonalityControlsContainer,
);
this.seasonalityControlsRenderer.renderComponent(
<SeasonalityControls
stateObs={this.seasonalityManager.getState()}
setDisplayMode={this.seasonalityManager.setDisplayMode}
setScale={this.seasonalityManager.setScale}
setRange={this.seasonalityManager.setRange}
/>,
);
this.seasonalityTableRenderer = new ReactRenderer(
seasonalityTableContainer,
);
this.seasonalityTableRenderer.renderComponent(
<SeasonalityTable
stateObs={this.seasonalityManager.getState()}
/>,
);
this.subscriptions.add(
this.seasonalityManager.getState().subscribe((state) => {
seasonalityTableContainer.hidden =
state.view !== ChartViewType.Seasonality ||
state.displayMode !== SeasonalityViewType.Table;
}),
);
this.subscriptions.add(
this.hotkeys.register({
keys: [Keys.escape],
enabled: () => (
this.seasonalityManager.isActive() &&
modalContainer.hidden
),
callback: this.backToChart,
}),
);
this.subscriptions.add(
this.eventManager.symbolId().subscribe(this.backToChart),
);
this.subscriptions.add(
combineLatest([store.theme$, store.mode$]).subscribe(([theme, mode]) => {
this.chart.updateTheme(theme, mode);
this.seasonalityManager.updateTheme();
document.documentElement.dataset.theme = theme;
document.documentElement.dataset.mode = mode;
}),
);
const realtimeParams = this.chart.getRealtimeApi();
this.subscriptions.add(
config.chartCollectionPreset.startRealtime(
realtimeParams.getSymbols,
realtimeParams.getTimeframe,
realtimeParams.update,
),
);
this.createRenderers(config.chartCollectionPreset, {
headerContainer,
toolBarContainer,
drawingToolbarContainer,
controlBarContainer,
footerContainer,
});
this.timeScaleHoverController = new TimeScaleHoverController({
eventManager: this.eventManager,
controlBarContainer,
chartContainer: chartAreaContainer,
});
this.renderAttachments(config, toggleToolbar);
};
private registerUndoRedoHotkeys(enabled = false): void {
if (!enabled) return;
const undoRedo = this.eventManager.getUndoRedo();
this.hotkeys.register({
keys: [Keys.mod, Keys.z],
callback: undoRedo.undo,
});
this.hotkeys.register({
keys: [Keys.mod, Keys.y],
callback: undoRedo.redo,
});
this.hotkeys.register({
keys: [Keys.mod, Keys.shift, Keys.z],
callback: undoRedo.redo,
});
}
private createRenderers(
preset: ChartCollectionPreset,
containers: Pick<
ReturnType<typeof ContainerManager.createContainers>,
| 'headerContainer'
| 'toolBarContainer'
| 'drawingToolbarContainer'
| 'controlBarContainer'
| 'footerContainer'
>,
): void {
const {
headerContainer,
toolBarContainer,
drawingToolbarContainer,
controlBarContainer,
footerContainer,
} = containers;
this.headerRenderer = new ReactRenderer(headerContainer);
this.toolbarRenderer = new ReactRenderer(toolBarContainer);
this.drawingToolbarRenderer = new ReactRenderer(
drawingToolbarContainer,
);
if (preset.showControlBar) {
this.controlBarRenderer = new ReactRenderer(controlBarContainer);
}
if (preset.showBottomPanel) {
this.footerRenderer = new ReactRenderer(footerContainer);
}
}
public setSettings(settings: ChartSettingsSource): void {
this.seasonalityManager.back();
this.eventManager.importChartSettings(settings);
}
public getSettings(): ChartSettings {
return this.eventManager.exportChartSettings();
}
// todo: описать подробнее в доке. Точно ли public?
public getRealtimeApi() {
return this.chart.getRealtimeApi();
}
// todo: описать подробнее в доке
public getCompareManager(): CompareManager {
return this.chart.getCompareManager();
}
public setSnapshot(snapshot: MoexChartSnapshotInput) {
const configConstructorLike: IMoexChart = {
snapshot,
chartCollectionPreset: this.chartCollectionPresetSettings,
container: this.rootContainer,
};
this.destroy();
this.setup(configConstructorLike);
}
// todo: описать в доке
public getSnapshot(): MoexChartSnapshot {
const res = {
settings: this.getSettings(),
charts: [this.chart.getSnapshot()], // todo: в будущем может быть несколько инстансов чартов
};
return res;
}
public setSymbol(symbolInfo: SymbolInfoInput): void {
this.seasonalityManager.back();
this.eventManager.setSymbol(symbolInfo);
}
public clearSymbol(): void {
this.seasonalityManager.back();
this.eventManager.clearSymbol();
}
public showSeasonality(range?: SeasonalityRange): Promise<void> {
const { symbolInfo } = this.eventManager.exportChartSettings();
return this.seasonalityManager.show(symbolInfo.symbolId, range);
}
public backToChart = (): void => {
this.seasonalityManager.back();
};
private setChartView = (view: ChartViewType): void => {
if (view === ChartViewType.Chart) {
this.backToChart();
return;
}
this.showSeasonality();
};
private renderHeader(
config: IMoexChart,
toggleToolbar: () => boolean,
): void {
this.headerRenderer.renderComponent(
<Header
seasonalityStateObs={this.seasonalityManager.getState()}
setChartView={
config.chartCollectionPreset.showSeasonalityButton
? this.setChartView
: undefined
}
timeframes={config.chartCollectionPreset.supportedTimeframes}
selectedTimeframeObs={this.eventManager.getTimeframeObs()}
setTimeframe={(value) => {
this.eventManager.setTimeframe(value);
}}
seriesTypes={config.chartCollectionPreset.supportedChartSeriesTypes}
selectedSeriesObs={this.eventManager.getSelectedSeries()}
setSelectedSeries={(value) => {
this.eventManager.setSeriesSelected(value);
}}
showSettingsModal={
config.chartCollectionPreset.showSettingsButton
? () => this.modalRenderer.renderComponent(
<SettingsModal
// todo: deal with onSave
changeTimeFormat={(format) => this.eventManager.setTimeFormat(format)}
changeDateFormat={(format) => this.eventManager.setDateFormat(format)}
chartDateTimeFormatObs={this.eventManager.getChartOptionsModel()}
/>,
{ title: t('Settings') },
)
: undefined
}
addIndicatorToChart={(indicatorType: IndicatorsIds) => (
this.chart.getIndicatorManager().addIndicator({ indicatorType })
)}
copyImage={
config.chartCollectionPreset.showSnapshotButton
? () => (
this.seasonalityManager.isActive()
? this.seasonalityManager.copyImage()
: this.chart.copyImage()
)
: undefined
}
showMenuButton={!!config.chartCollectionPreset.showMenuButton}
showFullscreenButton={!!config.chartCollectionPreset.showFullscreenButton}
fullscreen={this.fullscreen}
undoRedo={
config.chartCollectionPreset.undoRedoEnabled
? this.eventManager.getUndoRedo()
: undefined
}
toggleToolbarVisible={toggleToolbar}
showCompareButton={!!config.chartCollectionPreset.showCompareButton}
openCompareModal={
config.chartCollectionPreset.openCompareModal
? config.chartCollectionPreset.openCompareModal
: undefined
}
showSymbolSearchButton={!!config.chartCollectionPreset.openSymbolSearchModal}
openSymbolSearchModal={config.chartCollectionPreset.openSymbolSearchModal}
isMXT={config.chartCollectionPreset.theme === 'mxt'}
/>,
);
}
private renderAttachments(
config: IMoexChart,
toggleToolbar: () => boolean,
) {
const drawingsCollectionManager = this.chart.getDrawingsCollectionManager();
this.drawingToolbarRenderer.renderComponent(
// этот тулбар к drawingManger бы прибить, сильно на него завязан
<FloatingDrawingToolbar
selectedDrawing$={drawingsCollectionManager.selectedDrawing()}
onUpdateSettings={drawingsCollectionManager.updateSelectedDrawingSettings}
onToggleLock={() => drawingsCollectionManager.toggleSelectedDrawingLock()}
onOpenSettings={() => drawingsCollectionManager.openSelectedDrawingSettings()}
onDelete={() => drawingsCollectionManager.deleteSelectedDrawing()}
/>,
);
this.renderHeader(config, toggleToolbar);
if (this.toolbarRenderer && config.chartCollectionPreset.showMenuButton) {
const drawingCollection = this.chart.getDrawingsCollectionManager();
this.toolbarRenderer.renderComponent(
<Toolbar
toggleDOM={this.chart.getDom().toggleDOM}
addDrawing={drawingCollection.activateDrawingTool}
setEndlessDrawingsMode={drawingCollection.setEndlessDrawingMode}
isEndlessDrawingsMode$={drawingCollection.isEndlessDrawingMode()}
activateCrosshair={() => drawingCollection.activateCrosshair()}
activeTool$={drawingCollection.getActiveTool()}
/>,
);
}
if (this.controlBarRenderer && config.chartCollectionPreset.showControlBar) {
this.controlBarRenderer.renderComponent(
<ControlBar
scroll={this.chart.scrollTimeScale}
zoom={this.chart.zoomTimeScale}
reset={this.chart.resetZoom}
visible={this.eventManager.getControlBarVisible()}
/>,
);
}
if (this.footerRenderer && config.chartCollectionPreset.showBottomPanel) {
this.footerRenderer.renderComponent(
<Footer
supportedTimeframes={config.chartCollectionPreset.supportedTimeframes}
setInterval={this.eventManager.setInterval}
intervalObs={this.eventManager.getInterval()}
/>,
);
}
}
/**
* Уничтожение графика и очистка ресурсов
* @returns void
*/
destroy(): void {
this.seasonalityControlsRenderer.destroy();
this.seasonalityTableRenderer.destroy();
this.seasonalityManager.destroy();
this.headerRenderer.destroy();
this.drawingToolbarRenderer.destroy();
this.subscriptions.unsubscribe();
this.timeScaleHoverController.destroy();
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = undefined;
}
if (this.controlBarRenderer) {
this.controlBarRenderer.destroy();
}
if (this.footerRenderer) {
this.footerRenderer.destroy();
}
if (this.chart) {
this.chart.destroy();
}
this.hotkeys.destroy();
if (this.eventManager) {
this.eventManager.destroy();
}
if (this.toolbarRenderer) {
this.toolbarRenderer.destroy();
}
this.dataSource.destroy();
ContainerManager.clearContainers(this.rootContainer);
}
}