Загрузка данных
import {
MismatchDirection,
PriceScaleMode,
} from 'lightweight-charts';
import type {
IPriceLine,
LogicalRange,
} from 'lightweight-charts';
import { Subscription } from 'rxjs';
import type { Observable } 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 type { PriceAxisLabel } from './types';
type EntityCollection = 'compare' | 'indicator';
type SourceRole =
| 'main'
| 'compare'
| 'indicator'
| 'volume';
type PriceAxisSide =
| Direction.Left
| Direction.Right;
interface PriceAxisLabelsControllerOptions {
mainSeries$: Observable<
SeriesStrategies | null
>;
mainSymbol$: Observable<string>;
compareEntities$: Observable<Indicator[]>;
indicatorEntities$: Observable<Indicator[]>;
}
interface PriceLabelSource {
id: string;
role: SourceRole;
series: SeriesStrategies;
collisionGroup: string;
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: 50,
volume: 30,
};
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 toOpaqueColor(color: string): string {
return /^#[0-9a-fA-F]{8}$/.test(color)
? removeAlphaFromHex(color)
: color;
}
function getSeriesColor(
series: SeriesStrategies,
data: unknown,
): string {
if (
data &&
typeof data === 'object' &&
'color' in data &&
typeof data.color === 'string'
) {
return toOpaqueColor(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'
) {
const color =
data.close >= data.open
? 'upColor' in options
? options.upColor
: undefined
: 'downColor' in options
? options.downColor
: undefined;
if (typeof color === 'string') {
return toOpaqueColor(color);
}
}
if (
'color' in options &&
typeof options.color === 'string'
) {
return toOpaqueColor(options.color);
}
if (
'lineColor' in options &&
typeof options.lineColor === 'string'
) {
return toOpaqueColor(
options.lineColor,
);
}
if (
'topLineColor' in options &&
typeof options.topLineColor ===
'string'
) {
return toOpaqueColor(
options.topLineColor,
);
}
if (
'bottomLineColor' in options &&
typeof options.bottomLineColor ===
'string'
) {
return toOpaqueColor(
options.bottomLineColor,
);
}
return toOpaqueColor(
getThemeStore().colors.chartLineColor,
);
}
function getContrastTextColor(
color: string,
): string {
const hex = toOpaqueColor(color)
.replace('#', '')
.slice(0, 6);
const value = Number.parseInt(hex, 16);
if (Number.isNaN(value)) {
return '#FFFFFF';
}
const red = (value >> 16) & 255;
const green = (value >> 8) & 255;
const blue = value & 255;
const brightness =
0.199 * red +
0.687 * green +
0.114 * blue;
return brightness > 160
? '#000000'
: '#FFFFFF';
}
function getShortSymbol(symbol: string): string {
return symbol.split(':').at(-1) || symbol;
}
function getAxisSide(
source: PriceLabelSource,
): PriceAxisSide {
if (source.role === 'volume') {
return Direction.Right;
}
return source.series.options()
.priceScaleId === Direction.Left
? Direction.Left
: Direction.Right;
}
function getEntityKey(
collection: EntityCollection,
entity: Indicator,
): string {
return `${collection}:${entity.getId()}`;
}
export class PriceAxisLabelsController {
private readonly subscriptions =
new Subscription();
private readonly entitySubscriptions =
new Map<string, EntitySubscription>();
private readonly sourceDefaults =
new WeakMap<
SeriesStrategies,
SourceDefaults
>();
private readonly 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$,
}: PriceAxisLabelsControllerOptions) {
this.subscriptions.add(
mainSymbol$.subscribe((symbol) => {
this.mainSymbol =
getShortSymbol(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 &&
typeof cancelAnimationFrame ===
'function'
) {
cancelAnimationFrame(
this.updateFrame,
);
}
this.unsubscribeMainSeries();
this.subscriptions.unsubscribe();
this.entitySubscriptions.forEach(
({ subscription }) => {
subscription.unsubscribe();
},
);
this.getSources().forEach((source) => {
this.restoreSourceOptions(
source.series,
);
});
this.layers.forEach(
({ host, primitive }) => {
try {
host.detachPrimitive(primitive);
} catch {
// Серия могла быть удалена раньше контроллера.
}
},
);
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 {
// Серия могла быть удалена раньше контроллера.
}
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) =>
getEntityKey(collection, entity),
),
);
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 = getEntityKey(
collection,
entity,
);
const current =
this.entitySubscriptions.get(key);
if (current?.entity === entity) {
return;
}
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,
collisionGroup: 'main',
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[] {
const isVolume =
collection === 'indicator' &&
entity.getType() ===
IndicatorsIds.Volume;
const role: SourceRole =
collection === 'compare'
? 'compare'
: isVolume
? 'volume'
: 'indicator';
const collisionGroup =
role === 'volume'
? 'volume'
: `${collection}:${entity.getId()}`;
return Array.from(
entity.getSeriesMap().entries(),
([seriesId, series]) => ({
id:
`${collection}:` +
`${entity.getId()}:` +
seriesId,
role,
series,
collisionGroup,
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 {
// Серия могла быть удалена раньше контроллера.
}
}
private applyDisplayMode(): void {
this.applyDisplayModeToSources(
this.getSources(),
);
if (!this.isHistoryMode) {
this.hideCurrentPriceLine();
}
}
private applyDisplayModeToSources(
sources: readonly PriceLabelSource[],
): void {
sources.forEach((source) => {
const defaults =
this.ensureSourceDefaults(
source.series,
);
try {
if (source.role === 'volume') {
source.series.applyOptions({
lastValueVisible: false,
priceLineVisible: false,
title: '',
});
return;
}
source.series.applyOptions({
lastValueVisible:
this.isHistoryMode
? false
: defaults.lastValueVisible,
title:
this.isHistoryMode
? ''
: source.role === 'main'
? this.mainSymbol
: defaults.title,
});
} catch {
// Серия могла быть удалена раньше контроллера.
}
});
}
private refreshHistoryMode(): void {
if (
!this.visibleLogicalRange ||
!this.mainSeries
) {
this.isHistoryMode = false;
this.applyDisplayMode();
return;
}
const barsInfo =
this.mainSeries.barsInLogicalRange(
this.visibleLogicalRange,
);
const nextHistoryMode =
(barsInfo?.barsAfter ?? 0) > 0;
if (
this.isHistoryMode ===
nextHistoryMode
) {
return;
}
this.isHistoryMode =
nextHistoryMode;
this.applyDisplayMode();
}
private scheduleUpdate(): void {
if (this.updateFrame !== null) {
return;
}
if (
typeof requestAnimationFrame !==
'function'
) {
this.update();
return;
}
this.updateFrame =
requestAnimationFrame(() => {
this.updateFrame = null;
this.update();
});
}
private update(): void {
const sources = this.getSources();
this.updateCurrentPriceLine(sources);
const groups =
this.collectAxisGroups(sources);
this.updateAxisLayers(
groups,
sources,
);
}
private collectAxisGroups(
sources: readonly 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);
} else {
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;
const coordinate =
showRealtimeLabel
? this.getCurrentMainPriceCoordinate()
: null;
if (coordinate === null) {
return groups;
}
const paneIndex =
mainSource.series
.getPane()
.paneIndex();
const side = getAxisSide(mainSource);
const group = groups.get(
`${paneIndex}:${side}`,
);
if (group) {
group.reservedCoordinate = coordinate;
}
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,
collisionGroup:
source.collisionGroup,
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.at(-1) ?? null;
}
private updateAxisLayers(
groups: Map<string, AxisLabelsGroup>,
sources: readonly 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 {
// Серия могла быть удалена раньше контроллера.
}
this.layers.delete(key);
});
}
private getAxisHost(
paneIndex: number,
side: PriceAxisSide,
sources: readonly PriceLabelSource[],
): SeriesStrategies | null {
if (
paneIndex === MAIN_PANE_INDEX &&
side === Direction.Right &&
this.mainSeries
) {
return this.mainSeries;
}
const source = sources.find(
(currentSource) =>
currentSource.role !== 'volume' &&
currentSource.series
.getPane()
.paneIndex() === paneIndex &&
getAxisSide(currentSource) === side,
);
if (source) {
return source.series;
}
return (
sources.find(
(currentSource) =>
currentSource.series
.getPane()
.paneIndex() === paneIndex &&
getAxisSide(currentSource) === 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 {
// Серия могла быть удалена раньше контроллера.
}
}
const layer = {
host,
primitive:
new PriceAxisLabelsPrimitive(),
};
host.attachPrimitive(layer.primitive);
this.layers.set(key, layer);
return layer;
}
private updateCurrentPriceLine(
sources: readonly PriceLabelSource[],
): void {
const mainSource = sources.find(
(source) => source.role === 'main',
);
if (
!this.isHistoryMode ||
!mainSource ||
!mainSource.series.isVisible()
) {
this.hideCurrentPriceLine();
return;
}
const lastData =
mainSource.series.data().at(-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 price = getDataPrice(
this.mainSeries.data().at(-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 {
// Серия могла быть удалена раньше контроллера.
}
}
this.currentPriceLine = null;
this.currentPriceLineHost = null;
}
private formatValue(
series: SeriesStrategies,
price: number,
): string {
const { mode } =
series.priceScale().options();
if (
mode !== PriceScaleMode.Percentage &&
mode !== PriceScaleMode.IndexedTo100
) {
return (
formatPrice(price) ??
series
.priceFormatter()
.format(price)
);
}
const logicalRange =
this.visibleLogicalRange;
if (!logicalRange) {
return (
formatPrice(price) ??
series
.priceFormatter()
.format(price)
);
}
const firstVisiblePrice = getDataPrice(
series.dataByIndex(
Math.ceil(logicalRange.from),
MismatchDirection.NearestRight,
),
);
if (
firstVisiblePrice === null ||
firstVisiblePrice === 0
) {
return (
formatPrice(price) ??
series
.priceFormatter()
.format(price)
);
}
if (
mode === PriceScaleMode.Percentage
) {
return formatPercent(
((price - firstVisiblePrice) /
firstVisiblePrice) *
100,
);
}
return (
formatPrice(
(price / firstVisiblePrice) * 100,
) ?? String(price)
);
}
}