Загрузка данных
import {
IPriceLine,
LogicalRange,
MismatchDirection,
PriceScaleMode,
} from 'lightweight-charts';
import { Observable, 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 } from '@src/utils';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';
import { PriceAxisLabelsPrimitive } from './primitive';
import {
PRICE_AXIS_LABEL_HEIGHT,
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;
}
interface AxisLabelsGroup {
paneIndex: number;
side: PriceAxisSide;
labels: PriceAxisLabel[];
}
interface AxisLayer {
host: SeriesStrategies;
primitive: PriceAxisLabelsPrimitive;
}
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() as unknown as Record<
string,
unknown
>;
if (
data &&
typeof data === 'object' &&
'open' in data &&
'close' in data &&
typeof data.open === 'number' &&
typeof data.close === 'number'
) {
const candleColor =
data.close >= data.open
? options.upColor
: options.downColor;
if (typeof candleColor === 'string') {
return toOpaqueColor(candleColor);
}
}
const color = [
options.color,
options.lineColor,
options.topLineColor,
options.bottomLineColor,
].find(
(value): value is string =>
typeof value === 'string',
);
return color
? toOpaqueColor(color)
: toOpaqueColor(
getThemeStore().colors.chartLineColor,
);
}
function getContrastTextColor(color: string): string {
const normalizedColor = toOpaqueColor(color)
.replace('#', '')
.slice(0, 6);
if (normalizedColor.length !== 6) {
return '#FFFFFF';
}
const red = Number.parseInt(
normalizedColor.slice(0, 2),
16,
);
const green = Number.parseInt(
normalizedColor.slice(2, 4),
16,
);
const blue = Number.parseInt(
normalizedColor.slice(4, 6),
16,
);
if (
Number.isNaN(red) ||
Number.isNaN(green) ||
Number.isNaN(blue)
) {
return '#FFFFFF';
}
const luminance =
(red * 0.299 +
green * 0.587 +
blue * 0.114) /
255;
return luminance > 0.6
? '#000000'
: '#FFFFFF';
}
function getShortSymbol(symbol: string): string {
const parts = symbol.split(':');
return parts[parts.length - 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 getAxisKey(
paneIndex: number,
side: PriceAxisSide,
): string {
return `${paneIndex}:${side}`;
}
function getEntityKey(
collection: EntityCollection,
entity: Indicator,
): string {
return `${collection}:${entity.getId()}`;
}
function areSeriesListsEqual(
currentSeries:
| readonly SeriesStrategies[]
| undefined,
nextSeries: readonly SeriesStrategies[],
): boolean {
return (
currentSeries?.length === nextSeries.length &&
currentSeries.every(
(series, index) =>
series === nextSeries[index],
)
);
}
export class PriceAxisLabelsController {
private readonly subscriptions =
new Subscription();
private readonly entitySubscriptions =
new Map<string, Subscription>();
private readonly entitySeries = new Map<
string,
readonly SeriesStrategies[]
>();
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.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.updateFrame = null;
}
this.unsubscribeMainSeries();
this.subscriptions.unsubscribe();
this.entitySubscriptions.forEach(
(subscription) => {
subscription.unsubscribe();
},
);
this.entitySubscriptions.clear();
this.entitySeries.clear();
this.getSources().forEach((source) => {
this.restoreSourceOptions(source.series);
});
this.layers.forEach(({ host, primitive }) => {
try {
host.detachPrimitive(primitive);
} catch {
// Серия могла быть удалена раньше контроллера.
}
});
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;
}
this.syncEntitySubscriptions(
collection,
entities,
);
this.applyDisplayMode();
this.scheduleUpdate();
}
private syncEntitySubscriptions(
collection: EntityCollection,
entities: Indicator[],
): void {
const activeKeys = new Set(
entities.map((entity) =>
getEntityKey(collection, entity),
),
);
this.entitySubscriptions.forEach(
(subscription, key) => {
if (
!key.startsWith(`${collection}:`) ||
activeKeys.has(key)
) {
return;
}
subscription.unsubscribe();
this.entitySubscriptions.delete(key);
this.entitySeries.delete(key);
},
);
entities.forEach((entity) => {
const key = getEntityKey(
collection,
entity,
);
const nextSeries = Array.from(
entity.getSeriesMap().values(),
);
const currentSeries =
this.entitySeries.get(key);
if (
!areSeriesListsEqual(
currentSeries,
nextSeries,
)
) {
this.entitySeries.set(key, nextSeries);
this.applyDisplayModeToSources(
this.getEntitySources(
collection,
entity,
),
);
}
if (this.entitySubscriptions.has(key)) {
return;
}
const subscription =
entity.subscribeDataChange(() => {
const updatedSeries = Array.from(
entity.getSeriesMap().values(),
);
const registeredSeries =
this.entitySeries.get(key);
if (
!areSeriesListsEqual(
registeredSeries,
updatedSeries,
)
) {
this.entitySeries.set(
key,
updatedSeries,
);
this.applyDisplayModeToSources(
this.getEntitySources(
collection,
entity,
),
);
}
this.scheduleUpdate();
});
this.entitySubscriptions.set(
key,
subscription,
);
});
}
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'
: role === 'compare'
? `compare:${entity.getId()}`
: `indicator:${entity.getId()}`;
return Array.from(
entity.getSeriesMap().entries(),
).map(([seriesId, series]) => ({
id: `${collection}:${entity.getId()}:${seriesId}`,
role,
series,
collisionGroup,
priority: SOURCE_PRIORITY[role],
}));
}
private ensureSourceDefaults(
series: SeriesStrategies,
): SourceDefaults {
const currentDefaults =
this.sourceDefaults.get(series);
if (currentDefaults) {
return currentDefaults;
}
const options = series.options();
const defaults = {
lastValueVisible:
options.lastValueVisible,
priceLineVisible:
options.priceLineVisible,
};
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,
});
return;
}
source.series.applyOptions({
lastValueVisible:
this.isHistoryMode
? false
: defaults.lastValueVisible,
});
} catch {
// Серия могла быть удалена раньше контроллера.
}
});
}
private getHistoryMode(
logicalRange: LogicalRange | null,
): boolean {
if (!logicalRange || !this.mainSeries) {
return false;
}
const barsInfo =
this.mainSeries.barsInLogicalRange(
logicalRange,
);
return (barsInfo?.barsAfter ?? 0) > 0;
}
private refreshHistoryMode(): void {
const nextHistoryMode = this.getHistoryMode(
this.visibleLogicalRange,
);
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);
this.updateAxisLayers(
this.collectAxisGroups(sources),
sources,
);
}
private collectAxisGroups(
sources: readonly PriceLabelSource[],
): Map<string, AxisLabelsGroup> {
const groups =
new Map<string, AxisLabelsGroup>();
sources.forEach((source) => {
if (!source.series.isVisible()) {
return;
}
if (
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 axisKey = getAxisKey(
paneIndex,
side,
);
const group = groups.get(axisKey);
if (group) {
group.labels.push(label);
return;
}
groups.set(axisKey, {
paneIndex,
side,
labels: [label],
});
});
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;
}
if (source.role === 'main') {
const currentCoordinate =
this.getCurrentMainPriceCoordinate();
if (
currentCoordinate !== null &&
Math.abs(
currentCoordinate - coordinate,
) <=
PRICE_AXIS_LABEL_HEIGHT + 2
) {
return null;
}
}
return {
id: source.id,
collisionGroup:
source.collisionGroup,
desiredCoordinate: coordinate,
text:
source.role === 'volume'
? String(
formatCompactNumber(price) ??
price,
)
: this.formatValue(
source.series,
price,
),
color: getSeriesColor(
source.series,
data,
),
style: this.isHistoryMode
? 'outlined'
: 'filled',
priority: source.priority,
height: PRICE_AXIS_LABEL_HEIGHT,
};
}
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.length > 0
? data[data.length - 1]
: null;
}
private updateAxisLayers(
groups: Map<string, AxisLabelsGroup>,
sources: readonly PriceLabelSource[],
): void {
const activeLayerKeys = new Set<string>();
groups.forEach((group, axisKey) => {
const host = this.getAxisHost(
group.paneIndex,
group.side,
sources,
);
if (!host) {
return;
}
activeLayerKeys.add(axisKey);
this.getOrCreateLayer(
axisKey,
host,
).primitive.setLabels(group.labels);
});
this.layers.forEach(
(layer, axisKey) => {
if (activeLayerKeys.has(axisKey)) {
return;
}
try {
layer.host.detachPrimitive(
layer.primitive,
);
} catch {
// Серия могла быть удалена раньше контроллера.
}
this.layers.delete(axisKey);
},
);
}
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 regularSource = sources.find(
(source) =>
source.role !== 'volume' &&
source.series
.getPane()
.paneIndex() === paneIndex &&
getAxisSide(source) === side,
);
if (regularSource) {
return regularSource.series;
}
const fallbackSource = sources.find(
(source) =>
source.series
.getPane()
.paneIndex() === paneIndex &&
getAxisSide(source) === side,
);
return fallbackSource?.series ?? null;
}
private getOrCreateLayer(
axisKey: string,
host: SeriesStrategies,
): AxisLayer {
const currentLayer =
this.layers.get(axisKey);
if (currentLayer?.host === host) {
return currentLayer;
}
if (currentLayer) {
try {
currentLayer.host.detachPrimitive(
currentLayer.primitive,
);
} catch {
// Серия могла быть удалена раньше контроллера.
}
}
const primitive =
new PriceAxisLabelsPrimitive();
host.attachPrimitive(primitive);
const nextLayer = {
host,
primitive,
};
this.layers.set(axisKey, nextLayer);
return nextLayer;
}
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 data = mainSource.series.data();
const lastData =
data.length > 0
? data[data.length - 1]
: null;
const price = getDataPrice(lastData);
if (price === null) {
this.hideCurrentPriceLine();
return;
}
const color = getSeriesColor(
mainSource.series,
lastData,
);
const priceLine = this.getCurrentPriceLine(
mainSource.series,
color,
);
priceLine.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 lastData =
data.length > 0
? data[data.length - 1]
: null;
const price = getDataPrice(lastData);
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().mode;
if (
mode !== PriceScaleMode.Percentage &&
mode !== PriceScaleMode.IndexedTo100
) {
return series.priceFormatter().format(price);
}
const logicalRange =
this.visibleLogicalRange;
if (!logicalRange) {
return series.priceFormatter().format(price);
}
const firstVisibleData =
series.dataByIndex(
Math.ceil(logicalRange.from),
MismatchDirection.NearestRight,
);
const firstVisiblePrice =
getDataPrice(firstVisibleData);
if (
firstVisiblePrice === null ||
firstVisiblePrice === 0
) {
return series.priceFormatter().format(price);
}
if (
mode === PriceScaleMode.Percentage
) {
const percentage =
((price - firstVisiblePrice) /
firstVisiblePrice) *
100;
return `${percentage.toFixed(2)}%`;
}
return (
(price / firstVisiblePrice) *
100
).toFixed(2);
}
}