Загрузка данных
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>
);
}