Загрузка данных
import { DrawingsManagerSnapshot } from '@core/DrawingsManager';
import { ChartSeriesType, DateFormat, IndicatorsIds, Intervals, Timeframes } from '@lib';
import { Direction } from '@src/types/chart';
import { SymbolInfo, SymbolInfoInput } from '@src/types/symbol';
import { TimeFormat } from '@src/types/timeScale';
import { SettingsValues } from './settings';
import type { PriceScaleMode } from 'lightweight-charts';
export type PriceScaleSide = Direction.Left | Direction.Right;
export interface ISerializable<T extends object> {
getSnapshot: () => T;
}
interface MainSymbolSnapshotInput {
symbolId: string | null;
symbol?: string;
symbolName?: string;
}
export interface InitialSnapshot extends MainSymbolSnapshotInput {
timeframe: Timeframes; // todo: move to snap
chartSeriesType: ChartSeriesType; // todo: move to snap
}
export interface MoexChartSnapshotInput {
// settings: ChartSettingsSnapshot;
charts: ChartSnapshotInput[];
}
export interface MoexChartSnapshot {
// settings: ChartSettingsSnapshot;
charts: ChartSnapshot[];
}
interface ChartSnapshotBase {
timeframe: Timeframes;
chartSeriesType: ChartSeriesType;
timeFormat?: TimeFormat;
dateFormat?: DateFormat;
interval?: Intervals | null;
panes: PaneSnapshot[];
}
export interface ChartSnapshotInput extends ChartSnapshotBase, MainSymbolSnapshotInput {}
export interface ChartSnapshot extends ChartSnapshotBase, SymbolInfo {}
export interface PriceScaleSnapshot {
side: PriceScaleSide;
mode: PriceScaleMode;
}
export interface PaneSnapshot {
isMain: boolean;
id: number;
indicators: (IndicatorSnapshot | CompareSnapshot)[];
drawings: DrawingsManagerSnapshot;
priceScales?: PriceScaleSnapshot[];
}
export interface CompareSnapshot extends Partial<DOMObjectSnapshot> {
symbolInfo: SymbolInfoInput;
seriesName: ChartSeriesType;
scale: Direction;
}
export interface IndicatorSnapshot extends Partial<DOMObjectSnapshot> {
indicatorType: IndicatorsIds;
settings?: SettingsValues;
}
// todo: move DrawingsManagerSnapshot here
export interface DOMObjectSnapshot {
id: string;
name: string;
zIndex: number;
hidden: boolean;
paneId: number;
}
this.subscriptions.add(
combineLatest([this.eventManager.hasSymbol(), this.indicatorsMap]).subscribe(([hasMainSymbol, indicators]) => {
const hasIndicator = Array.from(indicators.values()).some(
(indicator) => indicator.getIndicatorType() !== undefined,
);
this.paneEmptyStateContainer.hidden = hasMainSymbol || !hasIndicator;
}),
);
import { IChartApi, PriceScaleMode, SeriesType } from 'lightweight-charts';
import { BehaviorSubject, distinctUntilChanged, map, Observable, of, 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 { CompareItem, CompareMode, Direction, IndicatorConfig, SymbolInfo, SymbolInfoInput } from '@src/types';
import { CompareSnapshot } from '@src/types/snapshot';
import { createFallbackColor, normalizeColor, normalizeSymbol, normalizeSymbolInfo } from '@src/utils';
type MainScaleCompareMode = CompareMode.Absolute | CompareMode.Percentage;
interface CompareEntry extends CompareItem {
key: string;
entity: Indicator;
}
interface CompareManagerParams {
chart: IChartApi;
eventManager: EventManager;
dataSource: DataSource;
indicatorManager: IndicatorManager;
paneManager: PaneManager;
initialIndicators?: CompareSnapshot[];
}
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 {
for (const key of Array.from(this.entries.keys())) {
this.removeEntry(key);
}
this.commitEntriesChange();
}
public async setSymbolMode(
seriesType: SeriesType,
symbolInfoInput: SymbolInfoInput,
mode: CompareMode,
paneId?: number,
): Promise<void> {
const symbolInfo = normalizeSymbolInfo(symbolInfoInput);
if (!symbolInfo) {
return;
}
const { symbolId, symbol, symbolName } = symbolInfo;
if (mode === CompareMode.NewScale && this.isNewScaleDisabled() && !this.restoringInitialIndicators) {
return;
}
const isMainScaleMode = isMainScaleCompareMode(mode);
if (isMainScaleMode) {
for (const entry of this.entries.values()) {
if (isMainScaleCompareMode(entry.mode)) {
entry.mode = mode;
}
}
}
const key = makeKey(symbolId, mode);
if (this.entries.has(key)) {
if (isMainScaleMode) {
this.commitEntriesChange();
}
return;
}
const entity = this.indicatorManager.addEntity<Indicator>((zIndex, moveUp, moveDown) => {
const existingIndicators = Array.from(this.indicatorManager.getIndicators().value.values());
const usedColors = [...this.entitiesSubject.value, ...existingIndicators]
.flatMap((indicator) => indicator.getConfig().series.map(({ seriesOptions }) => seriesOptions?.color))
.filter((color): color is string => typeof color === 'string');
const config = getDefaultCompareIndicatorConfig({
symbolInfo,
seriesType,
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,
mainSymbolId$: of(symbolId),
mainSymbol$: of(symbol),
dataSource: this.dataSource,
associatedPane,
config: {
...config,
series: [
{
...config.series[0],
actLikeMainSerie: true,
seriesOptions: {
...config.series[0]?.seriesOptions,
...(mode === CompareMode.NewScale ? { priceScaleId: Direction.Left } : {}),
},
},
],
newPane: mode === CompareMode.NewPane,
},
zIndex,
onDelete: () => {
if (this.removeEntry(key)) {
this.commitEntriesChange();
}
},
moveUp,
moveDown,
paneId: associatedPane.getId(),
});
});
this.entries.set(key, {
key,
symbolId,
symbol,
symbolName,
mode,
entity,
});
this.commitEntriesChange();
await this.dataSource.waitUntilReady(symbolId);
}
public removeSymbolMode(symbolIdRaw: string, mode: CompareMode): void {
const symbolId = normalizeSymbol(symbolIdRaw);
if (!symbolId) {
return;
}
if (this.removeEntry(makeKey(symbolId, mode))) {
this.commitEntriesChange();
}
}
public removeSymbol(symbolIdRaw: string): void {
const symbolId = normalizeSymbol(symbolIdRaw);
if (!symbolId) {
return;
}
let removed = false;
for (const [key, entry] of this.entries) {
if (entry.symbolId !== symbolId) {
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(({ symbolId, symbol, symbolName, entity, mode }) => ({
symbolId,
symbol,
symbolName,
entity,
mode,
}));
}
public destroy(): void {
this.subscriptions.unsubscribe();
this.clear();
this.itemsSubject.complete();
this.entitiesSubject.complete();
}
private setup = async (compareIndicators: CompareSnapshot[]): Promise<void> => {
this.restoringInitialIndicators = true;
const mainPane = this.paneManager.getMainPane();
const mainPaneId = mainPane.getId();
const mainScaleMode =
mainPane.getPriceScale(Direction.Right).getMode() === PriceScaleMode.Percentage
? CompareMode.Percentage
: CompareMode.Absolute;
try {
for (const compareIndicator of compareIndicators) {
const { scale, symbolInfo, seriesName, paneId } = compareIndicator;
const symbolInfoNormalized = normalizeSymbolInfo(symbolInfo);
if (!symbolInfoNormalized) {
continue;
}
if (mainPaneId !== paneId && scale === Direction.Left) {
throw new Error('[CompareManager]: несколько шкал на второстеменном пейне не поддерживаются');
}
const compareMode =
scale === Direction.Left ? CompareMode.NewScale : mainPaneId === paneId ? mainScaleMode : CompareMode.NewPane;
// eslint-disable-next-line no-await-in-loop
await this.setSymbolMode(seriesName, symbolInfoNormalized, compareMode, 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();
return true;
}
private commitEntriesChange(): void {
this.applyPolicy();
this.publish();
}
private publish(): void {
const entries = Array.from(this.entries.values());
this.itemsSubject.next(
entries.map(({ symbolId, symbol, symbolName, mode }) => ({
symbolId,
symbol,
symbolName,
mode,
})),
);
this.entitiesSubject.next(entries.map(({ entity }) => entity));
}
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 {
let percentageComparisonActive = false;
let newScaleComparisonActive = false;
for (const { mode } of this.entries.values()) {
if (mode === CompareMode.Percentage) {
percentageComparisonActive = true;
}
if (mode === CompareMode.NewScale) {
newScaleComparisonActive = true;
}
}
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 isMainScaleCompareMode(mode: CompareMode): mode is MainScaleCompareMode {
return mode === CompareMode.Absolute || mode === CompareMode.Percentage;
}
function makeKey(symbolId: string, mode: CompareMode): string {
const keyMode = isMainScaleCompareMode(mode) ? 'MAIN' : mode;
return `${symbolId}|${keyMode}`;
}
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);
}
function getDefaultCompareIndicatorConfig({
symbolInfo,
seriesType,
usedColors,
}: {
symbolInfo: SymbolInfo;
seriesType: SeriesType;
usedColors: string[];
}): IndicatorConfig {
const reservedColors = new Set(usedColors.map(normalizeColor));
return {
symbolInfo,
newPane: true,
label: symbolInfo.symbolName,
series: [
{
name: seriesType,
id: `compare-${crypto.randomUUID()}`,
seriesOptions: {
visible: true,
color: getPaletteColorFromIndex(reservedColors, 0),
},
},
],
};
}
this.eventManager = new EventManager({
initialTimeframe: timeframe,
initialSeries: chartSeriesType,
initialSymbolInfo: {
symbolId: symbolId ?? '',
symbol,
symbolName,
},
initialTimeFormat: timeFormat,
initialDateFormat: dateFormat,
initialInterval: interval,
});