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


import { CHART_ROOT_CLASSNAME } from '@src/constants';

import styles from './styles.module.scss';

interface CreateContainersOptions {
  parentContainer: HTMLElement;
  showBottomPanel?: boolean;
  showMenuButton?: boolean;
}

enum ZIndex {
  Chart = '0',
  Base = '10',
  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);
    parentContainer.style.height = '100%';
    parentContainer.style.width = '100%';
    parentContainer.style.padding = 'var(--space-1000)';
    parentContainer.style.backgroundColor = 'var(--neutral-0)';
    parentContainer.style.borderRadius = 'var(--space-0500)';

    parentContainer.style.display = 'grid';
    parentContainer.style.rowGap = `${verticalGap}px`;
    parentContainer.style.gridTemplateRows = showBottomPanel
      ? `${headerHeight}px minmax(0, 1fr) ${footerHeight}px`
      : `${headerHeight}px minmax(0, 1fr)`;

    const headerContainer = document.createElement('div');
    headerContainer.style.width = '100%';
    headerContainer.style.height = `${headerHeight}px`;
    headerContainer.style.overflow = 'auto hidden';
    headerContainer.className = styles.scrollableBox;

    const footerContainer = document.createElement('div');
    footerContainer.style.width = '100%';
    footerContainer.style.height = `${footerHeight}px`;

    const chartContainer = document.createElement('div');
    chartContainer.style.height = '100%';
    chartContainer.style.width = '100%';
    chartContainer.style.minWidth = '0';
    chartContainer.style.minHeight = '0';
    chartContainer.style.display = 'grid';
    chartContainer.style.columnGap = 'var(--space-0500)';
    chartContainer.style.gridTemplateColumns = 'minmax(0, 1fr)';

    const chartAreaContainer = document.createElement('div');
    chartAreaContainer.style.position = 'relative';
    chartAreaContainer.style.height = '100%';
    chartAreaContainer.style.minHeight = '0';
    chartAreaContainer.style.minWidth = '0';
    chartAreaContainer.style.cursor = 'crosshair';

    const toolBarContainer = document.createElement('div');
    toolBarContainer.style.height = '100%';
    toolBarContainer.style.minHeight = '0';
    toolBarContainer.style.overflow = 'hidden';

    const controlBarContainer = document.createElement('div');
    controlBarContainer.style.width = '250px';
    controlBarContainer.style.position = 'absolute';
    controlBarContainer.style.left = '50%';
    controlBarContainer.style.transform = 'translateX(-50%)';
    controlBarContainer.style.bottom = 'var(--space-2000)';
    controlBarContainer.style.zIndex = ZIndex.Base;

    const modalContainer = document.createElement('div');
    modalContainer.className = 'moex-chart-modal-container';
    modalContainer.style.position = 'absolute';
    modalContainer.style.width = '100%';
    modalContainer.style.height = '100%';
    modalContainer.style.maxWidth = '100%';
    modalContainer.style.maxHeight = '100%';
    modalContainer.style.zIndex = ZIndex.Modal;
    modalContainer.style.pointerEvents = 'none';

    chartAreaContainer.append(controlBarContainer, modalContainer);
    chartContainer.append(chartAreaContainer);
    parentContainer.append(headerContainer, chartContainer);

    if (showBottomPanel) {
      parentContainer.append(footerContainer);
    }

    const toggleToolbar = () => {
      const mounted = toolBarContainer.isConnected;

      if (mounted) {
        toolBarContainer.remove();
        chartContainer.style.gridTemplateColumns = 'minmax(0, 1fr)';
        return false;
      }

      chartContainer.insertBefore(toolBarContainer, chartAreaContainer);
      chartContainer.style.gridTemplateColumns = `${toolbarWidth}px minmax(0, 1fr)`;
      return true;
    };

    chartContainer.insertBefore(toolBarContainer, chartAreaContainer);
    chartContainer.style.gridTemplateColumns = `${toolbarWidth}px minmax(0, 1fr)`;

    if (!showMenuButton) {
      toggleToolbar();
    }

    return {
      headerContainer,
      footerContainer,

      chartContainer,
      chartAreaContainer,
      toolBarContainer,

      modalContainer,
      controlBarContainer,

      toggleToolbar,
    };
  }

  /**
   * Очистка контейнеров
   */
  static clearContainers(parentContainer: HTMLElement): void {
    parentContainer.innerHTML = '';
  }

  static createPaneContainers() {
    const legendContainer = document.createElement('div');
    legendContainer.style.width = '80%';
    legendContainer.style.position = 'absolute';
    legendContainer.style.top = '0';
    legendContainer.style.left = '0';
    legendContainer.style.zIndex = ZIndex.Base;
    legendContainer.style.pointerEvents = 'none';

    const paneOverlayContainer = document.createElement('div');
    paneOverlayContainer.className = 'moex-chart-pane-overlay-container';
    paneOverlayContainer.style.position = 'absolute';
    paneOverlayContainer.style.inset = '0';
    paneOverlayContainer.style.zIndex = ZIndex.Base;
    paneOverlayContainer.style.pointerEvents = 'none';

    return {
      legendContainer,
      paneOverlayContainer,
    };
  }
}



import React, { ReactElement } from 'react';
import { createRoot, Root } from 'react-dom/client';

import { UIRenderer } from './UIRenderer';

/**
 * React реализация рендерера UI компонентов
 */
export class ReactRenderer extends UIRenderer {
  protected reactRoot: Root | null = null;
  protected container: HTMLElement;

  constructor(container: HTMLElement) {
    super();
    this.container = container;
  }

  /**
   * Инициализация React рендера
   */
  private initializeReact(): void {
    if (!this.reactRoot) {
      this.reactRoot = createRoot(this.container);
    }
  }

  /**
   * Рендер React компонента
   */
  render(component: React.ReactElement): void {
    if (!this.reactRoot) {
      this.initializeReact();
    }

    this.reactRoot?.render(component);
  }

  /**
   * Очистка React компонентов
   */
  clear = () => {
    const root = this.reactRoot;

    if (!root) return;

    this.reactRoot = null;

    requestAnimationFrame(() => root.unmount());
  };

  /**
   * Проверить, инициализирован ли React
   */
  isInitialized(): boolean {
    return this.reactRoot !== null;
  }

  /**
   * Уничтожение React рендерера
   */
  destroy(): void {
    this.clear();
  }

  /**
   * Рендер UI компонента
   */
  renderComponent(component: ReactElement): void {
    this.render(component);
  }

  /**
   * Очистка UI компонентов
   */
  clearComponents(): void {
    this.clear();
  }
}

import { ReactElement } from 'react';

/**
 * Абстрактный класс для рендеринга UI компонентов
 */
export abstract class UIRenderer {
  /**
   * Рендер компонента
   */
  abstract render(component: ReactElement): void;

  /**
   * Рендер UI компонента
   */
  abstract renderComponent(component: ReactElement): void;

  /**
   * Очистка UI компонентов
   */
  abstract clearComponents(): void;

  /**
   * Очистка всех компонентов
   */
  abstract clear(): void;

  /**
   * Уничтожение рендерера и освобождение ресурсов
   */
  abstract destroy(): void;

  /**
   * Проверка, инициализирован ли рендерер
   */
  abstract isInitialized(): boolean;
}


@use './preflight';

.moex-chart-root {
  font-family: 'Inter', sans-serif;
}

.moex-chart-portal-host {
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 900;
}

.moex-chart-portal-host > * {
  pointer-events: auto;
}

.moex-chart-drawing-tooltip .tooltip-arrow-apca {
  display: none;
}


import { Button } from 'exchange-elements/v2';
import { Observable } from 'rxjs';

import { LongPressButton } from '@components/LongPressButton';

import { Resize } from '@core/Chart';

import { Direction } from '@src/types';
import { useObservable } from '@src/utils';

import { ChevronLeftIcon, ChevronRightIcon, MinusIcon, PlusIcon, RefreshIcon } from '../Icon';

import styles from './index.module.scss';

export const ControlBarDefaults = {
  longPressThreshold: 400,
  onPressButtonRepeatActionThreshold: 100,
  barWidth: 200,
};

interface ControlBarProps {
  scroll: (direction: Direction) => void;
  zoom: (resize: Resize) => void;
  reset: () => void;
  visible: Observable<boolean>;
}

export function ControlBar({ scroll, zoom, reset, visible }: ControlBarProps) {
  const visibleControlBar = useObservable(visible, false);

  return (
    <section className={`${styles.controller} ${visibleControlBar ? styles.controller_visible : ''}`}>
      <div className={styles.group}>
        <Button
          size="sm"
          className={styles.button}
          onClick={() => zoom(Resize.Shrink)}
          label={<MinusIcon />}
        />
        <Button
          size="sm"
          className={styles.button}
          onClick={() => zoom(Resize.Expand)}
          label={<PlusIcon />}
        />
      </div>
      <div className={styles.group}>
        <LongPressButton
          size="sm"
          className={styles.button}
          delayMs={ControlBarDefaults.longPressThreshold}
          onExecute={() => scroll(Direction.Left)}
          refreshMs={ControlBarDefaults.onPressButtonRepeatActionThreshold}
          label={<ChevronLeftIcon />}
        />
        <LongPressButton
          size="sm"
          className={styles.button}
          delayMs={ControlBarDefaults.longPressThreshold}
          onExecute={() => scroll(Direction.Right)}
          refreshMs={ControlBarDefaults.onPressButtonRepeatActionThreshold}
          label={<ChevronRightIcon />}
        />
      </div>
      <Button
        size="sm"
        className={styles.button}
        onClick={reset}
        label={<RefreshIcon />}
      />
    </section>
  );
}