Загрузка данных
import dayjs from 'dayjs';
import {
BarPrice,
ChartOptions,
createChart,
CrosshairMode,
DeepPartial,
IChartApi,
IRange,
LocalizationOptionsBase,
LogicalRange,
Time,
UTCTimestamp,
} from 'lightweight-charts';
import {
animationFrameScheduler,
BehaviorSubject,
combineLatest,
EMPTY,
from as fromPromise,
Observable,
Subscription,
} from 'rxjs';
import { catchError, filter, map, observeOn, switchMap, tap } from 'rxjs/operators';
import { ChartMouseEvents } from '@core/ChartMouseEvents';
import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { DrawingsManagerCollection } from '@core/DrawingsManagerCollection';
import { EventManager } from '@core/EventManager';
import { Hotkeys } from '@core/Hotkeys';
import { IndicatorManager } from '@core/IndicatorManager';
import { ModalRenderer } from '@core/ModalRenderer';
import { PaneManager } from '@core/PaneManager';
import { CHART_PANE_LEGEND_CONTAINER } from '@src/constants';
import { CompareManager } from '@src/core/CompareManager';
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, CompareSnapshot, IndicatorSnapshot, ISerializable, PaneSnapshot } from '@src/types/snapshot';
import { canvasToBlob, drawTextLayer, 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 = 500;
interface ChartParams {
params: {
dataSource: DataSource;
eventManager: EventManager;
modalRenderer: ModalRenderer;
ohlcConfig: OHLCConfig;
tooltipConfig: TooltipConfig;
panes: PaneSnapshot[];
hotkeys: Hotkeys;
};
lwcChartConfig: ChartConfig;
}
function splitIndicatorSnapshots(panes: PaneSnapshot[]): {
compareSnapshots: CompareSnapshot[];
indicatorSnapshots: IndicatorSnapshot[];
} {
const snapshots = panes.flatMap(({ id, indicators }) =>
indicators.map((indicator) => ({
...indicator,
paneId: id,
})),
);
function isIndicatorSnapshot(
input: (IndicatorSnapshot | CompareSnapshot) & { indicatorType?: unknown },
): input is IndicatorSnapshot {
return input.indicatorType !== undefined;
}
function isCompareSnapshot(
input: (IndicatorSnapshot | CompareSnapshot) & { indicatorType?: unknown },
): input is CompareSnapshot {
return input.indicatorType === undefined;
}
return {
indicatorSnapshots: snapshots.filter((x) => isIndicatorSnapshot(x)),
compareSnapshots: snapshots.filter((x) => isCompareSnapshot(x)),
};
}
/**
* Абстракция над библиотекой для построения графиков
*/
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 optionsSubscription: Subscription;
private dataSource: DataSource;
private chartConfig: ChartConfig;
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 activeSymbolIds: string[] = [];
private historyBatchRunning = false;
constructor({ params, lwcChartConfig }: ChartParams) {
const { eventManager, dataSource, modalRenderer, ohlcConfig, tooltipConfig, panes: panesSnapshot } = 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 }) => {
this.chartConfig = {
...this.chartConfig,
dateFormat,
timeFormat,
showTime,
};
this.lwcChart.applyOptions({
...getOptions(this.chartConfig),
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.paneManager = new PaneManager({
eventManager: this.eventManager,
panesSnapshot,
lwcChart: this.lwcChart,
dataSource,
DOM: this.DOM,
ohlcConfig,
subscribeChartEvent: this.subscribeChartEvent,
chartContainer: this.container,
tooltipConfig,
modalRenderer,
hotkeys: params.hotkeys,
});
this.mainSeries = this.paneManager.getMainPane().getMainSerie();
const { indicatorSnapshots, compareSnapshots } = splitIndicatorSnapshots(panesSnapshot);
this.indicatorManager = new IndicatorManager({
lwcChart: this.lwcChart,
eventManager,
dataSource: this.dataSource,
paneManager: this.paneManager,
initialIndicators: indicatorSnapshots,
DOM: this.DOM,
chartOptions: lwcChartConfig.chartOptions,
});
this.compareManager = new CompareManager({
chart: this.lwcChart,
eventManager,
dataSource: this.dataSource,
paneManager: this.paneManager,
initialIndicators: compareSnapshots,
indicatorManager: this.indicatorManager,
});
this.paneManager.start({
compareEntities$: this.compareManager.entities(),
indicatorEntities$: this.indicatorManager.entities(),
});
this.paneManager.setVisibleLogicalRange(this.lwcChart.timeScale().getVisibleLogicalRange());
this.paneManager.invalidate();
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 getDrawingsCollectionManager = (): DrawingsManagerCollection => {
return this.paneManager.getDrawingsCollectionManager();
};
public getIndicatorManager = (): IndicatorManager => {
return this.indicatorManager;
};
private onWheel = () => {
this.eventManager.resetInterval({
history: false,
});
};
private onPointerDown = (event: PointerEvent) => {
if (event.composedPath().includes(this.lwcChart.chartElement())) {
this.container.focus({ preventScroll: true });
}
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 updateTheme(theme: ThemeKey, mode: ThemeMode) {
this.chartConfig = {
...this.chartConfig,
theme,
mode,
};
this.lwcChart.applyOptions(getOptions(this.chartConfig));
this.paneManager.invalidate();
}
public destroy(): void {
this.subscriptions.unsubscribe();
this.mouseEvents.destroy();
this.compareManager.destroy();
this.paneManager.destroy();
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.activeSymbolIds,
update: (symbolId: string, candle: Candle) => {
this.dataSource.updateRealtime(symbolId, candle);
},
};
}
public getSnapshot(): ChartSnapshot {
const { seriesSelected, timeframe, dateFormat, timeFormat, interval, symbolInfo } =
this.eventManager.exportChartSettings();
return {
panes: this.paneManager.getSnapshot(),
chartSeriesType: seriesSelected,
timeframe,
dateFormat,
timeFormat,
interval,
...symbolInfo,
};
}
public async copyImage(): Promise<void> {
const canvas = this.lwcChart.takeScreenshot(true, false);
const chartElement = this.lwcChart.chartElement();
chartElement.querySelectorAll<HTMLElement>(`.${CHART_PANE_LEGEND_CONTAINER}`).forEach((legend) => {
if (legend.hidden || window.getComputedStyle(legend).display === 'none') {
return;
}
drawTextLayer(canvas, legend, chartElement);
});
const blob = await canvasToBlob(canvas);
await navigator.clipboard.write([
new ClipboardItem({
'image/png': blob,
}),
]);
}
private scheduleHistoryBatch = () => {
if (this.historyBatchRunning || this.activeSymbolIds.length === 0) {
return;
}
this.historyBatchRunning = true;
requestAnimationFrame(() => {
const symbolIds = this.activeSymbolIds.slice();
Promise.all(symbolIds.map((symbolId) => this.dataSource.loadMoreHistory(symbolId))).finally(() => {
this.historyBatchRunning = false;
const range = this.lwcChart.timeScale().getVisibleLogicalRange();
if (range && range.from < HISTORY_LOAD_THRESHOLD) {
this.scheduleHistoryBatch();
}
});
});
};
private syncActiveSymbols(symbolIds: string[]): string[] {
const symbolsChanged =
this.activeSymbolIds.length !== symbolIds.length ||
this.activeSymbolIds.some((symbolId, index) => symbolId !== symbolIds[index]);
if (!symbolsChanged) {
return [];
}
const previousSymbolIds = new Set(this.activeSymbolIds);
this.activeSymbolIds = symbolIds;
this.dataSource.setSymbols(symbolIds);
return symbolIds.filter((symbolId) => !previousSymbolIds.has(symbolId));
}
private warmupSymbolIds(symbolIds: string[]): void {
if (symbolIds.length === 0) {
return;
}
const range = this.lwcChart.timeScale().getVisibleRange();
if (!range) {
return;
}
const { from } = range as IRange<number>;
if (!from) {
return;
}
Promise.all(symbolIds.map((symbolId) => this.dataSource.loadTill(symbolId, from))).catch((error) => {
console.error('[Chart] Ошибка при прогреве символов:', error);
});
}
private async loadIntervalRange(interval: Intervals, symbolIds: string[]): Promise<IRange<Time> | null> {
if (interval === Intervals.All) {
await Promise.all(symbolIds.map((symbolId) => this.dataSource.loadAllHistory(symbolId)));
const firstTimes = symbolIds
.map((symbolId) => this.dataSource.getOldestTime(symbolId))
.filter((time): time is number => time !== null);
const lastTimes = symbolIds
.map((symbolId) => this.dataSource.getLastCandle(symbolId)?.time)
.filter((time): time is number => time !== undefined);
if (firstTimes.length === 0 || lastTimes.length === 0) {
return null;
}
return {
from: Math.min(...firstTimes) as Time,
to: Math.max(...lastTimes) as Time,
};
}
const { from, to } = getIntervalRange(interval);
await Promise.all(symbolIds.map((symbolId) => this.dataSource.loadTill(symbolId, from)));
const hasData = symbolIds.some((symbolId) => this.dataSource.getLastCandle(symbolId) !== null);
if (!hasData) {
return null;
}
return {
from: from as Time,
to: to as Time,
};
}
private setupDataSourceSubs(): void {
const symbolIds$ = combineLatest([this.eventManager.symbolId(), this.compareManager.itemsObs()]).pipe(
map(([mainSymbolId, items]) =>
Array.from(
new Set([mainSymbolId, ...items.map(({ symbolId }) => symbolId)].filter((symbolId) => symbolId !== '')),
),
),
);
this.subscriptions.add(
combineLatest([this.eventManager.getInterval(), symbolIds$])
.pipe(
switchMap(([interval, symbolIds]) => {
this.currentInterval = interval;
const addedSymbolIds = this.syncActiveSymbols(symbolIds);
if (!interval) {
this.warmupSymbolIds(addedSymbolIds);
return EMPTY;
}
if (symbolIds.length === 0) {
return EMPTY;
}
return fromPromise(this.loadIntervalRange(interval, symbolIds)).pipe(
filter((range): range is IRange<Time> => range !== null),
observeOn(animationFrameScheduler),
tap((range) => {
this.lwcChart.timeScale().setVisibleRange(range);
}),
catchError((error) => {
console.error('[Chart] Ошибка при применении интервала:', error);
return EMPTY;
}),
);
}),
)
.subscribe(),
);
}
private setupHistoricalDataLoading(): void {
// todo (не)вызвать loadMoreHistory после проверки на необходимость дозагрузки после смены таймфрейма
this.mouseEvents.subscribe('visibleLogicalRangeChange', (logicalRange: LogicalRange | null) => {
this.paneManager.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,
defaultVisiblePriceScaleId: Direction.Right,
layout: {
background: {
color: colors.chartBackground,
},
textColor: colors.chartTextPrimary,
panes: {
separatorColor: colors.chartPaneSeparator,
separatorHoverColor: colors.chartPaneSeparator,
enableResize: true,
},
},
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: 10,
shiftVisibleRangeOnNewBar: true,
allowShiftVisibleRangeOnWhitespaceReplacement: true,
},
rightPriceScale: {
minimumWidth: 60,
borderVisible: false,
},
leftPriceScale: {
minimumWidth: 60,
borderVisible: false,
},
localization,
};
}