Загрузка данных


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);
  }
}