Загрузка данных
import { useEffect, useRef, useState } from 'react';
import { DateFormat, IMoexChart, Locale, MoexChart, Timeframes } from '@lib';
import { Portal } from '@lib/components/Portal';
import { IndicatorsIds } from '@lib/constants';
import { CompareManager } from '@lib/core/CompareManager';
import { CompareMode } from '@lib/types';
import { dataSourceProvider } from '../common';
import type { Meta, StoryObj } from '@storybook/react';
/**
* ## MB story
* describes an entry point of MoexChart for MB user
*/
type MBProps = Omit<IMoexChart, 'container'>;
// todo: переписат ьпод новую сигнатуру
const MBEntry = (props: MBProps) => {
const [isCompareOpen, setIsCompareOpen] = useState(false);
const [moexChart, setMoexChart] = useState<MoexChart | undefined>(undefined);
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
const chart = new MoexChart({
...props,
container,
chartCollectionPreset: {
...props.chartCollectionPreset,
openCompareModal: () => setIsCompareOpen(true),
},
});
setMoexChart(chart);
return () => {
chart.destroy();
};
}, [props]);
return (
<div
style={{
width: '100%',
height: '100dvh',
display: 'grid',
gridTemplateRows: 'auto minmax(0, 1fr)',
gap: 10,
padding: 20,
boxSizing: 'border-box',
}}
>
<h3
style={{
margin: 0,
}}
>
MB usage
</h3>
<div ref={containerRef} />
{isCompareOpen && (
<Portal>
<Modal
onClose={() => setIsCompareOpen(false)}
compareManager={moexChart?.getCompareManager() ?? null}
/>
</Portal>
)}
</div>
);
};
const meta: Meta<MBProps> = {
title: 'MB',
component: MBEntry,
// argTypes, // todo: пофиксить вместе с переписыванием доки
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: `## MB story
describes an entry point of MoexChart for MB user`,
},
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
const args: MBProps = {
snapshot: {
charts: [
{
timeframe: Timeframes['10s'],
chartSeriesType: 'Candlestick',
symbol: 'APPL',
panes: [
{
// empty panes deletes automatically
isMain: true, // Be careful. There is only one main pane can be present
id: 0,
indicators: [
{
indicatorType: IndicatorsIds.Volume,
},
],
drawings: [],
},
],
},
],
},
chartCollectionPreset: {
undoRedoEnabled: true,
showMenuButton: true,
showBottomPanel: true,
showControlBar: true,
showFullscreenButton: true,
showSettingsButton: true,
showCompareButton: false,
tooltipConfig: {
showTooltip: false,
time: { visible: true, label: 'Время' },
close: { visible: true, label: 'Закр.' },
change: { visible: true, label: 'Изм.' },
volume: { visible: true, label: 'Объем' },
open: { visible: true, label: 'Откр.' },
high: { visible: true, label: 'Макс.' },
low: { visible: true, label: 'Мин.' },
},
supportedTimeframes: [
Timeframes['1s'],
Timeframes['5s'],
Timeframes['10s'],
Timeframes['1m'],
Timeframes['2m'],
Timeframes['30m'],
Timeframes['1h'],
Timeframes['2h'],
Timeframes['1d'],
Timeframes['1w'],
],
supportedChartSeriesTypes: ['Candlestick', 'Line', 'Bar'],
getDataSource: dataSourceProvider.generateCandles.bind(dataSourceProvider),
startRealtime: (getSymbols, getTimeframe, update) =>
dataSourceProvider.startRealtime(getSymbols, getTimeframe, update), // should return unsub
theme: 'mb',
ohlc: {
show: true,
precision: 2,
},
mode: 'dark',
locale: Locale.rus,
},
lwcInheritedChartOptions: {
timeVisible: true,
secondsVisible: false,
timeFormat: '24h',
dateFormat: DateFormat.DD_MM_YYYY_HH_mm_ss,
},
};
export const MB: Story = {
args,
parameters: {
controls: {
expanded: true, // отвечает за расширение колонок(+Description, +Default) в табе controls
},
},
};
const Modal = ({ onClose, compareManager }: { onClose: () => void; compareManager: CompareManager | null }) => {
const [isNewScaleDisabled, setIsNewScaleDisabled] = useState(false);
useEffect(() => {
if (!compareManager) {
setIsNewScaleDisabled(false);
return;
}
setIsNewScaleDisabled(compareManager.isNewScaleDisabled());
const subscription = compareManager.isNewScaleDisabledObservable().subscribe(setIsNewScaleDisabled);
return () => subscription.unsubscribe();
}, [compareManager]);
const { mode = 'light' } = args.chartCollectionPreset;
const containerStyles =
mode === 'light' ? { backgroundColor: 'white', color: 'black' } : { backgroundColor: 'black', color: 'white' };
const buttonStyles =
mode === 'light'
? { backgroundColor: 'lightgray', color: 'black', padding: '2px 8px' }
: { backgroundColor: 'darkgray', color: 'black', padding: '2px 8px' };
return (
<div
onClick={onClose}
style={{
width: '100%',
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#0000004D',
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 999,
cursor: 'pointer',
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
display: 'grid',
gap: 16,
padding: 16,
...containerStyles,
}}
>
{['SBER', 'APAX', 'SOL'].map((symbol) => (
<div
key={symbol}
style={{ display: 'flex', justifyContent: 'space-between', gap: 16 }}
>
<span>{symbol}</span>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={() => compareManager?.setSymbolMode('Line', symbol, CompareMode.Percentage)}
style={buttonStyles}
type="button"
>
%
</button>
<button
onClick={() => compareManager?.setSymbolMode('Line', symbol, CompareMode.NewScale)}
style={{
backgroundColor: isNewScaleDisabled ? 'darkgray' : 'lightgray',
padding: '2px 8px',
cursor: isNewScaleDisabled ? 'not-allowed' : 'cursor',
}}
disabled={isNewScaleDisabled}
type="button"
>
Новая шкала
</button>
<button
onClick={() => compareManager?.setSymbolMode('Line', symbol, CompareMode.NewPane)}
style={buttonStyles}
type="button"
>
Новая панель
</button>
</div>
</div>
))}
</div>
</div>
);
};
import { IChartApi, PriceScaleMode, SeriesType } from 'lightweight-charts';
import { flatten } from 'lodash-es';
import { BehaviorSubject, distinctUntilChanged, map, Observable, Subscription } 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 { PriceScale } from '@core/PriceScale';
import { COMPARE_COLOR_PALETTE } from '@src/theme';
import { CompareInstrument, CompareItem, CompareMode, Direction, IndicatorConfig } from '@src/types';
import { IndicatorSnapshot } from '@src/types/snapshot';
import { createFallbackColor, normalizeColor, normalizeSymbol } from '@src/utils';
interface CompareEntry extends CompareItem {
key: string;
symbol$: BehaviorSubject<string>;
entity: Indicator;
}
interface CompareManagerParams {
chart: IChartApi;
eventManager: EventManager;
dataSource: DataSource;
indicatorManager: IndicatorManager;
paneManager: PaneManager;
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 entries = new Map<string, CompareEntry>();
private readonly itemsSubject = new BehaviorSubject<CompareItem[]>([]);
private readonly entitiesSubject = new BehaviorSubject<Indicator[]>([]);
private readonly subscriptions = new Subscription();
private percentageComparisonActive = false;
private restoringInitialIndicators = false;
constructor({
chart,
eventManager,
dataSource,
indicatorManager,
paneManager,
initialIndicators = [],
}: CompareManagerParams) {
this.chart = chart;
this.eventManager = eventManager;
this.dataSource = dataSource;
this.indicatorManager = indicatorManager;
this.paneManager = paneManager;
this.subscriptions.add(
this.eventManager.timeframe().subscribe(() => {
this.applyPolicy();
}),
);
this.setup(initialIndicators);
}
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 index = 0; index < keys.length; index += 1) {
this.removeEntry(keys[index]);
}
this.commitEntriesChange();
}
public async setSymbolMode(
seriesType: SeriesType,
instrument: CompareInstrument,
mode: CompareMode,
paneId?: number,
): Promise<void> {
const symbol = normalizeSymbol(instrument.symbol);
if (!symbol) {
return;
}
const symbolName = instrument.symbolName.trim() || symbol;
if (mode === CompareMode.NewScale && this.isNewScaleDisabled() && !this.restoringInitialIndicators) {
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
(indicator) => indicator.getConfig().series?.[0]?.seriesOptions?.color,
);
const existingIndicators = Array.from(this.indicatorManager.getIndicators().value.values());
const usedColorsByIndicatorsRaw = existingIndicators.map((indicator) =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
indicator.config?.series?.map((series) => series.seriesOptions?.color),
);
const usedColorsByIndicators = flatten(usedColorsByIndicatorsRaw).filter((color) => color !== undefined);
const usedColors = usedColorsByCompare.concat(usedColorsByIndicators);
const config = getDefaultCompareIndicatorConfig(seriesType, symbol, symbolName, 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: () => {
if (this.removeEntry(key)) {
this.commitEntriesChange();
}
},
moveUp,
moveDown,
paneId: associatedPane.getId(),
});
});
this.entries.set(key, {
key,
symbol,
symbolName,
mode,
symbol$,
entity,
});
this.commitEntriesChange();
await this.dataSource.isReady(symbol);
}
public removeSymbolMode(symbolRaw: string, mode: CompareMode): void {
const symbol = normalizeSymbol(symbolRaw);
if (!symbol) {
return;
}
if (this.removeEntry(makeKey(symbol, mode))) {
this.commitEntriesChange();
}
}
public removeSymbol(symbolRaw: string): void {
const symbol = normalizeSymbol(symbolRaw);
if (!symbol) {
return;
}
const entries = Array.from(this.entries.entries());
let removed = false;
for (let index = 0; index < entries.length; index += 1) {
const [key, entry] = entries[index];
if (entry.symbol !== symbol) {
continue;
}
removed = this.removeEntry(key) || removed;
}
if (removed) {
this.commitEntriesChange();
}
}
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, symbolName, entity, mode }) => ({
symbol,
symbolName,
entity,
mode,
}));
}
public destroy(): void {
this.subscriptions.unsubscribe();
this.clear();
this.itemsSubject.complete();
this.entitiesSubject.complete();
}
private async setup(initialIndicators: IndicatorSnapshot[]): Promise<void> {
this.restoringInitialIndicators = true;
try {
for (const indicator of initialIndicators) {
if (indicator.indicatorType !== undefined) {
continue;
}
if (!indicator.config?.label) {
continue;
}
const series = indicator.config.series[0];
const symbol = indicator.config.symbol ?? indicator.config.label;
const compareMode =
series.seriesOptions?.priceScaleId === Direction.Left
? CompareMode.NewScale
: indicator.config.newPane
? CompareMode.NewPane
: CompareMode.Percentage;
// eslint-disable-next-line no-await-in-loop
await this.setSymbolMode(
series.name,
{
symbol,
symbolName: indicator.config.label,
},
compareMode,
indicator.paneId,
);
}
} finally {
this.restoringInitialIndicators = false;
}
}
private removeEntry(key: string): boolean {
const entry = this.entries.get(key);
if (!entry) {
return false;
}
this.entries.delete(key);
this.indicatorManager.removeEntity(entry.entity);
entry.entity.destroy();
entry.symbol$.complete();
return true;
}
private commitEntriesChange(): void {
this.applyPolicy();
this.publish();
}
private publish(): void {
const values = Array.from(this.entries.values());
const items: CompareItem[] = [];
const entities: Indicator[] = [];
for (let index = 0; index < values.length; index += 1) {
items.push({
symbol: values[index].symbol,
symbolName: values[index].symbolName,
mode: values[index].mode,
});
entities.push(values[index].entity);
}
this.itemsSubject.next(items);
this.entitiesSubject.next(entities);
}
private syncPercentageMode(priceScale: PriceScale, shouldEnablePercentageMode: boolean): void {
if (this.restoringInitialIndicators) {
this.percentageComparisonActive = shouldEnablePercentageMode;
return;
}
if (this.percentageComparisonActive === shouldEnablePercentageMode) {
return;
}
this.percentageComparisonActive = shouldEnablePercentageMode;
if (shouldEnablePercentageMode) {
priceScale.setMode(PriceScaleMode.Percentage);
return;
}
if (priceScale.getMode() === PriceScaleMode.Percentage) {
priceScale.setMode(PriceScaleMode.Normal);
}
}
private applyPolicy(): void {
const entries = Array.from(this.entries.values());
let percentageComparisonActive = false;
let newScaleComparisonActive = false;
for (let index = 0; index < entries.length; index += 1) {
if (entries[index].mode === CompareMode.Percentage) {
percentageComparisonActive = true;
}
if (entries[index].mode === CompareMode.NewScale) {
newScaleComparisonActive = true;
}
}
for (let index = 0; index < entries.length; index += 1) {
const entry = entries[index];
const pane = entry.entity.getPane();
// [0 - в индикаторах compare сущности может быть только одна серия] [1 - entry]
const series = Array.from(entry.entity.getSeriesMap().values())[0];
if (!series) {
continue;
}
series.applyOptions({
priceScaleId: pane.isMainPane() && entry.mode === CompareMode.NewScale ? Direction.Left : Direction.Right,
});
}
this.paneManager.setPriceScaleSideVisible(Direction.Left, newScaleComparisonActive);
this.paneManager.setPriceScaleSideVisible(Direction.Right, true);
const mainRightPriceScale = this.paneManager.getMainPane().getPriceScale(Direction.Right);
this.syncPercentageMode(mainRightPriceScale, percentageComparisonActive);
this.paneManager.invalidate();
}
}
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 = (
seriesType: SeriesType,
symbol: string,
symbolName: string,
usedColors: string[],
): IndicatorConfig => {
const reservedColors = new Set(usedColors.map(normalizeColor));
return {
symbol,
newPane: true,
label: symbolName,
series: [
{
name: 'Line', // todo: change with enum
id: `compare-${crypto.randomUUID()}`,
seriesOptions: {
visible: true,
color: getPaletteColorFromIndex(reservedColors, 0),
},
},
],
};
};
export enum CompareMode {
Percentage = 'PCT',
NewScale = 'SCALE',
NewPane = 'PANE',
}
export interface CompareInstrument {
symbol: string;
symbolName: string;
symbolTicker: string;
}
export interface CompareItem extends CompareInstrument {
mode: CompareMode;
}