Загрузка данных
import dayjs from 'dayjs';
import { BehaviorSubject, firstValueFrom, Observable, Subject } from 'rxjs';
import { filter, take } from 'rxjs/operators';
import { Candle } from '@src/types';
import { Timeframes } from '@src/types/timeframes';
import { ensureDefined, getStartTime, normalizeSeriesData, parseTimeframe } from '@src/utils';
export interface SymbolSourceParams {
symbol: string;
getData: (timeframe: Timeframes, symbol: string, until?: Candle) => Promise<Candle[] | null>;
getTimeframe: () => Timeframes;
}
export class SymbolSource {
public readonly symbol: string;
private readonly getData: SymbolSourceParams['getData'];
private readonly getTimeframe: SymbolSourceParams['getTimeframe'];
private readonly currentDataSubject = new BehaviorSubject<Candle[]>([]);
private readonly realtimeSubject = new Subject<Candle>();
private readonly lastCandleSubject = new BehaviorSubject<Candle | null>(null);
private readonly isLoadingSubject = new BehaviorSubject<boolean>(false);
private readonly isInitializedSubject = new BehaviorSubject<boolean>(false);
private realtimeCache: Candle[] = [];
private realtimeBuffer: Candle[] = [];
private oldestCandle: Candle | null = null;
private newestCandle: Candle | null = null;
private loadSeq = 0;
private loadingPromise: Promise<void> | null = null;
private isEndOfData = false;
constructor({ symbol, getData, getTimeframe }: SymbolSourceParams) {
this.symbol = symbol;
this.getData = getData;
this.getTimeframe = getTimeframe;
}
public init(): void {
this.reload(this.getTimeframe());
}
public data$(): Observable<Candle[]> {
return this.currentDataSubject.asObservable();
}
public realtime$(): Observable<Candle> {
return this.realtimeSubject.asObservable();
}
public lastCandle$(): Observable<Candle | null> {
return this.lastCandleSubject.asObservable();
}
public isInitialized$(): Observable<boolean> {
return this.isInitializedSubject.asObservable();
}
public isLoadingValue(): boolean {
return this.isLoadingSubject.value;
}
public getOldestTime(): number | null {
return this.oldestCandle?.time ?? null;
}
public getLastValue(): Candle | null {
return this.lastCandleSubject.value;
}
public async ready(): Promise<void> {
if (this.isInitializedSubject.value) return;
await firstValueFrom(this.isInitializedSubject.pipe(filter(Boolean), take(1)));
}
public destroy(): void {
this.loadSeq += 1;
this.loadingPromise = null;
this.currentDataSubject.complete();
this.realtimeSubject.complete();
this.lastCandleSubject.complete();
this.isLoadingSubject.complete();
this.isInitializedSubject.complete();
this.realtimeCache = [];
this.realtimeBuffer = [];
this.oldestCandle = null;
this.newestCandle = null;
}
public pushRealtime(next: Candle): void {
const tf = this.getTimeframe();
const aligned = this.alignCandle(tf, next);
const res = SymbolSource.stackCandles(tf, aligned, this.newestCandle, this.realtimeCache);
this.newestCandle = res.newestCandle;
this.realtimeCache = res.realtimeCache;
this.lastCandleSubject.next(res.newestCandle);
if (!this.isInitializedSubject.value) {
this.realtimeBuffer.push(res.newestCandle);
return;
}
this.realtimeSubject.next(res.newestCandle);
}
public saveRealtimeCache(): void {
if (this.realtimeCache.length === 0) return;
const tf = this.getTimeframe();
this.realtimeCache = this.normalizeList(tf, this.realtimeCache);
const left = this.currentDataSubject.value;
const right = this.realtimeCache;
if (left.length === 0 || right.length === 0) {
this.realtimeCache = [];
return;
}
const lastLeft = left[left.length - 1];
const firstRight = right[0];
const next =
lastLeft && firstRight && lastLeft.time === firstRight.time
? left
.slice(0, -1)
.concat(SymbolSource.combineCandles(lastLeft, firstRight, true))
.concat(right.slice(1))
: left.concat(right);
const normalizedNext = this.normalizeList(tf, next);
this.currentDataSubject.next(normalizedNext);
this.newestCandle = normalizedNext[normalizedNext.length - 1] ?? null;
this.realtimeCache = [];
this.lastCandleSubject.next(this.newestCandle);
}
public async loadMoreHistory(): Promise<void> {
await this.ready();
if (this.isEndOfData) return;
if (this.loadingPromise) {
return this.loadingPromise;
}
this.loadSeq += 1;
if (!this.oldestCandle) return;
const tf = ensureDefined(this.getTimeframe());
const task = (async () => {
this.isLoadingSubject.next(true);
try {
this.saveRealtimeCache();
if (!this.oldestCandle) return;
const olderData = await this.getData(tf, this.symbol, this.oldestCandle);
if (olderData === null) {
this.isEndOfData = true;
return;
}
const older = this.normalizeList(tf, olderData);
if (older.length === 0) return;
const current = this.currentDataSubject.value;
const combinedRaw = SymbolSource.mergeHistory(older, current);
const combined = this.normalizeList(tf, combinedRaw);
const nextOldest = combined[0] ?? null;
if (!nextOldest || !this.oldestCandle || nextOldest.time >= this.oldestCandle.time) {
return;
}
this.oldestCandle = nextOldest;
this.newestCandle = combined[combined.length - 1] ?? this.newestCandle;
this.currentDataSubject.next(combined);
this.lastCandleSubject.next(this.newestCandle);
} catch (error) {
console.error('[DataSource] Ошибка при догрузке истории:', error);
} finally {
this.isLoadingSubject.next(false);
}
})();
this.loadingPromise = task;
task.finally(() => {
if (this.loadingPromise === task) {
this.loadingPromise = null;
}
});
await task;
}
public async loadAllHistory(): Promise<void> {
await this.ready();
if (this.isEndOfData) return;
if (this.loadingPromise) {
await this.loadingPromise;
}
const seq = ++this.loadSeq;
const tf = ensureDefined(this.getTimeframe());
const task = (async () => {
this.isLoadingSubject.next(true);
try {
this.saveRealtimeCache();
let current = this.currentDataSubject.value;
let oldest = current[0] ?? null;
if (!oldest) return;
const seenOldestTimes = new Set<number>();
while (oldest) {
if (seq !== this.loadSeq) return;
if (seenOldestTimes.has(oldest.time)) {
break;
}
seenOldestTimes.add(oldest.time);
// eslint-disable-next-line no-await-in-loop
const olderData = await this.getData(tf, this.symbol, oldest);
if (seq !== this.loadSeq) return;
if (olderData === null) {
this.isEndOfData = true;
break;
}
const older = this.normalizeList(tf, olderData);
if (older.length === 0) break;
const combinedRaw = SymbolSource.mergeHistory(older, current);
const combined = this.normalizeList(tf, combinedRaw);
const nextOldest = combined[0] ?? null;
if (!nextOldest || nextOldest.time >= oldest.time) {
break;
}
oldest = nextOldest;
current = combined;
}
this.oldestCandle = current[0] ?? null;
this.newestCandle = current[current.length - 1] ?? null;
this.currentDataSubject.next(current);
this.saveRealtimeCache();
} catch (error) {
console.error('[DataSource] Ошибка при полной загрузке истории:', error);
} finally {
if (seq === this.loadSeq) {
this.isLoadingSubject.next(false);
}
}
})();
this.loadingPromise = task;
task.finally(() => {
if (this.loadingPromise === task) {
this.loadingPromise = null;
}
});
await task;
}
public async loadTill(time: number): Promise<void> {
await this.ready();
while (this.oldestCandle && this.oldestCandle.time >= time) {
const before = this.oldestCandle.time;
// eslint-disable-next-line no-await-in-loop
await this.loadMoreHistory();
if (!this.oldestCandle) break;
if (this.oldestCandle.time === before) break;
}
}
public async reload(tf: Timeframes): Promise<void> {
this.loadSeq += 1;
const seq = this.loadSeq;
this.isInitializedSubject.next(false);
this.isLoadingSubject.next(true);
this.currentDataSubject.next([]);
this.realtimeCache = [];
this.realtimeBuffer = [];
this.oldestCandle = null;
this.newestCandle = null;
this.isEndOfData = false;
this.lastCandleSubject.next(null);
const task = (async () => {
try {
const loaded = (await this.getData(tf, this.symbol)) ?? [];
if (seq !== this.loadSeq) return;
const normalized = this.normalizeList(tf, loaded);
this.oldestCandle = normalized[0] ?? null;
this.newestCandle = normalized[normalized.length - 1] ?? null;
this.currentDataSubject.next(normalized);
this.lastCandleSubject.next(this.newestCandle);
this.isInitializedSubject.next(true);
this.flushRealtimeBuffer();
} catch (error) {
console.error('[DataSource] Ошибка при загрузке данных:', error);
} finally {
if (seq === this.loadSeq) this.isLoadingSubject.next(false);
}
})();
this.loadingPromise = task;
task.finally(() => {
if (this.loadingPromise === task) this.loadingPromise = null;
});
await task;
}
private flushRealtimeBuffer(): void {
if (this.realtimeBuffer.length === 0) return;
const newestTime = this.newestCandle?.time ?? Number.NEGATIVE_INFINITY;
const filtered = this.realtimeBuffer.filter((c) => c.time >= newestTime);
const map = new Map<number, Candle>();
for (const c of filtered) {
map.set(c.time, c);
}
const unique = Array.from(map.values()).sort((a, b) => a.time - b.time);
for (const c of unique) {
this.realtimeSubject.next(c);
}
this.realtimeBuffer = [];
}
private alignCandle(tf: Timeframes, c: Candle): Candle {
const timeMS = c.time > 1e10 ? Math.floor(c.time) : Math.floor(c.time * 1000);
const startMS = getStartTime(tf, timeMS);
return { ...c, time: startMS };
}
private normalizeList(tf: Timeframes, list: Candle[]): Candle[] {
const aligned = list.map((c) => this.alignCandle(tf, c)).sort((a, b) => a.time - b.time);
return normalizeSeriesData(aligned);
}
private static stackCandles(
timeframe: Timeframes,
next: Candle,
newestCandle: Candle | null,
realtimeCache: Candle[],
): { realtimeCache: Candle[]; newestCandle: Candle } {
const { candleWidth, dayjsUnit } = parseTimeframe(timeframe);
const historicalUpdate = newestCandle
? next.time - newestCandle.time < dayjs.duration(candleWidth, dayjsUnit).as('s')
: false;
if (!historicalUpdate) {
const nextNewestCandle = { ...next, time: getStartTime(timeframe, next.time * 1000) };
realtimeCache.push(nextNewestCandle);
return { realtimeCache, newestCandle: nextNewestCandle };
}
const dataToSet = SymbolSource.combineCandles(ensureDefined(newestCandle), next);
if (realtimeCache.length === 0) {
realtimeCache.push(dataToSet);
return { realtimeCache, newestCandle: dataToSet };
}
realtimeCache[realtimeCache.length - 1] = dataToSet;
return { realtimeCache, newestCandle: dataToSet };
}
private static mergeHistory(older: Candle[], current: Candle[]): Candle[] {
if (current.length === 0) return older;
if (older.length === 0) return current;
const lastOlder = older[older.length - 1];
const firstCurrent = current[0];
if (lastOlder && firstCurrent && lastOlder.time === firstCurrent.time) {
const merged = SymbolSource.combineCandles(lastOlder, firstCurrent, true);
return older.slice(0, -1).concat(merged).concat(current.slice(1));
}
return older.concat(current);
}
private static combineCandles(left: Candle, right: Candle, volumeStacked?: boolean): Candle {
return {
time: left.time,
open: left.open,
high: Math.max(left.high, right.high),
low: Math.min(left.low, right.low),
close: right.close,
volume: volumeStacked ? (right.volume ?? 0) : (left.volume ?? 0) + (right.volume ?? 0),
};
}
}
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 panesIdIterator = 0;
constructor(params: PaneManagerParams) {
this.paneChartInheritedParams = { ...params, isMainPane: false };
this.mainPane = new Pane({ ...params, isMainPane: true, id: 0, onDelete: () => {} });
this.panesMap.set(this.panesIdIterator++, this.mainPane);
this.setup(params.panesSnapshot);
}
private setup(panesSnapshot: PaneSnapshot[]) {
panesSnapshot.forEach((paneSnap: PaneSnapshot) => {
const { isMain, id, indicators, drawings } = paneSnap;
this.panesMap.get(id)?.destroy();
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();
pane.setDrawingsSnapshot(drawings);
}
const lastPane = Array.from(this.panesMap.values()).at(-1);
lastPane?.setIsLast(true);
});
}
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;
};
public addPane(dataSource?: DataSource): Pane {
const id = this.panesIdIterator++;
const newPane = new Pane({
...this.paneChartInheritedParams,
id,
dataSource: dataSource ?? null,
basedOn: dataSource ? undefined : this.mainPane,
onDelete: () => {
this.panesIdIterator--;
this.panesMap.delete(id);
const prevPane = Array.from(this.panesMap.values()).at(-1);
prevPane?.setIsLast(true);
},
});
const prevPane = Array.from(this.panesMap.values()).at(-1);
prevPane?.setIsLast(false);
newPane.setIsLast(true);
this.panesMap.set(id, newPane);
return newPane;
}
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 { IChartApi, ISeriesApi, SeriesType } from 'lightweight-charts';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { EventManager } from '@core';
import { DOMModel } from '@core/DOMModel';
import { Drawing } from '@core/Drawings';
import { EntitySettingsModal } from '@src/components/EntitySettingsModal';
import { drawingLabelById, drawingsMap, DrawingsNames } from '@src/constants';
import { ModalRenderer } from '@src/core/ModalRenderer';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ActiveDrawingTool } from '@src/types';
interface DrawingsManagerParams {
eventManager: EventManager;
mainSeries$: Observable<SeriesStrategies | null>;
lwcChart: IChartApi;
DOM: DOMModel;
container: HTMLElement;
modalRenderer: ModalRenderer;
paneId: number;
}
export interface DrawingSnapshotItem {
id: string;
drawingName: DrawingsNames;
state: unknown;
}
interface CreateDrawingOptions {
id?: string;
state?: unknown;
shouldUpdateDrawingsList?: boolean;
}
export type DrawingsManagerSnapshot = DrawingSnapshotItem[];
export class DrawingsManager {
private eventManager: EventManager;
private lwcChart: IChartApi;
private DOM: DOMModel;
private container: HTMLElement;
private modalRenderer: ModalRenderer;
private mainSeries: SeriesStrategies | null = null;
private subscriptions = new Subscription();
private drawings$ = new BehaviorSubject<Drawing[]>([]);
private activeTool$ = new BehaviorSubject<ActiveDrawingTool>('crosshair');
private endlessMode$ = new BehaviorSubject(false);
private recreateScheduled = false;
private pendingSnapshot: DrawingsManagerSnapshot | null = null;
private paneId: number;
constructor({ eventManager, mainSeries$, lwcChart, DOM, container, modalRenderer, paneId }: DrawingsManagerParams) {
this.DOM = DOM;
this.eventManager = eventManager;
this.paneId = paneId;
this.lwcChart = lwcChart;
this.container = container;
this.modalRenderer = modalRenderer;
this.subscriptions.add(
mainSeries$.subscribe((series) => {
if (!series) {
return;
}
this.mainSeries = series;
this.drawings$.value.forEach((drawing) => drawing.rebind(series));
if (this.pendingSnapshot) {
const snapshot = this.pendingSnapshot;
this.pendingSnapshot = null;
this.setSnapshot(snapshot);
}
}),
);
window.addEventListener('pointerup', this.handlePointerUp);
this.container.addEventListener('click', this.handleClick);
this.container.addEventListener('pointerdown', this.handlePointerDown);
}
private handlePointerDown = (): void => {
this.DOM.refreshEntities();
};
private handlePointerUp = (): void => {
this.DOM.refreshEntities();
this.updateActiveTool();
};
private handleClick = (): void => {
this.DOM.refreshEntities();
this.updateActiveTool();
};
private updateActiveTool = (): void => {
const hasPendingDrawing = this.drawings$.value.some((drawing) => drawing.isCreationPending());
if (hasPendingDrawing) {
return;
}
const activeTool = this.activeTool$.value;
const isSingleInstanceTool = activeTool !== 'crosshair' && drawingsMap[activeTool]?.singleInstance;
if (activeTool !== 'crosshair' && this.endlessMode$.value && !isSingleInstanceTool) {
if (this.recreateScheduled) {
return;
}
this.recreateScheduled = true;
queueMicrotask(() => {
this.recreateScheduled = false;
const currentTool = this.activeTool$.value;
const hasPendingAfterTick = this.drawings$.value.some((drawing) => drawing.isCreationPending());
if (currentTool === 'crosshair') {
return;
}
if (!this.endlessMode$.value) {
return;
}
if (drawingsMap[currentTool]?.singleInstance) {
return;
}
if (hasPendingAfterTick) {
return;
}
this.createDrawing(currentTool);
});
return;
}
this.activeTool$.next('crosshair');
};
private removeDrawing = (id: string): void => {
const drawing = this.drawings$.value.find((item) => item.id === id);
if (!drawing) {
return;
}
this.removeDrawings([drawing]);
};
private removeDrawingsByName(name: DrawingsNames, shouldUpdateTool = true): void {
const drawingsToRemove = this.drawings$.value.filter((drawing) => drawing.getDrawingName() === name);
this.removeDrawings(drawingsToRemove, shouldUpdateTool);
}
private removePendingDrawings(shouldUpdateTool = true): void {
const drawingsToRemove = this.drawings$.value.filter((drawing) => drawing.isCreationPending());
this.removeDrawings(drawingsToRemove, shouldUpdateTool);
}
private removeDrawings(drawingsToRemove: Drawing[], shouldUpdateTool = true): void {
if (!drawingsToRemove.length) {
return;
}
drawingsToRemove.forEach((drawing) => {
drawing.destroy();
this.DOM.removeEntity(drawing);
});
this.drawings$.next(this.drawings$.value.filter((drawing) => !drawingsToRemove.includes(drawing)));
if (shouldUpdateTool) {
this.updateActiveTool();
}
this.DOM.refreshEntities();
}
public addDrawingForce = (name: DrawingsNames): void => {
this.removePendingDrawings(false);
if (drawingsMap[name].singleInstance) {
this.removeDrawingsByName(name, false);
}
this.activeTool$.next(name);
this.createDrawing(name);
this.DOM.refreshEntities();
};
private createDrawing(name: DrawingsNames, options: CreateDrawingOptions = {}): Drawing {
if (!this.mainSeries) {
throw new Error('[Drawings] main series is not defined');
}
const { id, state, shouldUpdateDrawingsList = true } = options;
const config = drawingsMap[name];
const drawingId = id ?? crypto.randomUUID();
let createdDrawing: Drawing | null = null;
const construct = (chart: IChartApi, series: ISeriesApi<SeriesType>) =>
config.construct({
chart,
series,
eventManager: this.eventManager,
container: this.container,
removeSelf: () => this.removeDrawing(drawingId),
openSettings: () => {
if (createdDrawing) {
this.openSettings(createdDrawing);
}
},
});
const drawingFactory = (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) =>
new Drawing({
lwcChart: this.lwcChart,
mainSeries: this.mainSeries as SeriesStrategies,
id: drawingId,
drawingName: name,
name: drawingLabelById[name],
onDelete: this.removeDrawing,
zIndex,
moveDown,
moveUp,
construct,
paneId: this.paneId,
});
const entity = this.DOM.setEntity<Drawing>(drawingFactory);
createdDrawing = entity;
if (state !== undefined) {
entity.setState(state);
}
if (shouldUpdateDrawingsList) {
this.drawings$.next([...this.drawings$.value, entity]);
}
return entity;
}
public getSnapshot(): DrawingsManagerSnapshot {
return this.drawings$.value
.filter((drawing) => !drawing.isCreationPending())
.map((drawing) => ({
id: drawing.id,
drawingName: drawing.getDrawingName(),
state: drawing.getState(),
}));
}
public setSnapshot(snapshot: DrawingsManagerSnapshot): void {
if (!Array.isArray(snapshot)) {
return;
}
if (!this.mainSeries) {
this.pendingSnapshot = snapshot;
return;
}
this.removeDrawings(this.drawings$.value, false);
const restoredDrawings = snapshot.reduce<Drawing[]>((drawings, item) => {
if (!drawingsMap[item.drawingName]) {
return drawings;
}
drawings.push(
this.createDrawing(item.drawingName, { id: item.id, state: item.state, shouldUpdateDrawingsList: false }),
);
return drawings;
}, []);
this.drawings$.next(restoredDrawings);
this.activeTool$.next('crosshair');
this.DOM.refreshEntities();
}
public setEndlessDrawingMode = (value: boolean): void => {
this.endlessMode$.next(value);
};
public isEndlessDrawingsMode(): Observable<boolean> {
return this.endlessMode$.asObservable();
}
public getActiveTool(): Observable<ActiveDrawingTool> {
return this.activeTool$.asObservable();
}
public activateCrosshair(): void {
this.removePendingDrawings(false);
this.activeTool$.next('crosshair');
this.DOM.refreshEntities();
}
public entities(): Observable<Drawing[]> {
return this.drawings$.asObservable();
}
private openSettings = (drawing: Drawing) => {
const tabs = drawing.getSettingsTabs();
if (!tabs.length || tabs.every((tab) => tab.fields.length === 0)) {
return;
}
let settings = drawing.getSettings();
this.modalRenderer.renderComponent(
<EntitySettingsModal
tabs={tabs}
values={settings}
onChange={(nextSettings) => {
settings = nextSettings;
}}
initialTabKey={tabs[0]?.key}
/>,
{
size: 'sm',
title: drawing.name,
onSave: () => drawing.updateSettings(settings),
},
);
};
public getDrawings(): Drawing[] {
return this.drawings$.value;
}
public hideAll(): void {
this.drawings$.value.forEach((drawing) => drawing.hide());
this.DOM.refreshEntities();
}
public destroy(): void {
window.removeEventListener('pointerup', this.handlePointerUp);
this.container.removeEventListener('click', this.handleClick);
this.container.removeEventListener('pointerdown', this.handlePointerDown);
this.drawings$.value.forEach((drawing) => drawing.destroy());
this.subscriptions.unsubscribe();
this.drawings$.complete();
this.activeTool$.complete();
this.endlessMode$.complete();
}
}
import { combineLatest, Subscription } from 'rxjs';
import { ControlBar } from '@components/ControlBar';
import { Footer } from '@components/Footer';
import { Header } from '@components/Header';
import { DataSource, DataSourceParams } from '@core/DataSource';
import { ModalRenderer } from '@core/ModalRenderer';
import { SettingsModal } from '@src/components/SettingsModal';
import Toolbar from '@src/components/Toolbar';
import { IndicatorsIds } from '@src/constants';
import { CompareManager } from '@src/core/CompareManager';
import { FullscreenController } from '@src/core/Fullscreen';
import { configureThemeStore } from '@src/theme/store';
import { ThemeKey, ThemeMode } from '@src/theme/types';
import { Candle, ChartSeriesType, ChartTypeOptions, OHLCConfig, TooltipConfig } from '@src/types';
import { ISerializable, MoexChartSnapshot } from '@src/types/snapshot';
import { Timeframes } from '@src/types/timeframes';
import { setPricePrecision } from '@src/utils';
import { Chart } from './Chart';
import { ChartSettings, ChartSettingsSource } from './ChartSettings';
import { ContainerManager } from './ContainerManager';
import { EventManager } from './EventManager';
import { ReactRenderer } from './ReactRenderer';
import { TimeScaleHoverController } from './TimescaleHoverController';
import { UIRenderer } from './UIRenderer';
import 'exchange-elements/dist/fonts/inter/font.css';
import 'exchange-elements/dist/style.css';
import 'exchange-elements/dist/tokens/moex.css';
// todo: forbid @lib in /src
export interface ChartCollectionPreset {
undoRedoEnabled?: boolean;
showMenuButton?: boolean;
showBottomPanel?: boolean;
showControlBar?: boolean;
showFullscreenButton?: boolean;
showSettingsButton?: boolean;
showCompareButton?: boolean;
/**
* Дефолтная конфигурация тултипа - всегда показывается по умолчанию.
* При добавлении/изменении полей в конфиге - они объединяются с дефолтными значениями.
*
* Полная кастомизация:
* @example
* ```typescript
* tooltipConfig: {
* time: { visible: true, label: 'Дата и время' },
* symbol: { visible: true, label: 'Инструмент' },
* close: { visible: true, label: 'Курс' },
* change: { visible: true, label: 'Изменение' },
* volume: { visible: true, label: 'Объем' },
* open: { visible: false },
* high: { visible: false },
* low: { visible: false }
* }
*```
*/
tooltipConfig?: TooltipConfig; // todo: wrap into ChartCollectionSettings
size?:
| {
width: number;
height: number;
}
| false;
supportedTimeframes: Timeframes[];
supportedChartSeriesTypes: ChartSeriesType[];
getDataSource: DataSourceParams['getData'];
startRealtime: (
getSymbols: () => string[],
getTimeframe: () => Timeframes,
update: (symbol: string, candle: Candle) => void,
periodMs?: number,
) => () => void;
theme: ThemeKey; // 'mb' | 'mxt' | 'tr'
ohlc: OHLCConfig;
mode?: ThemeMode; // 'light' | 'dark'
openCompareModal?: () => void;
}
export interface IMoexChart {
snapshot: MoexChartSnapshot; // todo: combine with snapshot
chartCollectionPreset: ChartCollectionPreset;
container: HTMLElement;
lwcInheritedChartOptions?: ChartTypeOptions;
}
export class MoexChart implements ISerializable<MoexChartSnapshot> {
private chart!: Chart;
private resizeObserver?: ResizeObserver;
private eventManager!: EventManager;
private rootContainer!: HTMLElement;
private headerRenderer!: UIRenderer;
private modalRenderer!: ModalRenderer;
private toolbarRenderer: UIRenderer | undefined;
private controlBarRenderer?: UIRenderer;
private footerRenderer?: UIRenderer;
private timeScaleHoverController!: TimeScaleHoverController;
private dataSource!: DataSource;
private subscriptions = new Subscription();
private fullscreen!: FullscreenController;
private chartCollectionPresetSettings!: ChartCollectionPreset;
constructor(config: IMoexChart) {
this.setup(config);
}
private setup = (config: IMoexChart) => {
this.chartCollectionPresetSettings = config.chartCollectionPreset;
setPricePrecision(config.chartCollectionPreset.ohlc.precision);
this.eventManager = new EventManager({
initialTimeframe: config.snapshot.charts[0].timeframe,
initialSeries: config.snapshot.charts[0].chartSeriesType,
initialSymbol: config.snapshot.charts[0].symbol,
initialChartOptions: config.lwcInheritedChartOptions,
});
// todo: сюда прокидывается не подходящий под сигнатуру интерфейс. Функция не работает
// if (config.lwcInheritedChartOptions) {
// this.setSettings(config.lwcInheritedChartOptions);
// }
this.dataSource = new DataSource({
getData: config.chartCollectionPreset.getDataSource,
eventManager: this.eventManager,
});
this.rootContainer = config.container;
this.fullscreen = new FullscreenController(this.rootContainer);
const store = configureThemeStore(config.chartCollectionPreset);
const {
chartAreaContainer,
toolBarContainer,
headerContainer,
modalContainer,
controlBarContainer,
footerContainer,
toggleToolbar, // todo: move this function to toolbarModel
} = ContainerManager.createContainers({
parentContainer: this.rootContainer,
showBottomPanel: config.chartCollectionPreset.showBottomPanel, // todo: apply config.showBottomPanel in FullscreenController
showMenuButton: config.chartCollectionPreset.showMenuButton,
});
this.modalRenderer = new ModalRenderer(modalContainer);
this.chart = new Chart({
params: {
dataSource: this.dataSource,
eventManager: this.eventManager,
modalRenderer: this.modalRenderer,
ohlcConfig: config.chartCollectionPreset.ohlc, // todo: omptimize
tooltipConfig: config.chartCollectionPreset.tooltipConfig ?? {},
panes: config.snapshot.charts[0].panes,
},
lwcChartConfig: {
container: chartAreaContainer,
seriesTypes: config.chartCollectionPreset.supportedChartSeriesTypes,
theme: store.theme,
mode: store.mode,
chartOptions: config.lwcInheritedChartOptions, // todo: remove, use only model from eventManager
},
});
this.subscriptions.add(
combineLatest([store.theme$, store.mode$]).subscribe(([theme, mode]) => {
this.chart.updateTheme(theme, mode);
document.documentElement.dataset.theme = theme;
document.documentElement.dataset.mode = mode;
}),
);
const realtimeParams = this.chart.getRealtimeApi();
this.subscriptions.add(
config.chartCollectionPreset.startRealtime(
realtimeParams.getSymbols,
realtimeParams.getTimeframe,
realtimeParams.update,
),
);
this.headerRenderer = new ReactRenderer(headerContainer);
this.toolbarRenderer = new ReactRenderer(toolBarContainer);
if (config.chartCollectionPreset.showControlBar) {
this.controlBarRenderer = new ReactRenderer(controlBarContainer);
}
if (config.chartCollectionPreset.showBottomPanel) {
this.footerRenderer = new ReactRenderer(footerContainer);
}
this.timeScaleHoverController = new TimeScaleHoverController({
eventManager: this.eventManager,
controlBarContainer,
chartContainer: chartAreaContainer,
});
this.renderAttachments(config, toggleToolbar);
};
public setSettings(settings: ChartSettingsSource): void {
this.eventManager.importChartSettings(settings);
}
public getSettings(): ChartSettings {
return this.eventManager.exportChartSettings();
}
public getRealtimeApi() {
return this.chart.getRealtimeApi();
}
// todo: описать в доке
public getCompareManager(): CompareManager {
return this.chart.getCompareManager();
}
// todo: описать в доке
public setSnapshot(snap: MoexChartSnapshot) {
const configConstructorLike: IMoexChart = {
snapshot: snap,
chartCollectionPreset: this.chartCollectionPresetSettings,
container: this.rootContainer,
};
this.destroy();
this.setup(configConstructorLike);
}
// todo: описать в доке
public getSnapshot(): MoexChartSnapshot {
const res = {
settings: this.getSettings(),
charts: [this.chart.getSnapshot()], // todo: в будущем может быть несколько инстансов чартов
};
return res;
}
public setSymbol(symbol: string): void {
if (!symbol) return;
this.eventManager.setSymbol(symbol);
}
private renderAttachments(config: IMoexChart, toggleToolbar: () => boolean) {
this.headerRenderer.renderComponent(
<Header
timeframes={config.chartCollectionPreset.supportedTimeframes}
selectedTimeframeObs={this.eventManager.getTimeframeObs()}
setTimeframe={(value) => {
this.eventManager.setTimeframe(value);
}}
seriesTypes={config.chartCollectionPreset.supportedChartSeriesTypes}
selectedSeriesObs={this.eventManager.getSelectedSeries()}
setSelectedSeries={(value) => {
this.eventManager.setSeriesSelected(value);
}}
showSettingsModal={
config.chartCollectionPreset.showSettingsButton
? () =>
this.modalRenderer.renderComponent(
<SettingsModal
// todo: deal with onSave
changeTimeFormat={(format) => this.eventManager.setTimeFormat(format)}
changeDateFormat={(format) => this.eventManager.setDateFormat(format)}
chartDateTimeFormatObs={this.eventManager.getChartOptionsModel()}
/>,
{ title: 'Настройки' },
)
: undefined
}
addIndicatorToChart={(indicatorType: IndicatorsIds) =>
this.chart.getIndicatorManager().addIndicator({ indicatorType })
}
showMenuButton={!!config.chartCollectionPreset.showMenuButton}
showFullscreenButton={!!config.chartCollectionPreset.showFullscreenButton}
fullscreen={this.fullscreen}
undoRedo={config.chartCollectionPreset.undoRedoEnabled ? this.eventManager.getUndoRedo() : undefined}
toggleToolbarVisible={toggleToolbar}
showCompareButton={!!config.chartCollectionPreset.showCompareButton}
openCompareModal={
config.chartCollectionPreset.openCompareModal ? config.chartCollectionPreset.openCompareModal : undefined
}
isMXT={config.chartCollectionPreset.theme === 'mxt'}
/>,
);
if (this.toolbarRenderer && config.chartCollectionPreset.showMenuButton) {
this.toolbarRenderer.renderComponent(
<Toolbar
toggleDOM={this.chart.getDom().toggleDOM}
addDrawing={(name) => {
// todo: deal with new panes logic
this.chart.getDrawingsManager().addDrawingForce(name);
}}
setEndlessDrawingsMode={this.chart.getDrawingsManager().setEndlessDrawingMode}
isEndlessDrawingsMode$={this.chart.getDrawingsManager().isEndlessDrawingsMode()}
activateCrosshair={() => this.chart.getDrawingsManager().activateCrosshair()}
activeTool$={this.chart.getDrawingsManager().getActiveTool()}
/>,
);
}
if (this.controlBarRenderer && config.chartCollectionPreset.showControlBar) {
this.controlBarRenderer.renderComponent(
<ControlBar
scroll={this.chart.scrollTimeScale}
zoom={this.chart.zoomTimeScale}
reset={this.chart.resetZoom}
visible={this.eventManager.getControlBarVisible()}
/>,
);
}
if (this.footerRenderer && config.chartCollectionPreset.showBottomPanel) {
this.footerRenderer.renderComponent(
<Footer
supportedTimeframes={config.chartCollectionPreset.supportedTimeframes}
setInterval={this.eventManager.setInterval}
intervalObs={this.eventManager.getInterval()}
/>,
);
}
}
/**
* Уничтожение графика и очистка ресурсов
* @returns void
*/
destroy(): void {
this.headerRenderer.destroy();
this.subscriptions.unsubscribe();
this.timeScaleHoverController.destroy();
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = undefined;
}
if (this.controlBarRenderer) {
this.controlBarRenderer.destroy();
}
if (this.footerRenderer) {
this.footerRenderer.destroy();
}
if (this.chart) {
this.chart.destroy();
}
if (this.eventManager) {
this.eventManager.destroy();
}
this.dataSource.destroy();
ContainerManager.clearContainers(this.rootContainer);
}
}
import { Button, Divider } from 'exchange-elements/v2';
import { useEffect, useState } from 'react';
import { Observable } from 'rxjs';
import { IndicatorsSelect } from '@components/IndicatorsSelect';
import { IndicatorsIds } from '@src/constants';
import { FullscreenController } from '@src/core/Fullscreen';
import { UndoRedo } from '@src/core/UndoRedo';
import { ChartSeriesType } from '@src/types';
import { Timeframes } from '@src/types/timeframes';
import { useObservable } from '@src/utils';
import { Dropdown } from '../Dropdown';
import {
BarIcon,
BurgerMenuIcon,
CandleStickIcon,
FullscreenIcon,
LineIcon,
PlusCircleIcon,
RedoIcon,
UndoIcon,
} from '../Icon';
import { SeriesMenu, TimeframesMenu } from '../Menu';
import styles from './index.module.scss';
interface HeaderProps {
timeframes: Timeframes[];
selectedTimeframeObs: Observable<Timeframes>;
setTimeframe: (value: Timeframes) => void;
seriesTypes: ChartSeriesType[];
setSelectedSeries: (next: ChartSeriesType) => void;
selectedSeriesObs: Observable<ChartSeriesType>;
showSettingsModal: (() => void) | undefined;
addIndicatorToChart: (id: IndicatorsIds) => void;
toggleToolbarVisible: () => boolean;
showCompareButton: boolean;
undoRedo: UndoRedo | undefined;
showMenuButton?: boolean;
showFullscreenButton?: boolean;
fullscreen: FullscreenController;
openCompareModal?: () => void;
isMXT: boolean;
}
export function Header({
setTimeframe,
selectedTimeframeObs,
setSelectedSeries,
selectedSeriesObs,
timeframes,
seriesTypes,
showSettingsModal,
addIndicatorToChart,
toggleToolbarVisible,
showCompareButton,
fullscreen,
undoRedo,
showFullscreenButton,
showMenuButton,
openCompareModal,
isMXT,
}: HeaderProps) {
const [isToolbarOpen, setIsToolbarOpen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(fullscreen.isFullscreen);
useEffect(() => {
const unsubscribe = fullscreen.onChange(() => setIsFullscreen(fullscreen.isFullscreen));
return unsubscribe;
}, [fullscreen]);
const selectedTimeframe = useObservable(selectedTimeframeObs);
const selectedSeries = useObservable(selectedSeriesObs);
const seriesDropdownValue =
selectedSeries === 'Line' ? <LineIcon /> : selectedSeries === 'Bar' ? <BarIcon /> : <CandleStickIcon />;
const handleOpenToolbar = () => setIsToolbarOpen(() => toggleToolbarVisible());
return (
<header className={styles.header}>
<div className={styles.group}>
{showMenuButton && (
<Button
size="sm"
className={`${styles.button} ${isToolbarOpen ? styles.pressed : ''}`}
onClick={handleOpenToolbar}
label={<BurgerMenuIcon />}
/>
)}
{showCompareButton && (
<Button
size="sm"
className={styles.button}
onClick={openCompareModal}
label={<PlusCircleIcon />}
/>
)}
</div>
{(showMenuButton || showCompareButton) && (
<Divider
direction="vertical"
pt={{ divider: { className: styles.divider } }}
/>
)}
<Dropdown
menuClassName={styles.menu}
selectedValue={selectedTimeframe}
>
<TimeframesMenu
onClick={setTimeframe}
{...{ selectedTimeframe, timeframes }}
/>
</Dropdown>
<Divider
direction="vertical"
pt={{ divider: { className: styles.divider } }}
/>
<div className={styles.group}>
<IndicatorsSelect
addIndicatorToChart={addIndicatorToChart}
isMXT={isMXT}
/>
{showSettingsModal && (
<Button
size="sm"
className={`${styles.button}`}
onClick={() => showSettingsModal()}
label="Настройки"
/>
)}
<Dropdown
menuClassName={styles.menu}
selectedValue={seriesDropdownValue}
>
<SeriesMenu
selectedType={selectedSeries}
seriesTypes={seriesTypes}
onClick={setSelectedSeries}
/>
</Dropdown>
</div>
{(!!undoRedo || showFullscreenButton) && (
<Divider
direction="vertical"
pt={{ divider: { className: styles.divider } }}
/>
)}
{!!undoRedo && (
<div className={styles.group}>
<Button
size="sm"
className={styles.button}
onClick={() => undoRedo?.undo()}
disabled={!undoRedo?.canUndo()}
label={<UndoIcon />}
/>
<Button
size="sm"
className={styles.button}
onClick={() => undoRedo?.redo()}
disabled={!undoRedo?.canRedo()}
label={<RedoIcon />}
/>
</div>
)}
{showFullscreenButton && (
<Button
size="sm"
className={`${styles.button} ${isFullscreen ? styles.pressed : ''}`}
onClick={() => fullscreen.toggle()}
label={<FullscreenIcon />}
/>
)}
</header>
);
}
import { SeriesType, Time } from 'lightweight-charts';
import { DateFormat } from '@src/utils/formatter';
import { TimeFormat } from './timeScale';
/**
* Типы графиков, поддерживаемые библиотекой
*/
export type ChartSeriesType = SeriesType;
/**
* Универсальный формат данных для всех типов графиков
*/
export interface ChartDataPoint {
time?: Time;
value?: number;
open?: number;
high?: number;
low?: number;
close?: number;
volume?: number;
}
interface CandlestickChartOptions {
upColor?: string;
downColor?: string;
wickUpColor?: string;
wickDownColor?: string;
borderVisible?: boolean;
}
interface LineChartOptions {
lineStyle?: 'solid' | 'dotted' | 'dashed';
}
interface AreaChartOptions {
topColor?: string;
bottomColor?: string;
}
interface BaselineChartOptions {
baseValue?: number;
topLineColor?: string;
bottomLineColor?: string;
topFillColor1?: string;
topFillColor2?: string;
bottomFillColor1?: string;
bottomFillColor2?: string;
}
interface TimescaleOptions {
timeVisible?: boolean;
secondsVisible?: boolean;
timeFormat?: TimeFormat;
dateFormat?: DateFormat;
}
interface CrosshairOptions {
crosshairLineXColor?: string;
crosshairLineYColor?: string;
crosshairLabelXBgColor?: string;
crosshairLabelYBgColor?: string;
}
interface HistogramOptions {
scaleMargins?: {
top: number;
bottom: number;
};
}
/**
* @deprecated
* Опции для разных типов графиков
*/
export interface ChartTypeOptions // todo: неверный интерфейс
extends HistogramOptions,
CrosshairOptions,
TimescaleOptions,
BaselineChartOptions,
AreaChartOptions,
LineChartOptions,
CandlestickChartOptions {
// Общие опции
color?: string;
lineWidth?: number;
}
export interface LineCandle {
time: number;
value: number;
volume?: number;
}
export interface Candle {
time: number;
open: number;
high: number;
low: number;
close: number;
volume?: number; // todo: volume & future metaData to customFields
}
export type Destroyable = {
destroy(): void;
};
export enum Direction {
Top = 'top',
Bottom = 'bottom',
Left = 'left',
Right = 'right',
}
// Настройки графика, которые могут изменяться в рантайме
export interface ChartOptionsModel {
dateFormat: DateFormat;
timeFormat: TimeFormat;
showTime: boolean;
}