Загрузка данных
import classNames from 'classnames';
import { Button } from 'exchange-elements/v2';
import { SyntheticEvent } from 'react';
import styles from './index.module.scss';
interface PriceScaleControlsProps {
isAutoScale: boolean;
isLogarithmic: boolean;
onAutoScale: () => void;
onToggleLogarithmic: () => void;
onRefresh: () => void;
}
export function PriceScaleControls({
isAutoScale,
isLogarithmic,
onAutoScale,
onToggleLogarithmic,
onRefresh,
}: PriceScaleControlsProps) {
const stopPropagation = (event: SyntheticEvent) => {
event.stopPropagation();
};
return (
<section
className={classNames(styles.root, 'moex-price-scale-controls-content')}
onMouseEnter={onRefresh}
onFocus={onRefresh}
onPointerDown={stopPropagation}
onMouseDown={stopPropagation}
onClick={stopPropagation}
>
<Button
size="sm"
className={classNames(styles.button, {
[styles.pressed]: isAutoScale,
})}
onClick={onAutoScale}
label="A"
/>
<Button
size="sm"
className={classNames(styles.button, {
[styles.pressed]: isLogarithmic,
})}
onClick={onToggleLogarithmic}
label="L"
/>
</section>
);
}
@use '../../theme/mixins' as m;
.root {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-0250);
padding: var(--space-0750) var(--space-0250);
background-color: var(--neutral-0);
pointer-events: none;
.button {
@include m.buttonBase;
min-width: var(--space-1250);
height: var(--space-1500);
flex-shrink: 0;
pointer-events: auto;
}
}
[data-theme='mxt'],
[data-theme='mb'][data-mode='light'] {
--btn-bg-hover: var(--neutral-4);
--btn-text-hover: var(--neutral-13);
--btn-bg-pressed: var(--neutral-5);
--btn-text-pressed: var(--neutral-13);
--btn-bg-selected: var(--neutral-12);
--btn-text-selected: var(--neutral-2);
--btn-svg-color: var(--neutral-2);
--tooltip-bg: var(--neutral-13);
--tooltip-text: var(--neutral-1);
}
[data-theme='tr'],
[data-theme='mb'][data-mode='dark'] {
--btn-bg-hover: var(--neutral-4);
--btn-text-hover: var(--neutral-13);
--btn-bg-pressed: var(--neutral-5);
--btn-text-pressed: var(--neutral-13);
--btn-bg-selected: var(--neutral-5);
--btn-text-selected: var(--neutral-13);
--btn-svg-color: var(--neutral-12);
--tooltip-bg: var(--neutral-6);
--tooltip-text: var(--neutral-13);
}
@mixin buttonBase {
min-width: var(--space-2000);
display: flex;
justify-content: start;
align-items: center;
gap: var(--space-0250);
color: var(--neutral-12);
padding: var(--space-0500);
border-radius: var(--space-0250);
font-size: var(--space-0750);
cursor: pointer;
p {
display: flex;
justify-content: start;
align-items: center;
font-size: var(--space-0750);
gap: var(--space-0500);
padding: 0;
svg {
width: var(--space-1000);
height: var(--space-1000);
}
}
&:hover:not(:disabled):not(.selected):not(.pressed),
&:active:not(:disabled):not(.selected):not(.pressed) {
background-color: var(--btn-bg-hover);
color: var(--btn-text-hover);
}
&.pressed {
background-color: var(--btn-bg-pressed);
color: var(--btn-text-pressed);
}
&.selected {
background-color: var(--btn-bg-selected);
color: var(--btn-text-selected);
}
&:disabled {
background-color: transparent;
color: var(--neutral-9);
cursor: not-allowed;
}
}
@mixin tooltipHint {
background-color: var(--tooltip-bg) !important;
color: var(--tooltip-text) !important;
font-size: var(--space-0750);
div {
border-right-color: var(--tooltip-bg) !important;
}
}
import { IChartApi, IPaneApi, IPriceScaleApi, PriceScaleMode, Time } from 'lightweight-charts';
import { BehaviorSubject, Subscription } from 'rxjs';
import { ChartTooltip } from '@components/ChartTooltip';
import { LegendComponent } from '@components/Legend';
import { ChartMouseEvents } from '@core/ChartMouseEvents';
import { ContainerManager } from '@core/ContainerManager';
import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { DrawingsManager, DrawingsManagerSnapshot } from '@core/DrawingsManager';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { Legend } from '@core/Legend';
import { ReactRenderer } from '@core/ReactRenderer';
import { TooltipService } from '@core/Tooltip';
import { UIRenderer } from '@core/UIRenderer';
import { EntitySettingsModal } from '@src/components/EntitySettingsModal';
import { DrawingsNames, indicatorLabelById, MAIN_PANE_INDEX } from '@src/constants';
import { ModalRenderer } from '@src/core/ModalRenderer';
import { PriceScaleControlsController, PriceScaleTarget } from '@src/core/PriceScale';
import { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { t } from '@src/translations';
import { Direction, OHLCConfig, TooltipConfig } from '@src/types';
import { DOMObjectSnapshot, IndicatorSnapshot, ISerializable, PaneSnapshot } from '@src/types/snapshot';
import { ensureDefined } from '@src/utils';
export interface PaneParams {
id: number;
lwcChart: IChartApi;
eventManager: EventManager;
DOM: DOMModel;
isMainPane: boolean;
ohlcConfig: OHLCConfig;
dataSource: DataSource | null; // todo: deal with dataSource. На каких то пейнах он нужен, на каких то нет
basedOn?: Pane; // Pane на котором находится главная серия, или серия, по которой строятся серии на текущем пейне
subscribeChartEvent: ChartMouseEvents['subscribe'];
tooltipConfig: TooltipConfig;
onDelete: () => void;
chartContainer: HTMLElement;
modalRenderer: ModalRenderer;
getSavedPriceScaleMode: (target: PriceScaleTarget) => PriceScaleMode;
togglePriceScaleMode: (target: PriceScaleTarget, mode: PriceScaleMode) => void;
}
// todo: Pane, ему должна принадлежать mainSerie, а также IndicatorManager и drawingsManager, mouseEvents. Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
// todo: Учитывать, что есть линейка, которая рисуется одна для всех пейнов
// todo: в CompareManage, при создании нового пейна для сравнения - инициализируем новый dataSource, принадлежащий только конкретному пейну. Убираем возможность добавлять индикаторы на такие пейны
// todo: на каждый символ свой DataSource (учитывать что есть MainPane и "главный" DataSource, который инициализиурется во время старта moexChart)
// todo: сделать два разных представления для compare, в зависимости от отображения на главном пейне или на второстепенном
export class Pane implements ISerializable<PaneSnapshot> {
private readonly id: number;
private isMain: boolean;
private mainSeries: BehaviorSubject<SeriesStrategies | null> = new BehaviorSubject<SeriesStrategies | null>(null); // Main Series. Exists in a single copy
private legend!: Legend;
private tooltip: TooltipService | undefined;
private indicatorsMap: BehaviorSubject<Map<string, Indicator>> = new BehaviorSubject<Map<string, Indicator>>(
new Map(),
);
private lwcPane: IPaneApi<Time>;
private lwcChart: IChartApi;
private eventManager: EventManager;
private drawingsManager: DrawingsManager;
private legendContainer!: HTMLElement;
private paneOverlayContainer!: HTMLElement;
private legendRenderer!: UIRenderer;
private tooltipRenderer: UIRenderer | undefined;
private modalRenderer: ModalRenderer;
private priceScaleControlsController!: PriceScaleControlsController;
private mainSerieSub!: Subscription;
private subscribeChartEvent: ChartMouseEvents['subscribe'];
private onDelete: () => void;
private subscriptions = new Subscription();
private paneContainerSyncFrameId: number | null = null;
constructor({
lwcChart,
eventManager,
dataSource,
DOM,
isMainPane,
ohlcConfig,
id,
basedOn,
subscribeChartEvent,
tooltipConfig,
onDelete,
chartContainer,
modalRenderer,
getSavedPriceScaleMode,
togglePriceScaleMode,
}: PaneParams) {
this.onDelete = onDelete;
this.eventManager = eventManager;
this.lwcChart = lwcChart;
this.modalRenderer = modalRenderer;
this.subscribeChartEvent = subscribeChartEvent;
this.isMain = isMainPane ?? false;
this.id = id;
if (isMainPane) {
this.lwcPane = this.lwcChart.panes()[MAIN_PANE_INDEX];
} else {
this.lwcPane = this.lwcChart.addPane(true);
}
this.priceScaleControlsController = new PriceScaleControlsController({
getPaneElement: () => this.lwcPane.getHTMLElement(),
getPriceScale: (priceScaleId) => this.getPriceScale(priceScaleId),
getPriceScaleTarget: (priceScaleId) => this.getPriceScaleTarget(priceScaleId),
hasPriceScaleSeries: (priceScaleId) => this.hasPriceScaleSeries(priceScaleId),
getSavedPriceScaleMode,
togglePriceScaleMode,
});
this.initializeLegend({ ohlcConfig });
this.tooltip = new TooltipService({
config: tooltipConfig,
legend: this.legend,
paneOverlayContainer: this.paneOverlayContainer,
});
this.tooltipRenderer = new ReactRenderer(this.paneOverlayContainer);
this.tooltipRenderer.renderComponent(
<ChartTooltip
formatObs={this.eventManager.getChartOptionsModel()}
timeframeObs={this.eventManager.getTimeframeObs()}
viewModel={this.tooltip.getTooltipViewModel()}
// ohlcConfig={this.legend.getConfig()}
ohlcConfig={ohlcConfig}
tooltipConfig={this.tooltip.getConfig()}
/>,
);
if (dataSource) {
this.initializeMainSerie({ lwcChart, dataSource });
} else if (basedOn) {
this.mainSeries = basedOn?.getMainSerie();
} else {
console.error('[Pane]: There is no any mainSerie for new pane');
}
this.drawingsManager = new DrawingsManager({
// todo: менеджер дровингов должен быть один на чарт, не на пейн
eventManager,
DOM,
mainSeries$: this.mainSeries.asObservable(),
lwcChart,
container: chartContainer,
modalRenderer: this.modalRenderer,
paneId: this.id,
});
this.subscriptions.add(
this.drawingsManager.entities().subscribe((drawings) => {
const hasRuler = drawings.some((drawing) => drawing.getDrawingName() === DrawingsNames.ruler);
this.legendContainer.style.display = hasRuler ? 'none' : '';
}),
);
}
public isMainPane = () => {
return this.isMain;
};
public getDrawingsSnapshot(): DrawingsManagerSnapshot {
return this.drawingsManager.getSnapshot();
}
public setDrawingsSnapshot(snapshot: DrawingsManagerSnapshot): void {
this.drawingsManager.setSnapshot(snapshot);
}
public getMainSerie = () => {
return this.mainSeries;
};
public getId = () => {
return this.id;
};
public paneIndex = () => {
return this.lwcPane.paneIndex();
};
public getPriceScale(priceScaleId: string): IPriceScaleApi {
return this.lwcChart.priceScale(priceScaleId, this.paneIndex());
}
public getPriceScaleTarget(priceScaleId: string): PriceScaleTarget {
return {
paneId: this.id,
priceScaleId,
};
}
public hasPriceScaleSeries(priceScaleId: Direction): boolean {
if (priceScaleId === Direction.Right && this.mainSeries.value) {
return true;
}
const indicators = Array.from(this.indicatorsMap.value.values());
for (let i = 0; i < indicators.length; i += 1) {
const series = Array.from(indicators[i].getSeriesMap().values());
for (let j = 0; j < series.length; j += 1) {
if (this.isSeriesOnPriceScale(series[j], priceScaleId)) {
return true;
}
}
}
return false;
}
private isSeriesOnPriceScale(series: SeriesStrategies, priceScaleId: Direction): boolean {
const options = series.options();
const seriesPriceScaleId = options.priceScaleId ?? Direction.Right;
return options.visible !== false && seriesPriceScaleId === priceScaleId;
}
public setIndicator(indicatorId: string, indicator: Indicator): void {
const map = this.indicatorsMap.value;
map.set(indicatorId, indicator);
this.indicatorsMap.next(map);
}
public removeIndicator(indicatorId: string): void {
const map = this.indicatorsMap.value;
map.delete(indicatorId);
this.indicatorsMap.next(map);
if (map.size === 0 && !this.isMain) {
this.onDelete();
}
}
public getDrawingManager(): DrawingsManager {
return this.drawingsManager;
}
private initializeLegend({ ohlcConfig }: { ohlcConfig: OHLCConfig }) {
const { legendContainer, paneOverlayContainer } = ContainerManager.createPaneContainers();
this.legendContainer = legendContainer;
this.paneOverlayContainer = paneOverlayContainer;
this.legendRenderer = new ReactRenderer(legendContainer);
this.schedulePaneContainerSync();
// todo: переписать код ниже под логику пейнов
// /*
// Внутри lightweight-chart DOM построен как таблица из 3 td
// [0] left priceScale, [1] center chart, [2] right priceScale
// Кладём легенду в td[1] и тогда легенда сама будет адаптироваться при изменении ширины шкал
// */
// requestAnimationFrame(() => {
// const root = chartAreaContainer.querySelector('.tv-lightweight-charts');
// console.log(root)
// const table = root?.querySelector('table');
// console.log(table)
//
// const htmlCollectionOfPanes = table?.getElementsByTagName('td')
// console.log(htmlCollectionOfPanes)
//
// const centerId = htmlCollectionOfPanes?.[1];
// console.log(centerId)
//
// if (centerId && legendContainer && legendContainer.parentElement !== centerId) {
// centerId.appendChild(legendContainer);
// }
// });
// /*
// Внутри lightweight-chart DOM построен как таблица из 3 td
// [0] left priceScale, [1] center chart, [2] right priceScale
// Кладём легенду в td[1] и тогда легенда сама будет адаптироваться при изменении ширины шкал
// */
// requestAnimationFrame(() => {
// const root = chartAreaContainer.querySelector('.tv-lightweight-charts');
// const table = root?.querySelector('table');
// const centerId = table?.getElementsByTagName('td')?.[1];
//
// if (centerId && legendContainer && legendContainer.parentElement !== centerId) {
// centerId.appendChild(legendContainer);
// }
// });
this.legend = new Legend({
config: ohlcConfig,
indicators: this.indicatorsMap,
eventManager: this.eventManager,
subscribeChartEvent: this.subscribeChartEvent,
mainSeries: this.isMain ? this.mainSeries : null,
paneId: this.id,
paneIndex: this.paneIndex,
openIndicatorSettings: (indicatorId, indicator) => {
let settings = indicator.getSettings();
this.modalRenderer.renderComponent(
<EntitySettingsModal
tabs={[{ key: 'arguments', label: t('Arguments'), fields: indicator.getSettingsConfig() }]}
values={settings}
onChange={(nextSettings) => {
settings = nextSettings;
}}
initialTabKey="arguments"
/>,
{
size: 'sm',
title: indicatorLabelById()[indicatorId],
onSave: () => indicator.updateSettings(settings),
},
);
},
// todo: throw isMainPane
});
this.legendRenderer.renderComponent(
<LegendComponent
ohlcConfig={this.legend.getConfig()}
viewModel={this.legend.getLegendViewModel()}
/>,
);
}
private initializeMainSerie({ lwcChart, dataSource }: { lwcChart: IChartApi; dataSource: DataSource }) {
this.mainSerieSub = this.eventManager.subscribeSeriesSelected((nextSeries) => {
this.mainSeries.value?.destroy();
const next = ensureDefined(SeriesFactory.create(nextSeries))({
lwcChart,
dataSource,
mainSymbol$: this.eventManager.getSymbol(),
mainSerie$: this.mainSeries,
});
this.mainSeries.next(next);
});
}
public schedulePaneContainerSync(): void {
if (this.paneContainerSyncFrameId !== null) {
return;
}
this.paneContainerSyncFrameId = requestAnimationFrame(() => {
this.paneContainerSyncFrameId = null;
this.syncPaneContainers();
});
}
private syncPaneContainers(): void {
const lwcPaneElement = this.lwcPane.getHTMLElement();
if (!lwcPaneElement) return;
lwcPaneElement.style.position = 'relative';
lwcPaneElement.appendChild(this.legendContainer);
lwcPaneElement.appendChild(this.paneOverlayContainer);
this.priceScaleControlsController.mount();
}
public getSnapshot(): PaneSnapshot {
const indicators: (DOMObjectSnapshot & IndicatorSnapshot)[] = [];
this.indicatorsMap.value.forEach((ind) => {
indicators.push(ind.getSnapshot());
});
const snap = {
isMain: this.isMain,
id: this.id,
indicators,
drawings: this.getDrawingsSnapshot(),
};
return snap;
}
public updatePriceScaleControls(): void {
this.priceScaleControlsController.update();
}
public resetPriceScalesAutoScale(): void {
this.priceScaleControlsController.resetAutoScale();
}
public destroy() {
if (this.paneContainerSyncFrameId !== null) {
cancelAnimationFrame(this.paneContainerSyncFrameId);
this.paneContainerSyncFrameId = null;
}
this.subscriptions.unsubscribe();
this.tooltip?.destroy();
this.legend?.destroy();
this.legendRenderer.destroy();
this.tooltipRenderer?.destroy();
this.legendContainer.remove();
this.paneOverlayContainer.remove();
this.priceScaleControlsController.destroy();
this.indicatorsMap.complete();
this.mainSerieSub?.unsubscribe();
if (this.isMain) {
this.mainSeries.value?.destroy();
this.mainSeries?.complete();
}
const paneIndex = this.paneIndex();
if (paneIndex >= 0) {
try {
this.lwcChart.removePane(paneIndex);
} catch (e) {
console.log(e);
}
}
}
}
import { DataSource } from '@core/DataSource';
import { DrawingsManager, DrawingsManagerSnapshot } from '@core/DrawingsManager';
import { Pane, PaneParams } from '@core/Pane';
import { ISerializable, PaneSnapshot } from '@src/types/snapshot';
interface PaneManagerParams extends Omit<PaneParams, 'isMainPane' | 'id' | 'basedOn' | 'onDelete'> {
panesSnapshot: PaneSnapshot[];
}
// todo: PaneManager, регулирует порядок пейнов. Знает про MainPane.
// todo: Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
// todo: в CompareManage, при создании нового пейна для сравнения - инициализируем новый dataSource, принадлежащий только конкретному пейну. Убираем возможность добавлять индикаторы на такие пейны
// todo: на каждый символ свой DataSource (учитывать что есть MainPane и "главный" DataSource, который инициализиурется во время старта moexChart)
// todo: сделать два разных представления для compare, в зависимости от отображения на главном пейне или на второстепенном
export class PaneManager implements ISerializable<PaneSnapshot[]> {
private mainPane: Pane;
private paneChartInheritedParams: PaneManagerParams & { isMainPane: boolean };
private panesMap: Map<number, Pane> = new Map<number, Pane>();
private nextPaneId = 0;
constructor(params: PaneManagerParams) {
this.paneChartInheritedParams = { ...params, isMainPane: false };
this.mainPane = new Pane({ ...params, isMainPane: true, id: 0, onDelete: () => {} });
this.panesMap.set(this.nextPaneId++, this.mainPane);
this.setup(params.panesSnapshot);
}
private setup(panesSnapshot: PaneSnapshot[]) {
panesSnapshot.forEach((paneSnap: PaneSnapshot) => {
const { isMain, id, drawings } = paneSnap;
this.destroyPane(id, false);
if (isMain) {
this.mainPane = new Pane({ ...this.paneChartInheritedParams, isMainPane: true, id: 0, onDelete: () => {} });
this.panesMap.set(id, this.mainPane);
this.mainPane.setDrawingsSnapshot(drawings);
} else {
const pane = this.addPane(undefined, id);
pane.setDrawingsSnapshot(drawings);
}
});
this.syncPaneContainers();
}
public getPaneById(id: number): Pane | undefined {
return this.panesMap.get(id);
}
public getDrawingsSnapshot(): DrawingsManagerSnapshot {
return this.mainPane.getDrawingsSnapshot();
}
public setDrawingsSnapshot(snapshot: DrawingsManagerSnapshot): void {
this.mainPane.setDrawingsSnapshot(snapshot);
}
public getPanes() {
return this.panesMap;
}
public getMainPane: () => Pane = () => {
return this.mainPane;
};
private destroyPane(id: number, syncAfter = true): void {
const pane = this.panesMap.get(id);
if (!pane) {
return;
}
this.panesMap.delete(id);
pane.destroy();
if (syncAfter) {
this.syncPaneContainers();
}
}
public addPane(dataSource?: DataSource, paneId?: number): Pane {
const id = paneId ?? this.nextPaneId++;
this.nextPaneId = Math.max(this.nextPaneId, id + 1);
const newPane = new Pane({
...this.paneChartInheritedParams,
id,
dataSource: dataSource ?? null,
basedOn: dataSource ? undefined : this.mainPane,
onDelete: () => this.destroyPane(id),
});
this.panesMap.set(id, newPane);
this.syncPaneContainers();
return newPane;
}
private syncPaneContainers(): void {
this.panesMap.forEach((pane) => {
pane.schedulePaneContainerSync();
});
}
public updatePriceScaleControls(): void {
this.panesMap.forEach((pane) => {
pane.updatePriceScaleControls();
});
}
public resetPriceScalesAutoScale(): void {
this.panesMap.forEach((pane) => {
pane.resetPriceScalesAutoScale();
});
this.updatePriceScaleControls();
}
public getDrawingsManager(): DrawingsManager {
// todo: temp
return this.mainPane.getDrawingManager();
}
public getSnapshot(): PaneSnapshot[] {
const res: PaneSnapshot[] = [];
this.panesMap.forEach((pane) => {
res.push(pane.getSnapshot());
});
return res;
}
}
import dayjs from 'dayjs';
import {
BarPrice,
ChartOptions,
createChart,
CrosshairMode,
DeepPartial,
IChartApi,
IRange,
LocalizationOptionsBase,
LogicalRange,
PriceScaleMode,
Time,
UTCTimestamp,
} from 'lightweight-charts';
import flatten from 'lodash-es/flatten';
import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
import { map, withLatestFrom } from 'rxjs/operators';
import { ChartMouseEvents } from '@core/ChartMouseEvents';
import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { DrawingsManager } from '@core/DrawingsManager';
import { EventManager } from '@core/EventManager';
import { IndicatorManager } from '@core/IndicatorManager';
import { ModalRenderer } from '@core/ModalRenderer';
import { PaneManager } from '@core/PaneManager';
import { PriceAxisLabelsController } from '@core/PriceAxisLabels';
import { CompareManager } from '@src/core/CompareManager';
import { PriceScaleModeController, PriceScaleModeSnapshot, PriceScaleTarget } from '@src/core/PriceScale';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { getThemeStore } from '@src/theme/store';
import { ThemeKey, ThemeMode } from '@src/theme/types';
import { getLocale } from '@src/translations';
import {
Candle,
ChartOptionsModel,
ChartSeriesType,
ChartTypeOptions,
Direction,
OHLCConfig,
TooltipConfig,
} from '@src/types';
import { Defaults } from '@src/types/defaults';
import { DayjsOffset, Intervals, intervalsToDayjs } from '@src/types/intervals';
import { ChartSnapshot, ISerializable, PaneSnapshot } from '@src/types/snapshot';
import { formatCompactNumber } from '@src/utils';
import { createTickMarkFormatter, formatDate } from '@src/utils/formatter';
export interface ChartConfig extends Partial<ChartOptionsModel> {
container: HTMLElement;
seriesTypes: ChartSeriesType[];
theme: ThemeKey;
mode?: ThemeMode;
chartOptions?: ChartTypeOptions;
localization?: LocalizationOptionsBase;
}
export enum Resize {
Shrink,
Expand,
}
const HISTORY_LOAD_THRESHOLD = 50;
interface ChartParams {
params: {
dataSource: DataSource;
eventManager: EventManager;
modalRenderer: ModalRenderer;
ohlcConfig: OHLCConfig;
tooltipConfig: TooltipConfig;
panes: PaneSnapshot[];
priceScaleModes?: PriceScaleModeSnapshot[];
};
lwcChartConfig: ChartConfig;
}
/**
* Абстракция над библиотекой для построения графиков
*/
export class Chart implements ISerializable<ChartSnapshot> {
private lwcChart!: IChartApi;
private container: HTMLElement;
private eventManager: EventManager;
private paneManager!: PaneManager;
private compareManager: CompareManager;
private mouseEvents: ChartMouseEvents;
private indicatorManager: IndicatorManager;
private priceAxisLabelsController: PriceAxisLabelsController;
private priceScaleModeController: PriceScaleModeController;
private optionsSubscription: Subscription;
private dataSource: DataSource;
private chartConfig: Omit<ChartConfig, 'theme' | 'mode'>;
private mainSeries: BehaviorSubject<SeriesStrategies | null>; // Main Series. Exists in a single copy
private DOM: DOMModel;
private isPointerDown = false;
private didResetOnDrag = false;
private subscriptions = new Subscription();
private currentInterval: Intervals | null = null;
private activeSymbols: string[] = [];
private historyBatchRunning = false;
constructor({ params, lwcChartConfig }: ChartParams) {
const {
eventManager,
dataSource,
modalRenderer,
ohlcConfig,
tooltipConfig,
panes: panesSnapshot,
priceScaleModes = [],
} = params;
this.eventManager = eventManager;
this.dataSource = dataSource;
this.container = lwcChartConfig.container;
this.chartConfig = lwcChartConfig;
this.lwcChart = createChart(this.container, getOptions(lwcChartConfig));
this.optionsSubscription = this.eventManager
.getChartOptionsModel()
.subscribe(({ dateFormat, timeFormat, showTime }) => {
const configToApply = { ...lwcChartConfig, dateFormat, timeFormat, showTime };
this.lwcChart.applyOptions({
...getOptions(configToApply),
localization: {
timeFormatter: (time: UTCTimestamp) => formatDate(time, dateFormat, timeFormat, showTime),
},
});
});
this.subscriptions.add(this.optionsSubscription);
this.mouseEvents = new ChartMouseEvents({ lwcChart: this.lwcChart, container: this.container });
this.mouseEvents.subscribe('wheel', this.onWheel);
this.mouseEvents.subscribe('pointerDown', this.onPointerDown);
this.mouseEvents.subscribe('pointerMove', this.onPointerMove);
this.mouseEvents.subscribe('pointerUp', this.onPointerUp);
this.mouseEvents.subscribe('pointerCancel', this.onPointerUp);
this.DOM = new DOMModel({ modalRenderer });
this.priceScaleModeController = new PriceScaleModeController({
getPaneById: (paneId) => this.paneManager?.getPaneById(paneId),
initialModes: priceScaleModes,
onChange: () => {
this.priceAxisLabelsController?.invalidate();
this.paneManager?.updatePriceScaleControls();
},
});
this.paneManager = new PaneManager({
eventManager: this.eventManager,
panesSnapshot,
lwcChart: this.lwcChart,
dataSource,
DOM: this.DOM,
ohlcConfig,
subscribeChartEvent: this.subscribeChartEvent,
chartContainer: this.container,
tooltipConfig,
modalRenderer,
getSavedPriceScaleMode: (target) => this.priceScaleModeController.getSavedMode(target),
togglePriceScaleMode: (target, mode) => this.priceScaleModeController.toggleSavedMode(target, mode),
});
this.mainSeries = this.paneManager.getMainPane().getMainSerie();
this.indicatorManager = new IndicatorManager({
eventManager,
initialIndicators: flatten(
panesSnapshot.map((pane) => pane.indicators.map((ind) => ({ ...ind, paneId: pane.id }))),
),
DOM: this.DOM,
dataSource: this.dataSource,
lwcChart: this.lwcChart,
paneManager: this.paneManager,
chartOptions: lwcChartConfig.chartOptions,
});
this.compareManager = new CompareManager({
chart: this.lwcChart,
initialIndicators: flatten(
panesSnapshot.map((pane) => pane.indicators.map((ind) => ({ ...ind, paneId: pane.id }))),
),
eventManager: this.eventManager,
dataSource: this.dataSource,
indicatorManager: this.indicatorManager,
paneManager: this.paneManager,
priceScaleModeController: this.priceScaleModeController,
});
this.priceAxisLabelsController = new PriceAxisLabelsController({
mainSeries$: this.paneManager.getMainPane().getMainSerie().asObservable(),
mainSymbol$: this.eventManager.symbol(),
compareEntities$: this.compareManager.entities(),
indicatorEntities$: this.indicatorManager.entities(),
});
this.priceAxisLabelsController.setVisibleLogicalRange(this.lwcChart.timeScale().getVisibleLogicalRange());
this.priceScaleModeController.applyAll();
this.paneManager.updatePriceScaleControls();
this.setupDataSourceSubs();
this.setupHistoricalDataLoading();
}
public getPriceScaleWidth(direction: Direction): number {
try {
const priceScale = this.lwcChart.priceScale(direction);
return priceScale ? priceScale.width() : 0;
} catch {
return 0;
}
}
public getDrawingsManager = (): DrawingsManager => {
return this.paneManager.getDrawingsManager();
};
public getIndicatorManager = (): IndicatorManager => {
return this.indicatorManager;
};
private onWheel = () => {
this.eventManager.resetInterval({ history: false });
};
private onPointerDown = () => {
this.isPointerDown = true;
this.didResetOnDrag = false;
};
private onPointerMove = () => {
if (!this.isPointerDown) return;
if (this.didResetOnDrag) return;
this.didResetOnDrag = true;
this.eventManager.resetInterval({ history: false });
};
private onPointerUp = () => {
this.isPointerDown = false;
};
public getDom(): DOMModel {
return this.DOM;
}
public getMainSeries(): Observable<SeriesStrategies | null> {
return this.mainSeries.asObservable();
}
public getCompareManager(): CompareManager {
return this.compareManager;
}
public getPriceScaleMode(target: PriceScaleTarget): PriceScaleMode {
return this.priceScaleModeController.getActiveMode(target);
}
public setPriceScaleMode(target: PriceScaleTarget, mode: PriceScaleMode): void {
this.priceScaleModeController.setSavedMode(target, mode);
}
public togglePriceScaleMode(target: PriceScaleTarget, mode: PriceScaleMode): void {
this.priceScaleModeController.toggleSavedMode(target, mode);
}
public getMainPriceScaleTarget(): PriceScaleTarget {
return this.paneManager.getMainPane().getPriceScaleTarget(Direction.Right);
}
public updateTheme(theme: ThemeKey, mode: ThemeMode) {
this.lwcChart.applyOptions(getOptions({ ...this.chartConfig, theme, mode }));
this.priceAxisLabelsController.invalidate();
}
public destroy(): void {
this.priceAxisLabelsController.destroy();
this.priceScaleModeController.destroy();
this.mouseEvents.destroy();
this.compareManager.clear();
this.subscriptions.unsubscribe();
this.lwcChart.remove();
}
public subscribeChartEvent: ChartMouseEvents['subscribe'] = (event, callback) =>
this.mouseEvents.subscribe(event, callback);
public unsubscribeChartEvent: ChartMouseEvents['unsubscribe'] = (event, callback) => {
this.mouseEvents.unsubscribe(event, callback);
};
// todo: add/move to undo/redo model(eventManager)
public scrollTimeScale = (direction: Direction) => {
this.eventManager.resetInterval({ history: false });
const diff = direction === Direction.Left ? -2 : 2;
const currentPosition = this.lwcChart.timeScale().scrollPosition();
this.lwcChart.timeScale().scrollToPosition(currentPosition + diff, false);
};
// todo: add/move to undo/redo model(eventManager)
public zoomTimeScale = (resize: Resize) => {
this.eventManager.resetInterval({ history: false });
const diff = resize === Resize.Shrink ? -1 : 1;
const currentRange = this.lwcChart.timeScale().getVisibleRange();
if (!currentRange) return;
const { from, to } = currentRange as IRange<number>;
if (!from || !to) return;
const next: IRange<Time> = {
from: (from + (to - from) * 0.1 * diff) as Time,
to: to as Time,
};
this.lwcChart.timeScale().setVisibleRange(next);
};
// todo: add to undo/redo model(eventManager)
public resetZoom = () => {
this.eventManager.resetInterval({ history: false });
this.lwcChart.timeScale().resetTimeScale();
this.paneManager.resetPriceScalesAutoScale();
};
public getRealtimeApi() {
return {
getTimeframe: () => this.eventManager.getTimeframe(),
getSymbols: () => this.activeSymbols,
update: (symbol: string, candle: Candle) => {
this.dataSource.updateRealtime(symbol, candle);
},
};
}
public getSnapshot(): ChartSnapshot {
return {
panes: this.paneManager.getSnapshot(),
chartSeriesType: this.eventManager.exportChartSettings().seriesSelected,
timeframe: this.eventManager.exportChartSettings().timeframe,
symbol: this.activeSymbols[0],
priceScaleModes: this.priceScaleModeController.getSnapshot(),
};
}
private scheduleHistoryBatch = () => {
if (this.historyBatchRunning) return;
this.historyBatchRunning = true;
requestAnimationFrame(() => {
const symbols = this.activeSymbols.slice();
Promise.all(symbols.map((s) => this.dataSource.loadMoreHistory(s))).finally(() => {
this.historyBatchRunning = false;
const range = this.lwcChart.timeScale().getVisibleLogicalRange();
if (range && range.from < HISTORY_LOAD_THRESHOLD) {
this.scheduleHistoryBatch();
}
});
});
};
private setupDataSourceSubs() {
const getWarmupFrom = (): number => {
if (this.currentInterval && this.currentInterval !== Intervals.All) {
return getIntervalRange(this.currentInterval).from;
}
const range = this.lwcChart.timeScale().getVisibleRange();
if (!range) return 0;
const { from } = range as IRange<number>;
return from as number;
};
const warmupSymbols = (symbols: string[]) => {
if (!symbols.length) return;
const from = getWarmupFrom();
if (!from) return;
Promise.all(symbols.map((symbol) => this.dataSource.loadTill(symbol, from))).catch((error) => {
console.error('[Chart] Ошибка при прогреве символов:', error);
});
};
const symbols$ = combineLatest([this.eventManager.symbol(), this.compareManager.itemsObs()]).pipe(
map(([main, items]) => Array.from(new Set([main, ...items.map((i) => i.symbol)]))),
);
this.subscriptions.add(
this.eventManager
.getInterval()
.pipe(withLatestFrom(symbols$))
.subscribe(([interval, symbols]) => {
this.currentInterval = interval;
if (!interval) return;
if (interval === Intervals.All) {
Promise.all(symbols.map((s) => this.dataSource.loadAllHistory(s)))
.then(() => {
requestAnimationFrame(() => this.lwcChart.timeScale().fitContent());
})
.catch((error) => console.error('[Chart] Ошибка при загрузке всей истории:', error));
return;
}
const { from, to } = getIntervalRange(interval);
Promise.all(symbols.map((s) => this.dataSource.loadTill(s, from)))
.then(() => {
this.lwcChart.timeScale().setVisibleRange({ from: from as Time, to: to as Time });
})
.catch((error) => {
console.error('[Chart] Ошибка при применении интервала:', error);
});
}),
);
this.subscriptions.add(
symbols$.subscribe((symbols) => {
const prevSymbols = this.activeSymbols;
this.activeSymbols = symbols;
this.dataSource.setSymbols(symbols);
const prevSet = new Set(prevSymbols);
const added: string[] = [];
for (let i = 0; i < symbols.length; i += 1) {
const s = symbols[i];
if (!s) continue;
if (prevSet.has(s)) continue;
added.push(s);
}
if (added.length) {
warmupSymbols(added);
}
}),
);
}
private setupHistoricalDataLoading(): void {
// todo (не)вызвать loadMoreHistory после проверки на необходимость дозагрузки после смены таймфрейма
this.mouseEvents.subscribe('visibleLogicalRangeChange', (logicalRange: LogicalRange | null) => {
this.priceAxisLabelsController.setVisibleLogicalRange(logicalRange);
if (!logicalRange) return;
if (this.currentInterval === Intervals.All) return;
const needsMoreData = logicalRange.from < HISTORY_LOAD_THRESHOLD;
if (!needsMoreData) return;
this.scheduleHistoryBatch();
});
}
}
function getIntervalRange(interval: Intervals): { from: number; to: number } {
const { value, unit } = intervalsToDayjs[interval] as DayjsOffset;
const from = Math.floor(dayjs().subtract(value, unit).valueOf() / 1000);
const to = Math.floor(dayjs().valueOf() / 1000);
return { from, to };
}
function getOptions(config: ChartConfig): DeepPartial<ChartOptions> {
const timeFormat = config.timeFormat ?? Defaults.timeFormat;
const showTime = config.showTime ?? Defaults.showTime;
const use12HourFormat = timeFormat === '12h';
const timeFormatString = use12HourFormat ? 'h:mm A' : 'HH:mm';
const { colors } = getThemeStore();
const localization: LocalizationOptionsBase = {
locale: getLocale(),
priceFormatter: (priceValue: BarPrice) => {
return formatCompactNumber(priceValue);
},
};
return {
width: config.container.clientWidth,
height: config.container.clientHeight,
autoSize: true,
layout: {
background: { color: colors.chartBackground },
textColor: colors.chartTextPrimary,
},
grid: {
vertLines: { color: colors.chartGridLine },
horzLines: { color: colors.chartGridLine },
},
crosshair: {
mode: CrosshairMode.Normal,
vertLine: { color: colors.chartCrosshairLine, labelBackgroundColor: colors.chartCrosshairLabel, style: 0 },
horzLine: { color: colors.chartCrosshairLine, labelBackgroundColor: colors.chartCrosshairLabel, style: 2 },
},
timeScale: {
timeVisible: showTime,
secondsVisible: false,
tickMarkFormatter: createTickMarkFormatter(timeFormatString),
borderVisible: false,
allowBoldLabels: false,
rightOffset: 25,
},
rightPriceScale: {
textColor: colors.chartTextPrimary,
borderVisible: false,
},
localization,
};
}
import { PriceScaleMode } from 'lightweight-charts';
import type { Pane } from '@core/Pane';
export interface PriceScaleTarget {
paneId: number;
priceScaleId: string;
}
export interface PriceScaleModeSnapshot extends PriceScaleTarget {
mode: PriceScaleMode;
}
interface PriceScaleModeControllerParams {
getPaneById: (paneId: number) => Pane | undefined;
initialModes?: PriceScaleModeSnapshot[];
onChange?: () => void;
}
export class PriceScaleModeController {
private readonly getPaneById: (paneId: number) => Pane | undefined;
private readonly onChange?: () => void;
private readonly savedModes = new Map<number, Map<string, PriceScaleMode>>();
private readonly overrideModes = new Map<number, Map<string, PriceScaleMode>>();
constructor({ getPaneById, initialModes = [], onChange }: PriceScaleModeControllerParams) {
this.getPaneById = getPaneById;
this.onChange = onChange;
this.setSnapshot(initialModes);
}
public getActiveMode(target: PriceScaleTarget): PriceScaleMode {
return this.getOverrideMode(target) ?? this.getSavedMode(target);
}
public getSavedMode(target: PriceScaleTarget): PriceScaleMode {
return this.savedModes.get(target.paneId)?.get(target.priceScaleId) ?? PriceScaleMode.Normal;
}
public setSavedMode(target: PriceScaleTarget, mode: PriceScaleMode): void {
if (this.getSavedMode(target) === mode) {
return;
}
this.setMode(this.savedModes, target, mode);
this.applyMode(target);
this.onChange?.();
}
public toggleSavedMode(target: PriceScaleTarget, mode: PriceScaleMode): void {
this.setSavedMode(target, this.getSavedMode(target) === mode ? PriceScaleMode.Normal : mode);
}
public setOverrideMode(target: PriceScaleTarget, mode: PriceScaleMode): void {
if (mode === PriceScaleMode.Normal) {
this.clearOverrideMode(target);
return;
}
if (this.getOverrideMode(target) === mode) {
return;
}
this.setMode(this.overrideModes, target, mode);
this.applyMode(target);
this.onChange?.();
}
public clearOverrideMode(target: PriceScaleTarget): void {
if (!this.getOverrideMode(target)) {
return;
}
this.removeMode(this.overrideModes, target);
this.applyMode(target);
this.onChange?.();
}
public applyAll(): void {
this.getTargets(this.savedModes).forEach((target) => this.applyMode(target));
this.getTargets(this.overrideModes).forEach((target) => this.applyMode(target));
}
public getSnapshot(): PriceScaleModeSnapshot[] {
const snapshot: PriceScaleModeSnapshot[] = [];
this.savedModes.forEach((paneModes, paneId) => {
if (!this.getPaneById(paneId)) {
return;
}
paneModes.forEach((mode, priceScaleId) => {
snapshot.push({
paneId,
priceScaleId,
mode,
});
});
});
return snapshot;
}
public setSnapshot(snapshot: PriceScaleModeSnapshot[]): void {
this.savedModes.clear();
snapshot.forEach(({ paneId, priceScaleId, mode }) => {
this.setMode(this.savedModes, { paneId, priceScaleId }, mode);
});
}
public destroy(): void {
this.savedModes.clear();
this.overrideModes.clear();
}
private getOverrideMode(target: PriceScaleTarget): PriceScaleMode | undefined {
return this.overrideModes.get(target.paneId)?.get(target.priceScaleId);
}
private setMode(
modes: Map<number, Map<string, PriceScaleMode>>,
target: PriceScaleTarget,
mode: PriceScaleMode,
): void {
if (mode === PriceScaleMode.Normal) {
this.removeMode(modes, target);
return;
}
const paneModes = modes.get(target.paneId) ?? new Map<string, PriceScaleMode>();
paneModes.set(target.priceScaleId, mode);
modes.set(target.paneId, paneModes);
}
private removeMode(modes: Map<number, Map<string, PriceScaleMode>>, target: PriceScaleTarget): void {
const paneModes = modes.get(target.paneId);
paneModes?.delete(target.priceScaleId);
if (paneModes?.size === 0) {
modes.delete(target.paneId);
}
}
private getTargets(modes: Map<number, Map<string, PriceScaleMode>>): PriceScaleTarget[] {
const targets: PriceScaleTarget[] = [];
modes.forEach((paneModes, paneId) => {
paneModes.forEach((_, priceScaleId) => {
targets.push({ paneId, priceScaleId });
});
});
return targets;
}
private applyMode(target: PriceScaleTarget): void {
const pane = this.getPaneById(target.paneId);
if (!pane) {
return;
}
pane.getPriceScale(target.priceScaleId).applyOptions({
mode: this.getActiveMode(target),
});
}
}
import { IPriceScaleApi, PriceScaleMode } from 'lightweight-charts';
import { PriceScaleControls } from '@components/PriceScaleControls';
import { ReactRenderer } from '@core/ReactRenderer';
import { Direction } from '@src/types';
import type { PriceScaleTarget } from './controller';
interface PriceScaleControlsControllerParams {
getPaneElement: () => HTMLElement | null;
getPriceScale: (priceScaleId: Direction) => IPriceScaleApi;
getPriceScaleTarget: (priceScaleId: Direction) => PriceScaleTarget;
hasPriceScaleSeries: (priceScaleId: Direction) => boolean;
getSavedPriceScaleMode: (target: PriceScaleTarget) => PriceScaleMode;
togglePriceScaleMode: (target: PriceScaleTarget, mode: PriceScaleMode) => void;
}
export class PriceScaleControlsController {
private readonly getPaneElement: () => HTMLElement | null;
private readonly getPriceScale: (priceScaleId: Direction) => IPriceScaleApi;
private readonly getPriceScaleTarget: (priceScaleId: Direction) => PriceScaleTarget;
private readonly hasPriceScaleSeries: (priceScaleId: Direction) => boolean;
private readonly getSavedPriceScaleMode: (target: PriceScaleTarget) => PriceScaleMode;
private readonly togglePriceScaleMode: (target: PriceScaleTarget, mode: PriceScaleMode) => void;
private readonly container: HTMLElement;
private readonly renderer: ReactRenderer;
private paneElement: HTMLElement | null = null;
private hoveredPriceScaleId: Direction | null = null;
constructor({
getPaneElement,
getPriceScale,
getPriceScaleTarget,
hasPriceScaleSeries,
getSavedPriceScaleMode,
togglePriceScaleMode,
}: PriceScaleControlsControllerParams) {
this.getPaneElement = getPaneElement;
this.getPriceScale = getPriceScale;
this.getPriceScaleTarget = getPriceScaleTarget;
this.hasPriceScaleSeries = hasPriceScaleSeries;
this.getSavedPriceScaleMode = getSavedPriceScaleMode;
this.togglePriceScaleMode = togglePriceScaleMode;
this.container = document.createElement('div');
this.container.className = 'moex-price-scale-controls';
this.container.style.position = 'absolute';
this.container.style.bottom = '0';
this.container.style.zIndex = '10';
this.container.style.pointerEvents = 'none';
this.renderer = new ReactRenderer(this.container);
}
public mount(): void {
const paneElement = this.getPaneElement();
if (!paneElement) {
return;
}
if (this.paneElement === paneElement && this.container.parentElement === paneElement) {
this.update();
return;
}
this.unmount();
this.paneElement = paneElement;
this.paneElement.style.position = 'relative';
this.paneElement.appendChild(this.container);
this.paneElement.addEventListener('pointermove', this.handlePointerMove);
this.paneElement.addEventListener('pointerleave', this.handlePointerLeave);
this.update();
}
public update(): void {
if (!this.hoveredPriceScaleId) {
this.hide();
return;
}
this.render(this.hoveredPriceScaleId);
}
public resetAutoScale(): void {
this.setAutoScale(Direction.Left);
this.setAutoScale(Direction.Right);
this.update();
}
public destroy(): void {
this.unmount();
this.renderer.destroy();
this.container.remove();
}
private handlePointerMove = (event: PointerEvent): void => {
if (!this.paneElement) {
return;
}
const paneRect = this.paneElement.getBoundingClientRect();
const pointerX = event.clientX - paneRect.left;
const nextPriceScaleId = this.getHoveredPriceScaleId(pointerX, paneRect.width);
if (this.hoveredPriceScaleId === nextPriceScaleId) {
if (nextPriceScaleId) {
this.updatePosition(nextPriceScaleId);
}
return;
}
this.hoveredPriceScaleId = nextPriceScaleId;
this.update();
};
private handlePointerLeave = (): void => {
this.hoveredPriceScaleId = null;
this.update();
};
private render(priceScaleId: Direction): void {
if (!this.isPriceScaleAvailable(priceScaleId)) {
this.hide();
return;
}
this.updatePosition(priceScaleId);
const target = this.getPriceScaleTarget(priceScaleId);
const savedMode = this.getSavedPriceScaleMode(target);
this.renderer.renderComponent(
<PriceScaleControls
isAutoScale={this.isAutoScale(priceScaleId)}
isLogarithmic={savedMode === PriceScaleMode.Logarithmic}
onAutoScale={() => {
this.setAutoScale(priceScaleId);
this.update();
}}
onToggleLogarithmic={() => {
this.togglePriceScaleMode(target, PriceScaleMode.Logarithmic);
this.update();
}}
onRefresh={() => {
this.update();
}}
/>,
);
this.container.classList.add('moex-price-scale-controls_visible');
}
private hide(): void {
this.container.classList.remove('moex-price-scale-controls_visible');
}
private getHoveredPriceScaleId(pointerX: number, paneWidth: number): Direction | null {
const leftWidth = this.getPriceScaleWidth(Direction.Left);
const rightWidth = this.getPriceScaleWidth(Direction.Right);
if (this.isPriceScaleAvailable(Direction.Left) && pointerX >= 0 && pointerX <= leftWidth) {
return Direction.Left;
}
if (this.isPriceScaleAvailable(Direction.Right) && pointerX >= paneWidth - rightWidth && pointerX <= paneWidth) {
return Direction.Right;
}
return null;
}
private updatePosition(priceScaleId: Direction): void {
const width = this.getPriceScaleWidth(priceScaleId);
this.container.style.width = `${width}px`;
if (priceScaleId === Direction.Left) {
this.container.style.left = '0';
this.container.style.right = '';
return;
}
this.container.style.left = '';
this.container.style.right = '0';
}
private setAutoScale(priceScaleId: Direction): void {
this.getPriceScale(priceScaleId).setAutoScale(true);
}
private isAutoScale(priceScaleId: Direction): boolean {
return this.getPriceScale(priceScaleId).options().autoScale ?? true;
}
private isPriceScaleAvailable(priceScaleId: Direction): boolean {
const priceScale = this.getPriceScale(priceScaleId);
const options = priceScale.options();
return options.visible !== false && priceScale.width() > 0 && this.hasPriceScaleSeries(priceScaleId);
}
private getPriceScaleWidth(priceScaleId: Direction): number {
return this.getPriceScale(priceScaleId).width();
}
private unmount(): void {
if (!this.paneElement) {
return;
}
this.paneElement.removeEventListener('pointermove', this.handlePointerMove);
this.paneElement.removeEventListener('pointerleave', this.handlePointerLeave);
this.paneElement = null;
}
}
export { PriceScaleModeController } from './controller';
export type { PriceScaleModeSnapshot, PriceScaleTarget } from './controller';
export { PriceScaleControlsController } from './controls';
import { IChartApi, PriceScaleMode, SeriesType } from 'lightweight-charts';
import { flatten } from 'lodash-es';
import { BehaviorSubject, distinctUntilChanged, map, Observable } from 'rxjs';
import { DataSource } from '@core/DataSource';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { IndicatorManager } from '@core/IndicatorManager';
import { PaneManager } from '@core/PaneManager';
import { PriceScaleModeController } from '@src/core/PriceScale';
import { COMPARE_COLOR_PALETTE } from '@src/theme';
import { CompareItem, CompareMode, Direction, IndicatorConfig } from '@src/types';
import { IndicatorSnapshot } from '@src/types/snapshot';
import { createFallbackColor, normalizeColor, normalizeSymbol } from '@src/utils';
interface CompareEntry {
key: string;
symbol: string;
mode: CompareMode;
paneIndex: number;
symbol$: BehaviorSubject<string>;
entity: Indicator;
}
interface CompareManagerParams {
chart: IChartApi;
eventManager: EventManager;
dataSource: DataSource;
indicatorManager: IndicatorManager;
paneManager: PaneManager;
priceScaleModeController: PriceScaleModeController;
initialIndicators?: IndicatorSnapshot[];
}
export class CompareManager {
private readonly chart: IChartApi;
private readonly eventManager: EventManager;
private readonly dataSource: DataSource;
private readonly indicatorManager: IndicatorManager;
private readonly paneManager: PaneManager;
private readonly priceScaleModeController: PriceScaleModeController;
private readonly entries = new Map<string, CompareEntry>();
private readonly itemsSubject = new BehaviorSubject<CompareItem[]>([]);
private readonly entitiesSubject = new BehaviorSubject<Indicator[]>([]);
constructor({
chart,
eventManager,
dataSource,
indicatorManager,
paneManager,
priceScaleModeController,
initialIndicators = [],
}: CompareManagerParams) {
this.chart = chart;
this.eventManager = eventManager;
this.dataSource = dataSource;
this.indicatorManager = indicatorManager;
this.paneManager = paneManager;
this.priceScaleModeController = priceScaleModeController;
this.eventManager.timeframe().subscribe(() => {
this.applyPolicy();
});
if (!initialIndicators) {
return;
}
this.setup(initialIndicators);
}
private async setup(initialIndicators: IndicatorSnapshot[]) {
for (const indicator of initialIndicators) {
if (indicator.indicatorType !== undefined) {
continue;
}
if (indicator.config && indicator.config.label) {
const serie = indicator.config.series[0];
const compareMode =
serie.seriesOptions?.priceScaleId === Direction.Left
? CompareMode.NewScale
: indicator.config.newPane
? CompareMode.NewPane
: CompareMode.Percentage;
// eslint-disable-next-line no-await-in-loop
await this.setSymbolMode(serie.name, indicator.config.label, compareMode, indicator.paneId);
}
}
}
public itemsObs(): Observable<CompareItem[]> {
return this.itemsSubject.asObservable();
}
public entities(): Observable<Indicator[]> {
return this.entitiesSubject.asObservable();
}
public clear(): void {
const keys = Array.from(this.entries.keys());
for (let i = 0; i < keys.length; i += 1) {
this.removeByKey(keys[i]);
}
this.applyPolicy();
this.publish();
}
public async setSymbolMode(
seriesType: SeriesType,
symbolRaw: string,
mode: CompareMode,
paneId?: number,
): Promise<void> {
const symbol = normalizeSymbol(symbolRaw);
if (!symbol) return;
if (mode === CompareMode.NewScale && this.isNewScaleDisabled()) {
return;
}
const key = makeKey(symbol, mode);
if (this.entries.has(key)) return;
const symbol$ = new BehaviorSubject(symbol);
const entity = this.indicatorManager.addEntity<Indicator>((zIndex, moveUp, moveDown) => {
const usedColorsByCompare = this.entitiesSubject.value.map(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(ind) => ind.getConfig().series?.[0]?.seriesOptions?.color,
);
const existIndicators = Array.from(this.indicatorManager.getIndicators().value.values());
const usedColorsByIndicatorsRaw = existIndicators.map((ind) =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
ind.config?.series?.map((serie) => serie.seriesOptions?.color),
);
const usedColorsByIndicators = flatten(usedColorsByIndicatorsRaw).filter((color) => color !== undefined);
const usedColors = usedColorsByCompare.concat(usedColorsByIndicators);
const config = getDefaultCompareIndicatorConfig(symbol, usedColors);
const associatedPane =
mode === CompareMode.NewPane
? paneId !== undefined
? (this.paneManager.getPaneById(paneId) ?? this.paneManager.addPane())
: this.paneManager.addPane()
: this.paneManager.getMainPane();
return new Indicator({
id: key,
lwcChart: this.chart,
mainSymbol$: symbol$,
dataSource: this.dataSource,
associatedPane,
config: {
...config,
series: [
{
...config.series[0],
seriesOptions: {
...config.series[0]?.seriesOptions,
priceScaleId: mode === CompareMode.NewScale ? Direction.Left : Direction.Right,
},
},
],
newPane: mode === CompareMode.NewPane,
},
zIndex,
onDelete: () => this.removeByKey(key),
moveUp,
moveDown,
paneId: associatedPane.getId(),
});
});
this.entries.set(key, { key, symbol, mode, paneIndex: entity.getPane().getId(), symbol$, entity });
this.applyPolicy();
this.publish();
await this.dataSource.isReady(symbol);
}
public removeSymbolMode(symbolRaw: string, mode: CompareMode): void {
const symbol = normalizeSymbol(symbolRaw);
if (!symbol) return;
this.removeByKey(makeKey(symbol, mode));
this.applyPolicy();
this.publish();
}
public removeSymbol(symbolRaw: string): void {
const symbol = normalizeSymbol(symbolRaw);
if (!symbol) return;
const all = Array.from(this.entries.entries());
for (let i = 0; i < all.length; i += 1) {
const [key, entry] = all[i];
if (entry.symbol === symbol) this.removeByKey(key);
}
this.applyPolicy();
this.publish();
}
public isNewScaleDisabled(): boolean {
return this.itemsSubject.value.length > 0;
}
public isNewScaleDisabledObservable(): Observable<boolean> {
return this.itemsSubject.pipe(
map((items) => items.length > 0),
distinctUntilChanged(),
);
}
public getAllEntities() {
return Array.from(this.entries.values()).map(({ symbol, entity, mode }) => ({
symbol,
entity,
mode,
}));
}
public destroy(): void {
this.clear();
this.itemsSubject.complete();
this.entitiesSubject.complete();
}
private removeByKey(key: string): void {
const entry = this.entries.get(key);
if (!entry) return;
this.entries.delete(key);
this.indicatorManager.removeEntity(entry.entity);
entry.entity.destroy();
entry.symbol$.complete();
this.applyPolicy();
this.publish();
}
private publish(): void {
const values = Array.from(this.entries.values());
const items: CompareItem[] = [];
const entities: Indicator[] = [];
for (let i = 0; i < values.length; i += 1) {
items.push({ symbol: values[i].symbol, mode: values[i].mode });
entities.push(values[i].entity);
}
this.itemsSubject.next(items);
this.entitiesSubject.next(entities);
}
private applyPolicy(): void {
const list = Array.from(this.entries.values());
const mainPane = this.paneManager.getMainPane();
let percentEnabled = false;
const leftScalePaneIds = new Set<number>();
const additionalPaneIds = new Set<number>();
for (let i = 0; i < list.length; i += 1) {
const entry = list[i];
const pane = entry.entity.getPane();
if (entry.mode === CompareMode.Percentage) {
percentEnabled = true;
}
if (entry.mode === CompareMode.NewScale) {
leftScalePaneIds.add(pane.getId());
}
if (!pane.isMainPane()) {
additionalPaneIds.add(pane.getId());
}
}
const mainLeftScaleVisible = leftScalePaneIds.has(mainPane.getId());
this.chart.applyOptions({
leftPriceScale: { visible: mainLeftScaleVisible, borderVisible: false },
});
this.chart.priceScale(Direction.Right, mainPane.paneIndex()).applyOptions({
visible: true,
});
this.chart.priceScale(Direction.Left, mainPane.paneIndex()).applyOptions({
visible: mainLeftScaleVisible,
borderVisible: false,
});
const mainRightPriceScaleTarget = mainPane.getPriceScaleTarget(Direction.Right);
if (percentEnabled) {
this.priceScaleModeController.setOverrideMode(mainRightPriceScaleTarget, PriceScaleMode.Percentage);
} else {
this.priceScaleModeController.clearOverrideMode(mainRightPriceScaleTarget);
}
const additionalPaneIdsList = Array.from(additionalPaneIds.values());
for (let i = 0; i < additionalPaneIdsList.length; i += 1) {
const pane = this.paneManager.getPaneById(additionalPaneIdsList[i]);
if (!pane) {
continue;
}
this.chart.priceScale(Direction.Right, pane.paneIndex()).applyOptions({
visible: true,
});
this.chart.priceScale(Direction.Left, pane.paneIndex()).applyOptions({
visible: leftScalePaneIds.has(pane.getId()),
borderVisible: false,
});
}
for (let i = 0; i < list.length; i += 1) {
const entry = list[i];
// [0 - в индикаторах compare сущности может быть только одна серия] [1 - entry]
const serie = Array.from(entry.entity.getSeriesMap())[0][1];
const pane = entry.entity.getPane();
if (!pane.isMainPane()) {
serie.applyOptions({ priceScaleId: Direction.Right });
continue;
}
serie.applyOptions({
priceScaleId: entry.mode === CompareMode.NewScale ? Direction.Left : Direction.Right,
});
}
this.paneManager.updatePriceScaleControls();
}
}
function makeKey(symbol: string, mode: CompareMode): string {
return `${symbol}|${mode}`;
}
function getPaletteColorFromIndex(usedColors: Set<string>, startIndex: number): string {
for (let offset = 0; offset < COMPARE_COLOR_PALETTE.length; offset += 1) {
const color = COMPARE_COLOR_PALETTE[(startIndex + offset) % COMPARE_COLOR_PALETTE.length];
if (!usedColors.has(normalizeColor(color))) {
return color;
}
}
return createFallbackColor(usedColors.size);
}
const getDefaultCompareIndicatorConfig = (symbol: string, usedColors: string[]): IndicatorConfig => {
const reservedColors = new Set(usedColors.map(normalizeColor));
return {
newPane: true,
label: symbol,
series: [
{
name: 'Line', // todo: change with enum
id: `compare-${crypto.randomUUID()}`,
seriesOptions: {
visible: true,
color: getPaletteColorFromIndex(reservedColors, 0),
},
},
],
};
};
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 dayjs from 'dayjs';
import { Button, Tooltip } from 'exchange-elements/v2';
import { useEffect, useState } from 'react';
import { Observable } from 'rxjs';
import { t } from '@src/translations';
import { Intervals, IntervalsToTimeframe, Timeframes } from '@src/types';
import { formatUtcOffset, useObservable } from '@src/utils';
import styles from './index.module.scss';
interface FooterProps {
intervalObs: Observable<Intervals | null>;
setInterval: (next: Intervals) => void;
supportedTimeframes: Timeframes[];
}
export function Footer({ setInterval: setIntervalValue, intervalObs, supportedTimeframes }: FooterProps) {
const [currentTime, setCurrentTime] = useState(dayjs().format('HH:mm:ss'));
const selectedInterval = useObservable<null | Intervals>(intervalObs);
const utcOffset = formatUtcOffset();
useEffect(() => {
const intervalId = setInterval(() => setCurrentTime(dayjs().format('HH:mm:ss')), 1000);
return () => clearInterval(intervalId);
}, []);
return (
<footer className={styles.footer}>
<div className={styles.intervals}>
{(Object.keys(Intervals) as Intervals[])
.filter((interval: Intervals) => {
if (!IntervalsToTimeframe[interval]) {
return false;
}
return supportedTimeframes.includes(IntervalsToTimeframe[interval]);
})
.map((interval) => (
<Tooltip
tooltipClassName={styles.tooltip}
key={interval}
label={`${t(interval)} ${t('interval for')} ${t(IntervalsToTimeframe[interval] as string)} ${t('timeframe')}`}
>
<Button
key={interval}
size="sm"
variant="solid"
className={`${styles.interval} ${interval === selectedInterval ? styles.pressed : ''}`}
onClick={() => setIntervalValue(interval)}
label={t(interval)}
/>
</Tooltip>
))}
</div>
<span className={styles.time}>
{currentTime} UTC{utcOffset}
</span>
</footer>
);
}
@use '../../theme/mixins' as m;
.tooltip {
@include m.tooltipHint;
}
.footer {
width: 100%;
height: 100%;
display: grid;
justify-content: center;
align-items: center;
grid-template: 1fr / 1fr auto;
column-gap: var(--space-0500);
.intervals {
display: flex;
justify-content: start;
align-items: center;
gap: var(--space-0500);
overflow: auto;
}
.interval {
@include m.buttonBase;
}
.time {
text-align: right;
font-size: var(--space-0750);
font-weight: var(--font-medium);
color: var(--neutral-12);
}
}