Загрузка данных
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 {
CompareItem,
CompareMode,
Direction,
IndicatorConfig,
SymbolInfo,
SymbolInfoInput,
} from '@src/types';
import { IndicatorSnapshot } from '@src/types/snapshot';
import {
createFallbackColor,
normalizeColor,
normalizeSymbol,
normalizeSymbolInfo,
} from '@src/utils';
interface CompareEntry extends CompareItem {
key: string;
symbolId$: BehaviorSubject<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,
symbolInfoInput: SymbolInfoInput,
mode: CompareMode,
paneId?: number,
): Promise<void> {
const normalizedSymbolId = normalizeSymbol(
symbolInfoInput.symbolId,
);
if (!normalizedSymbolId) {
return;
}
const symbolInfo = normalizeSymbolInfo({
...symbolInfoInput,
symbolId: normalizedSymbolId,
});
if (!symbolInfo) {
return;
}
const {
symbolId,
symbol,
symbolName,
} = symbolInfo;
if (
mode === CompareMode.NewScale &&
this.isNewScaleDisabled() &&
!this.restoringInitialIndicators
) {
return;
}
const key = makeKey(symbolId, mode);
if (this.entries.has(key)) {
return;
}
const symbolId$ = new BehaviorSubject(symbolId);
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,
symbolInfo,
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$: symbolId$,
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,
symbolId,
symbol,
symbolName,
mode,
symbolId$,
symbol$,
entity,
});
this.commitEntriesChange();
await this.dataSource.isReady(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;
}
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.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 async setup(
initialIndicators: IndicatorSnapshot[],
): Promise<void> {
this.restoringInitialIndicators = true;
try {
for (
let index = 0;
index < initialIndicators.length;
index += 1
) {
const indicator =
initialIndicators[index];
if (
indicator.indicatorType !== undefined
) {
continue;
}
const symbolInfoInput =
indicator.config?.symbolInfo;
if (!symbolInfoInput) {
continue;
}
const symbolInfo =
normalizeSymbolInfo(
symbolInfoInput,
);
if (!symbolInfo) {
continue;
}
const series =
indicator.config?.series[0];
if (!series) {
continue;
}
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,
symbolInfo,
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.symbolId$.complete();
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({
symbolId: values[index].symbolId,
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(
symbolId: string,
mode: CompareMode,
): string {
return `${symbolId}|${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,
);
}
function getDefaultCompareIndicatorConfig(
seriesType: SeriesType,
symbolInfo: SymbolInfo,
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,
),
},
},
],
};
}