Загрузка данных
import { MismatchDirection, PriceScaleMode } from 'lightweight-charts';
import { Subscription } from 'rxjs';
import { Indicator } from '@core/Indicator';
import { IndicatorsIds, MAIN_PANE_INDEX } from '@src/constants';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { getThemeStore } from '@src/theme';
import { Direction } from '@src/types';
import { formatCompactNumber, formatPercent, formatPrice } from '@src/utils';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';
import { PriceAxisLabelsPrimitive } from './primitive';
import { getAxisSideBySeries, getContrastTextColor } from './utils';
import type { PriceAxisLabel } from './types';
import type { IPriceLine, LogicalRange } from 'lightweight-charts';
import type { Observable } from 'rxjs';
type EntityCollection = 'compare' | 'indicator';
type SourceRole = 'main' | 'compare' | 'indicator' | 'volume';
type PriceAxisSide = Direction.Left | Direction.Right;
interface PriceAxisLabelsParams {
mainSeries$: Observable<SeriesStrategies | null>;
mainSymbol$: Observable<string>;
compareEntities$: Observable<Indicator[]>;
indicatorEntities$: Observable<Indicator[]>;
}
interface PriceLabelSource {
id: string;
role: SourceRole;
series: SeriesStrategies;
priority: number;
}
interface SourceDefaults {
lastValueVisible: boolean;
priceLineVisible: boolean;
title: string;
}
interface AxisLabelsGroup {
paneIndex: number;
side: PriceAxisSide;
labels: PriceAxisLabel[];
reservedCoordinate: number | null;
}
interface AxisLayer {
host: SeriesStrategies;
primitive: PriceAxisLabelsPrimitive;
}
interface EntitySubscription {
entity: Indicator;
subscription: Subscription;
}
const SOURCE_PRIORITY: Record<SourceRole, number> = {
main: 100,
compare: 80,
indicator: 60,
volume: 40,
};
function getDataPrice(data: unknown): number | null {
if (!data || typeof data !== 'object') {
return null;
}
if ('close' in data && typeof data.close === 'number') {
return data.close;
}
if ('value' in data && typeof data.value === 'number') {
return data.value;
}
return null;
}
function getSeriesColor(series: SeriesStrategies, data: unknown): string {
if (
data &&
typeof data === 'object' &&
'color' in data &&
typeof data.color === 'string'
) {
return removeAlphaFromHex(data.color);
}
const options = series.options();
if (
data &&
typeof data === 'object' &&
'open' in data &&
'close' in data &&
typeof data.open === 'number' &&
typeof data.close === 'number'
) {
if (
data.close >= data.open &&
'upColor' in options &&
typeof options.upColor === 'string'
) {
return removeAlphaFromHex(options.upColor);
}
if (
data.close < data.open &&
'downColor' in options &&
typeof options.downColor === 'string'
) {
return removeAlphaFromHex(options.downColor);
}
}
if ('color' in options && typeof options.color === 'string') {
return removeAlphaFromHex(options.color);
}
if ('lineColor' in options && typeof options.lineColor === 'string') {
return removeAlphaFromHex(options.lineColor);
}
if (
'topLineColor' in options &&
typeof options.topLineColor === 'string'
) {
return removeAlphaFromHex(options.topLineColor);
}
if (
'bottomLineColor' in options &&
typeof options.bottomLineColor === 'string'
) {
return removeAlphaFromHex(options.bottomLineColor);
}
return removeAlphaFromHex(getThemeStore().colors.chartLineColor);
}
function getAxisSide(source: PriceLabelSource): PriceAxisSide {
if (source.role === 'volume') {
return Direction.Right;
}
return getAxisSideBySeries(source.series);
}
export class PriceAxisLabels {
private subscriptions = new Subscription();
private entitySubscriptions = new Map<string, EntitySubscription>();
private sourceDefaults = new WeakMap<SeriesStrategies, SourceDefaults>();
private layers = new Map<string, AxisLayer>();
private mainSeries: SeriesStrategies | null = null;
private mainSeriesDataHandler: (() => void) | null = null;
private compareEntities: Indicator[] = [];
private indicatorEntities: Indicator[] = [];
private visibleLogicalRange: LogicalRange | null = null;
private currentPriceLine: IPriceLine | null = null;
private currentPriceLineHost: SeriesStrategies | null = null;
private mainSymbol = '';
private isHistoryMode = false;
private updateFrame: number | null = null;
constructor({
mainSeries$,
mainSymbol$,
compareEntities$,
indicatorEntities$,
}: PriceAxisLabelsParams) {
this.subscriptions.add(
mainSymbol$.subscribe((symbol) => {
const symbolParts = symbol.split(':');
this.mainSymbol =
symbolParts[symbolParts.length - 1] || symbol;
this.applyDisplayMode();
this.scheduleUpdate();
}),
);
this.subscriptions.add(
mainSeries$.subscribe((series) => {
this.setMainSeries(series);
}),
);
this.subscriptions.add(
compareEntities$.subscribe((entities) => {
this.setEntities('compare', entities);
}),
);
this.subscriptions.add(
indicatorEntities$.subscribe((entities) => {
this.setEntities('indicator', entities);
}),
);
}
public setVisibleLogicalRange(
logicalRange: LogicalRange | null,
): void {
this.visibleLogicalRange = logicalRange;
this.refreshHistoryMode();
this.scheduleUpdate();
}
public invalidate(): void {
this.scheduleUpdate();
}
public destroy(): void {
if (this.updateFrame !== null) {
cancelAnimationFrame(this.updateFrame);
this.updateFrame = null;
}
this.unsubscribeMainSeries();
this.subscriptions.unsubscribe();
this.entitySubscriptions.forEach(({ subscription }) => {
subscription.unsubscribe();
});
this.getSources().forEach(({ series }) => {
this.restoreSourceOptions(series);
});
this.layers.forEach(({ host, primitive }) => {
try {
host.detachPrimitive(primitive);
} catch {
// Серия могла быть удалена раньше объекта PriceAxisLabels.
}
});
this.entitySubscriptions.clear();
this.layers.clear();
this.removeCurrentPriceLine();
}
private setMainSeries(
series: SeriesStrategies | null,
): void {
if (this.mainSeries === series) {
return;
}
const previousSeries = this.mainSeries;
this.unsubscribeMainSeries();
if (previousSeries) {
this.restoreSourceOptions(previousSeries);
}
this.mainSeries = series;
if (series) {
this.ensureSourceDefaults(series);
this.mainSeriesDataHandler = () => {
this.refreshHistoryMode();
this.scheduleUpdate();
};
series.subscribeDataChanged(this.mainSeriesDataHandler);
}
if (
this.currentPriceLineHost &&
this.currentPriceLineHost !== series
) {
this.removeCurrentPriceLine();
}
this.refreshHistoryMode();
this.applyDisplayMode();
this.scheduleUpdate();
}
private unsubscribeMainSeries(): void {
if (!this.mainSeries || !this.mainSeriesDataHandler) {
this.mainSeriesDataHandler = null;
return;
}
try {
this.mainSeries.unsubscribeDataChanged(
this.mainSeriesDataHandler,
);
} catch {
// Серия могла быть удалена раньше объекта PriceAxisLabels.
}
this.mainSeriesDataHandler = null;
}
private setEntities(
collection: EntityCollection,
entities: Indicator[],
): void {
if (collection === 'compare') {
this.compareEntities = entities;
} else {
this.indicatorEntities = entities;
}
const activeKeys = new Set(
entities.map(
(entity) => `${collection}:${entity.getId()}`,
),
);
this.entitySubscriptions.forEach((entry, key) => {
if (
!key.startsWith(`${collection}:`) ||
activeKeys.has(key)
) {
return;
}
entry.entity.getSeriesMap().forEach((series) => {
this.restoreSourceOptions(series);
});
entry.subscription.unsubscribe();
this.entitySubscriptions.delete(key);
});
entities.forEach((entity) => {
const key = `${collection}:${entity.getId()}`;
const current = this.entitySubscriptions.get(key);
if (current?.entity === entity) {
return;
}
if (current) {
current.entity
.getSeriesMap()
.forEach((series) => {
this.restoreSourceOptions(series);
});
current.subscription.unsubscribe();
}
const subscription = entity.subscribeDataChange(() => {
this.applyDisplayModeToSources(
this.getEntitySources(collection, entity),
);
this.scheduleUpdate();
});
this.entitySubscriptions.set(key, {
entity,
subscription,
});
this.applyDisplayModeToSources(
this.getEntitySources(collection, entity),
);
});
this.applyDisplayMode();
this.scheduleUpdate();
}
private getSources(): PriceLabelSource[] {
const sources: PriceLabelSource[] = [];
if (this.mainSeries) {
sources.push({
id: 'main',
role: 'main',
series: this.mainSeries,
priority: SOURCE_PRIORITY.main,
});
}
this.compareEntities.forEach((entity) => {
sources.push(
...this.getEntitySources('compare', entity),
);
});
this.indicatorEntities.forEach((entity) => {
sources.push(
...this.getEntitySources('indicator', entity),
);
});
return sources;
}
private getEntitySources(
collection: EntityCollection,
entity: Indicator,
): PriceLabelSource[] {
let role: SourceRole = 'indicator';
if (collection === 'compare') {
role = 'compare';
} else if (
entity.getType() === IndicatorsIds.Volume
) {
role = 'volume';
}
return Array.from(
entity.getSeriesMap().entries(),
([seriesId, series]) => ({
id: `${collection}:${entity.getId()}:${seriesId}`,
role,
series,
priority: SOURCE_PRIORITY[role],
}),
);
}
private ensureSourceDefaults(
series: SeriesStrategies,
): SourceDefaults {
const savedDefaults =
this.sourceDefaults.get(series);
if (savedDefaults) {
return savedDefaults;
}
const options = series.options();
const defaults = {
lastValueVisible: options.lastValueVisible,
priceLineVisible: options.priceLineVisible,
title: options.title ?? '',
};
this.sourceDefaults.set(series, defaults);
return defaults;
}
private restoreSourceOptions(
series: SeriesStrategies,
): void {
const defaults = this.sourceDefaults.get(series);
if (!defaults) {
return;
}
try {
series.applyOptions(defaults);
} catch {
// Серия могла быть удалена раньше объекта PriceAxisLabels.
}
}
private applyDisplayMode(): void {
this.applyDisplayModeToSources(this.getSources());
if (!this.isHistoryMode) {
this.hideCurrentPriceLine();
}
}
private applyDisplayModeToSources(
sources: PriceLabelSource[],
): void {
sources.forEach((source) => {
const defaults = this.ensureSourceDefaults(
source.series,
);
try {
if (source.role === 'volume') {
source.series.applyOptions({
lastValueVisible: false,
priceLineVisible: false,
});
return;
}
if (source.role === 'main') {
source.series.applyOptions({
lastValueVisible: this.isHistoryMode
? false
: defaults.lastValueVisible,
title: this.isHistoryMode
? ''
: this.mainSymbol || defaults.title,
});
return;
}
source.series.applyOptions({
lastValueVisible: this.isHistoryMode
? false
: defaults.lastValueVisible,
});
} catch {
// Серия могла быть удалена раньше объекта PriceAxisLabels.
}
});
}
private refreshHistoryMode(): void {
const barsInfo =
this.visibleLogicalRange && this.mainSeries
? this.mainSeries.barsInLogicalRange(
this.visibleLogicalRange,
)
: null;
const nextHistoryMode =
(barsInfo?.barsAfter ?? 0) > 0;
if (nextHistoryMode === this.isHistoryMode) {
return;
}
this.isHistoryMode = nextHistoryMode;
this.applyDisplayMode();
}
private scheduleUpdate(): void {
if (this.updateFrame !== null) {
return;
}
this.updateFrame = requestAnimationFrame(() => {
this.updateFrame = null;
this.update();
});
}
private update(): void {
const sources = this.getSources();
this.updateCurrentPriceLine(sources);
this.updateAxisLayers(
this.collectAxisGroups(sources),
sources,
);
}
private collectAxisGroups(
sources: PriceLabelSource[],
): Map<string, AxisLabelsGroup> {
const groups = new Map<string, AxisLabelsGroup>();
sources.forEach((source) => {
if (
!source.series.isVisible() ||
(source.role !== 'volume' &&
!this.isHistoryMode)
) {
return;
}
const label = this.createLabel(source);
if (!label) {
return;
}
const paneIndex =
source.series.getPane().paneIndex();
const side = getAxisSide(source);
const key = `${paneIndex}:${side}`;
const group = groups.get(key);
if (group) {
group.labels.push(label);
return;
}
groups.set(key, {
paneIndex,
side,
labels: [label],
reservedCoordinate: null,
});
});
const mainSource = sources.find(
(source) => source.role === 'main',
);
if (!mainSource) {
return groups;
}
const showRealtimeLabel =
this.isHistoryMode ||
this.ensureSourceDefaults(mainSource.series)
.lastValueVisible;
if (!showRealtimeLabel) {
return groups;
}
const reservedCoordinate =
this.getCurrentMainPriceCoordinate();
if (reservedCoordinate === null) {
return groups;
}
const paneIndex =
mainSource.series.getPane().paneIndex();
const side = getAxisSide(mainSource);
const group = groups.get(
`${paneIndex}:${side}`,
);
if (group) {
group.reservedCoordinate = reservedCoordinate;
}
return groups;
}
private createLabel(
source: PriceLabelSource,
): PriceAxisLabel | null {
const data = this.getSourceData(source);
const price = getDataPrice(data);
if (price === null) {
return null;
}
const coordinate =
source.series.priceToCoordinate(price);
if (coordinate === null) {
return null;
}
return {
id: source.id,
desiredCoordinate: coordinate,
text:
source.role === 'volume'
? formatCompactNumber(price)
: this.formatValue(source.series, price),
color: getSeriesColor(source.series, data),
style: this.isHistoryMode
? 'outlined'
: 'filled',
priority: source.priority,
};
}
private getSourceData(
source: PriceLabelSource,
): unknown {
if (
this.isHistoryMode &&
this.visibleLogicalRange
) {
return source.series.dataByIndex(
Math.floor(this.visibleLogicalRange.to),
MismatchDirection.NearestLeft,
);
}
const data = source.series.data();
return data[data.length - 1] ?? null;
}
private updateAxisLayers(
groups: Map<string, AxisLabelsGroup>,
sources: PriceLabelSource[],
): void {
const activeKeys = new Set<string>();
groups.forEach((group, key) => {
const host = this.getAxisHost(
group.paneIndex,
group.side,
sources,
);
if (!host) {
return;
}
activeKeys.add(key);
this.getOrCreateLayer(key, host).primitive.setLabels(
group.labels,
group.reservedCoordinate,
);
});
this.layers.forEach((layer, key) => {
if (activeKeys.has(key)) {
return;
}
try {
layer.host.detachPrimitive(layer.primitive);
} catch {
// Серия могла быть удалена раньше объекта PriceAxisLabels.
}
this.layers.delete(key);
});
}
private getAxisHost(
paneIndex: number,
side: PriceAxisSide,
sources: PriceLabelSource[],
): SeriesStrategies | null {
if (
paneIndex === MAIN_PANE_INDEX &&
side === Direction.Right &&
this.mainSeries
) {
return this.mainSeries;
}
const regularSource = sources.find(
(source) =>
source.role !== 'volume' &&
source.series.getPane().paneIndex() ===
paneIndex &&
getAxisSide(source) === side,
);
if (regularSource) {
return regularSource.series;
}
return (
sources.find(
(source) =>
source.series.getPane().paneIndex() ===
paneIndex &&
getAxisSide(source) === side,
)?.series ?? null
);
}
private getOrCreateLayer(
key: string,
host: SeriesStrategies,
): AxisLayer {
const currentLayer = this.layers.get(key);
if (currentLayer?.host === host) {
return currentLayer;
}
if (currentLayer) {
try {
currentLayer.host.detachPrimitive(
currentLayer.primitive,
);
} catch {
// Серия могла быть удалена раньше объекта PriceAxisLabels.
}
}
const layer = {
host,
primitive: new PriceAxisLabelsPrimitive(),
};
host.attachPrimitive(layer.primitive);
this.layers.set(key, layer);
return layer;
}
private updateCurrentPriceLine(
sources: PriceLabelSource[],
): void {
const mainSource = sources.find(
(source) => source.role === 'main',
);
if (
!this.isHistoryMode ||
!mainSource ||
!mainSource.series.isVisible()
) {
this.hideCurrentPriceLine();
return;
}
const data = mainSource.series.data();
const lastData = data[data.length - 1];
const price = getDataPrice(lastData);
if (price === null) {
this.hideCurrentPriceLine();
return;
}
const color = getSeriesColor(
mainSource.series,
lastData,
);
this.getCurrentPriceLine(
mainSource.series,
color,
).applyOptions({
price,
color,
lineVisible: false,
axisLabelVisible: true,
axisLabelColor: color,
axisLabelTextColor:
getContrastTextColor(color),
title: this.mainSymbol,
});
}
private getCurrentMainPriceCoordinate(): number | null {
if (!this.mainSeries) {
return null;
}
const data = this.mainSeries.data();
const price = getDataPrice(
data[data.length - 1],
);
return price === null
? null
: this.mainSeries.priceToCoordinate(price);
}
private getCurrentPriceLine(
series: SeriesStrategies,
color: string,
): IPriceLine {
if (
this.currentPriceLine &&
this.currentPriceLineHost === series
) {
return this.currentPriceLine;
}
this.removeCurrentPriceLine();
this.currentPriceLine =
series.createPriceLine({
price: 0,
color,
lineVisible: false,
axisLabelVisible: false,
title: '',
});
this.currentPriceLineHost = series;
return this.currentPriceLine;
}
private hideCurrentPriceLine(): void {
this.currentPriceLine?.applyOptions({
lineVisible: false,
axisLabelVisible: false,
title: '',
});
}
private removeCurrentPriceLine(): void {
if (
this.currentPriceLine &&
this.currentPriceLineHost
) {
try {
this.currentPriceLineHost.removePriceLine(
this.currentPriceLine,
);
} catch {
// Серия могла быть удалена раньше объекта PriceAxisLabels.
}
}
this.currentPriceLine = null;
this.currentPriceLineHost = null;
}
private formatValue(
series: SeriesStrategies,
price: number,
): string {
const { mode } = series.priceScale().options();
const formattedPrice =
formatPrice(price) ??
series.priceFormatter().format(price);
if (
mode !== PriceScaleMode.Percentage &&
mode !== PriceScaleMode.IndexedTo100
) {
return formattedPrice;
}
if (!this.visibleLogicalRange) {
return formattedPrice;
}
const firstVisiblePrice = getDataPrice(
series.dataByIndex(
Math.ceil(this.visibleLogicalRange.from),
MismatchDirection.NearestRight,
),
);
if (
firstVisiblePrice === null ||
firstVisiblePrice === 0
) {
return formattedPrice;
}
if (mode === PriceScaleMode.Percentage) {
return formatPercent(
((price - firstVisiblePrice) /
firstVisiblePrice) *
100,
);
}
const indexedValue =
(price / firstVisiblePrice) * 100;
return (
formatPrice(indexedValue) ??
String(indexedValue)
);
}
}
export { PriceAxisLabels } from './PriceAxisLabels';
export { layoutPriceAxisLabels } from './layout';
export { PriceAxisLabelsPrimitive } from './primitive';
export type {
LaidOutPriceAxisLabel,
MeasuredPriceAxisLabel,
PriceAxisLabel,
PriceAxisLabelsLayoutOptions,
PriceAxisLabelStyle,
} from './types';
import type { LogicalRange } from 'lightweight-charts';
import type { Observable } from 'rxjs';
import { DataSource } from '@core/DataSource';
import {
DrawingsManager,
DrawingsManagerSnapshot,
} from '@core/DrawingsManager';
import type { Indicator } from '@core/Indicator';
import { Pane, PaneParams } from '@core/Pane';
import { PriceAxisLabels } from '@core/PriceAxisLabels';
import {
ISerializable,
PaneSnapshot,
PriceScaleSnapshot,
} from '@src/types/snapshot';
interface PaneManagerParams
extends Omit<
PaneParams,
| 'id'
| 'isMainPane'
| 'basedOn'
| 'onDelete'
| 'initialPriceScales'
| 'onPriceScaleStateChange'
> {
panesSnapshot: PaneSnapshot[];
}
interface PriceAxisLabelsSources {
compareEntities$: Observable<Indicator[]>;
indicatorEntities$: Observable<Indicator[]>;
}
type SharedPaneParams = Omit<
PaneManagerParams,
'panesSnapshot'
>;
// todo: PaneManager, регулирует порядок пейнов. Знает про MainPane.
// todo: Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
// todo: в CompareManage, при создании нового пейна для сравнения - инициализируем новый dataSource, принадлежащий только конкретному пейну. Убираем возможность добавлять индикаторы на такие пейны
// todo: на каждый символ свой DataSource (учитывать что есть MainPane и "главный" DataSource, который инициализиурется во время старта moexChart)
// todo: сделать два разных представления для compare, в зависимости от отображения на главном пейне или на второстепенном
export class PaneManager
implements ISerializable<PaneSnapshot[]>
{
private readonly sharedPaneParams: SharedPaneParams;
private readonly panesMap = new Map<number, Pane>();
private mainPane: Pane;
private nextPaneId: number;
private priceAxisLabels: PriceAxisLabels | null =
null;
constructor({
panesSnapshot,
...sharedPaneParams
}: PaneManagerParams) {
this.sharedPaneParams = sharedPaneParams;
const mainPaneSnapshot = panesSnapshot.find(
(paneSnapshot) => paneSnapshot.isMain,
);
const mainPaneId = mainPaneSnapshot?.id ?? 0;
this.mainPane = new Pane({
...this.sharedPaneParams,
id: mainPaneId,
isMainPane: true,
onDelete: () => {},
initialPriceScales:
mainPaneSnapshot?.priceScales,
onPriceScaleStateChange:
this.handlePriceScaleStateChange,
});
this.panesMap.set(mainPaneId, this.mainPane);
if (mainPaneSnapshot) {
this.mainPane.setDrawingsSnapshot(
mainPaneSnapshot.drawings,
);
}
const greatestPaneId = panesSnapshot.reduce(
(greatestId, paneSnapshot) =>
Math.max(greatestId, paneSnapshot.id),
mainPaneId,
);
this.nextPaneId = greatestPaneId + 1;
panesSnapshot.forEach((paneSnapshot) => {
if (paneSnapshot.isMain) {
return;
}
const pane = this.addPane(
undefined,
paneSnapshot.id,
paneSnapshot.priceScales,
);
pane.setDrawingsSnapshot(
paneSnapshot.drawings,
);
});
this.syncPaneContainers();
}
public initializePriceAxisLabels({
compareEntities$,
indicatorEntities$,
}: PriceAxisLabelsSources): void {
this.priceAxisLabels?.destroy();
this.priceAxisLabels = new PriceAxisLabels({
mainSeries$:
this.mainPane
.getMainSerie()
.asObservable(),
mainSymbol$:
this.sharedPaneParams.eventManager.symbol(),
compareEntities$,
indicatorEntities$,
});
}
public setVisibleLogicalRange(
logicalRange: LogicalRange | null,
): void {
this.priceAxisLabels?.setVisibleLogicalRange(
logicalRange,
);
}
public invalidatePriceAxisLabels(): void {
this.priceAxisLabels?.invalidate();
}
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(): Map<number, Pane> {
return this.panesMap;
}
public getMainPane = (): Pane => {
return this.mainPane;
};
public addPane(
dataSource?: DataSource,
paneId?: number,
initialPriceScales?: PriceScaleSnapshot[],
): Pane {
const id = paneId ?? this.nextPaneId++;
this.nextPaneId = Math.max(
this.nextPaneId,
id + 1,
);
const pane = new Pane({
...this.sharedPaneParams,
id,
isMainPane: false,
dataSource: dataSource ?? null,
basedOn: dataSource
? undefined
: this.mainPane,
onDelete: () => this.destroyPane(id),
initialPriceScales,
onPriceScaleStateChange:
this.handlePriceScaleStateChange,
});
this.panesMap.set(id, pane);
this.syncPaneContainers();
this.priceAxisLabels?.invalidate();
return pane;
}
public refreshPriceScaleControls(): void {
this.panesMap.forEach((pane) => {
pane.refreshPriceScaleControls();
});
}
public resetPriceScalesAutoScale(): void {
this.panesMap.forEach((pane) => {
pane.resetPriceScalesAutoScale();
});
}
public getDrawingsManager(): DrawingsManager {
// todo: temp
return this.mainPane.getDrawingManager();
}
public getSnapshot(): PaneSnapshot[] {
const snapshot: PaneSnapshot[] = [];
this.panesMap.forEach((pane) => {
snapshot.push(pane.getSnapshot());
});
return snapshot;
}
public destroy(): void {
this.priceAxisLabels?.destroy();
this.priceAxisLabels = null;
const panes = Array.from(
this.panesMap.values(),
).sort(
(leftPane, rightPane) =>
rightPane.paneIndex() -
leftPane.paneIndex(),
);
this.panesMap.clear();
panes.forEach((pane) => {
pane.destroy();
});
}
private destroyPane(id: number): void {
const pane = this.panesMap.get(id);
if (!pane) {
return;
}
this.panesMap.delete(id);
pane.destroy();
this.syncPaneContainers();
this.priceAxisLabels?.invalidate();
}
private syncPaneContainers(): void {
this.panesMap.forEach((pane) => {
pane.schedulePaneContainerSync();
});
}
private handlePriceScaleStateChange =
(): void => {
this.priceAxisLabels?.invalidate();
};
}
import dayjs from 'dayjs';
import {
BarPrice,
ChartOptions,
createChart,
CrosshairMode,
DeepPartial,
IChartApi,
IRange,
LocalizationOptionsBase,
LogicalRange,
Time,
UTCTimestamp,
} from 'lightweight-charts';
import flatten from 'lodash-es/flatten';
import {
BehaviorSubject,
combineLatest,
Observable,
Subscription,
} from 'rxjs';
import {
map,
withLatestFrom,
} from 'rxjs/operators';
import { ChartMouseEvents } from '@core/ChartMouseEvents';
import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { DrawingsManager } from '@core/DrawingsManager';
import { EventManager } from '@core/EventManager';
import { IndicatorManager } from '@core/IndicatorManager';
import { ModalRenderer } from '@core/ModalRenderer';
import { PaneManager } from '@core/PaneManager';
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,
ISerializable,
PaneSnapshot,
} from '@src/types/snapshot';
import { 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 = 50;
interface ChartParams {
params: {
dataSource: DataSource;
eventManager: EventManager;
modalRenderer: ModalRenderer;
ohlcConfig: OHLCConfig;
tooltipConfig: TooltipConfig;
panes: PaneSnapshot[];
};
lwcChartConfig: ChartConfig;
}
/**
* Абстракция над библиотекой для построения графиков
*/
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: Omit<
ChartConfig,
'theme' | 'mode'
>;
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 activeSymbols: 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,
}) => {
const configToApply = {
...lwcChartConfig,
dateFormat,
timeFormat,
showTime,
};
this.lwcChart.applyOptions({
...getOptions(configToApply),
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,
});
this.mainSeries =
this.paneManager
.getMainPane()
.getMainSerie();
this.indicatorManager =
new IndicatorManager({
eventManager,
initialIndicators: flatten(
panesSnapshot.map((pane) =>
pane.indicators.map(
(indicator) => ({
...indicator,
paneId: pane.id,
}),
),
),
),
DOM: this.DOM,
dataSource: this.dataSource,
lwcChart: this.lwcChart,
paneManager: this.paneManager,
chartOptions:
lwcChartConfig.chartOptions,
});
this.compareManager =
new CompareManager({
chart: this.lwcChart,
initialIndicators: flatten(
panesSnapshot.map((pane) =>
pane.indicators.map(
(indicator) => ({
...indicator,
paneId: pane.id,
}),
),
),
),
eventManager: this.eventManager,
dataSource: this.dataSource,
indicatorManager:
this.indicatorManager,
paneManager: this.paneManager,
});
this.paneManager.initializePriceAxisLabels({
compareEntities$:
this.compareManager.entities(),
indicatorEntities$:
this.indicatorManager.entities(),
});
this.paneManager.setVisibleLogicalRange(
this.lwcChart
.timeScale()
.getVisibleLogicalRange(),
);
this.paneManager.refreshPriceScaleControls();
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 getDrawingsManager =
(): DrawingsManager => {
return this.paneManager.getDrawingsManager();
};
public getIndicatorManager =
(): IndicatorManager => {
return this.indicatorManager;
};
private onWheel = () => {
this.eventManager.resetInterval({
history: false,
});
};
private onPointerDown = () => {
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.lwcChart.applyOptions(
getOptions({
...this.chartConfig,
theme,
mode,
}),
);
this.paneManager.invalidatePriceAxisLabels();
}
public destroy(): void {
this.mouseEvents.destroy();
this.subscriptions.unsubscribe();
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.activeSymbols,
update: (
symbol: string,
candle: Candle,
) => {
this.dataSource.updateRealtime(
symbol,
candle,
);
},
};
}
public getSnapshot(): ChartSnapshot {
return {
panes: this.paneManager.getSnapshot(),
chartSeriesType:
this.eventManager
.exportChartSettings()
.seriesSelected,
timeframe:
this.eventManager
.exportChartSettings()
.timeframe,
symbol: this.activeSymbols[0],
};
}
private scheduleHistoryBatch = () => {
if (this.historyBatchRunning) return;
this.historyBatchRunning = true;
requestAnimationFrame(() => {
const symbols =
this.activeSymbols.slice();
Promise.all(
symbols.map((symbol) =>
this.dataSource.loadMoreHistory(
symbol,
),
),
).finally(() => {
this.historyBatchRunning = false;
const range =
this.lwcChart
.timeScale()
.getVisibleLogicalRange();
if (
range &&
range.from <
HISTORY_LOAD_THRESHOLD
) {
this.scheduleHistoryBatch();
}
});
});
};
private setupDataSourceSubs() {
const getWarmupFrom = (): number => {
if (
this.currentInterval &&
this.currentInterval !== Intervals.All
) {
return getIntervalRange(
this.currentInterval,
).from;
}
const range =
this.lwcChart
.timeScale()
.getVisibleRange();
if (!range) return 0;
const { from } =
range as IRange<number>;
return from as number;
};
const warmupSymbols = (
symbols: string[],
) => {
if (!symbols.length) return;
const from = getWarmupFrom();
if (!from) return;
Promise.all(
symbols.map((symbol) =>
this.dataSource.loadTill(
symbol,
from,
),
),
).catch((error) => {
console.error(
'[Chart] Ошибка при прогреве символов:',
error,
);
});
};
const symbols$ = combineLatest([
this.eventManager.symbol(),
this.compareManager.itemsObs(),
]).pipe(
map(([main, items]) =>
Array.from(
new Set([
main,
...items.map(
(item) => item.symbol,
),
]),
),
),
);
this.subscriptions.add(
this.eventManager
.getInterval()
.pipe(withLatestFrom(symbols$))
.subscribe(
([interval, symbols]) => {
this.currentInterval = interval;
if (!interval) return;
if (interval === Intervals.All) {
Promise.all(
symbols.map((symbol) =>
this.dataSource.loadAllHistory(
symbol,
),
),
)
.then(() => {
requestAnimationFrame(() =>
this.lwcChart
.timeScale()
.fitContent(),
);
})
.catch((error) =>
console.error(
'[Chart] Ошибка при загрузке всей истории:',
error,
),
);
return;
}
const { from, to } =
getIntervalRange(interval);
Promise.all(
symbols.map((symbol) =>
this.dataSource.loadTill(
symbol,
from,
),
),
)
.then(() => {
this.lwcChart
.timeScale()
.setVisibleRange({
from: from as Time,
to: to as Time,
});
})
.catch((error) => {
console.error(
'[Chart] Ошибка при применении интервала:',
error,
);
});
},
),
);
this.subscriptions.add(
symbols$.subscribe((symbols) => {
const previousSymbols =
this.activeSymbols;
this.activeSymbols = symbols;
this.dataSource.setSymbols(symbols);
const previousSymbolsSet =
new Set(previousSymbols);
const addedSymbols: string[] = [];
for (
let index = 0;
index < symbols.length;
index += 1
) {
const symbol = symbols[index];
if (!symbol) continue;
if (
previousSymbolsSet.has(symbol)
) {
continue;
}
addedSymbols.push(symbol);
}
if (addedSymbols.length) {
warmupSymbols(addedSymbols);
}
}),
);
}
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,
layout: {
background: {
color: colors.chartBackground,
},
textColor: colors.chartTextPrimary,
},
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: 25,
},
rightPriceScale: {
textColor:
colors.chartTextPrimary,
borderVisible: false,
},
localization,
};
}