Загрузка данных
import {
IPrimitivePaneRenderer,
IPrimitivePaneView,
ISeriesPrimitive,
PrimitivePaneViewZOrder,
SeriesAttachedParameter,
Time,
} from 'lightweight-charts';
import { getThemeStore } from '@src/theme';
export type PriceAxisLabelStyle = 'filled' | 'outlined';
export interface PriceAxisLabel {
id: string;
coordinate: number | null;
value: string;
color: string;
style: PriceAxisLabelStyle;
symbol?: string;
priority?: number;
}
interface ResolvedPriceAxisLabel extends PriceAxisLabel {
coordinate: number;
}
const LABEL_HEIGHT = 20;
const LABEL_GAP = 2;
const HORIZONTAL_PADDING = 6;
const SYMBOL_MIN_WIDTH_RATIO = 0.3;
const SYMBOL_MAX_WIDTH_RATIO = 0.48;
const FONT_SIZE = 11;
const FONT_WEIGHT = 500;
function getContrastTextColor(color: string): string {
const normalizedColor = 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 fitText(context: CanvasRenderingContext2D, text: string, maxWidth: number): string {
if (maxWidth <= 0) {
return '';
}
if (context.measureText(text).width <= maxWidth) {
return text;
}
const ellipsis = '…';
let result = text;
while (result.length > 0 && context.measureText(`${result}${ellipsis}`).width > maxWidth) {
result = result.slice(0, -1);
}
return result.length > 0 ? `${result}${ellipsis}` : '';
}
function resolveLabelPositions(labels: PriceAxisLabel[], height: number): ResolvedPriceAxisLabel[] {
const halfLabelHeight = LABEL_HEIGHT / 2;
const minCoordinate = halfLabelHeight;
const maxCoordinate = Math.max(halfLabelHeight, height - halfLabelHeight);
const resolvedLabels = labels
.filter(
(label): label is PriceAxisLabel & { coordinate: number } =>
label.coordinate !== null && Number.isFinite(label.coordinate),
)
.sort((left, right) => {
if (left.coordinate === right.coordinate) {
return (right.priority ?? 0) - (left.priority ?? 0);
}
return left.coordinate - right.coordinate;
})
.map((label) => ({
...label,
coordinate: Math.min(Math.max(label.coordinate, minCoordinate), maxCoordinate),
}));
for (let index = 1; index < resolvedLabels.length; index += 1) {
const previousLabel = resolvedLabels[index - 1];
const currentLabel = resolvedLabels[index];
const minCurrentCoordinate = previousLabel.coordinate + LABEL_HEIGHT + LABEL_GAP;
if (currentLabel.coordinate < minCurrentCoordinate) {
currentLabel.coordinate = minCurrentCoordinate;
}
}
const lastLabel = resolvedLabels[resolvedLabels.length - 1];
if (lastLabel && lastLabel.coordinate > maxCoordinate) {
const offset = lastLabel.coordinate - maxCoordinate;
resolvedLabels.forEach((label) => {
label.coordinate -= offset;
});
}
for (let index = resolvedLabels.length - 2; index >= 0; index -= 1) {
const currentLabel = resolvedLabels[index];
const nextLabel = resolvedLabels[index + 1];
const maxCurrentCoordinate = nextLabel.coordinate - LABEL_HEIGHT - LABEL_GAP;
if (currentLabel.coordinate > maxCurrentCoordinate) {
currentLabel.coordinate = maxCurrentCoordinate;
}
}
const firstLabel = resolvedLabels[0];
if (firstLabel && firstLabel.coordinate < minCoordinate) {
const offset = minCoordinate - firstLabel.coordinate;
resolvedLabels.forEach((label) => {
label.coordinate += offset;
});
}
return resolvedLabels;
}
class PriceAxisLabelsRenderer implements IPrimitivePaneRenderer {
constructor(private readonly getLabels: () => readonly PriceAxisLabel[]) {}
public draw(target: Parameters<IPrimitivePaneRenderer['draw']>[0]): void {
const labels = this.getLabels();
if (labels.length === 0) {
return;
}
target.useBitmapCoordinateSpace(
({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }) => {
const { width, height } = bitmapSize;
if (width <= 0 || height <= 0) {
return;
}
const resolvedLabels = resolveLabelPositions(
[...labels],
height / verticalPixelRatio,
);
if (resolvedLabels.length === 0) {
return;
}
const { colors } = getThemeStore();
context.save();
context.font = `${FONT_WEIGHT} ${Math.round(FONT_SIZE * verticalPixelRatio)}px Inter, sans-serif`;
context.textBaseline = 'middle';
resolvedLabels.forEach((label) => {
const coordinate = Math.round(label.coordinate * verticalPixelRatio);
const labelHeight = Math.round(LABEL_HEIGHT * verticalPixelRatio);
const top = Math.round(coordinate - labelHeight / 2);
const horizontalPadding = Math.round(HORIZONTAL_PADDING * horizontalPixelRatio);
const backgroundColor = label.style === 'outlined' ? colors.chartBackground : label.color;
const textColor = label.style === 'outlined' ? label.color : getContrastTextColor(label.color);
context.fillStyle = backgroundColor;
context.fillRect(0, top, width, labelHeight);
if (label.style === 'outlined') {
const borderWidth = Math.max(1, Math.round(horizontalPixelRatio));
context.strokeStyle = label.color;
context.lineWidth = borderWidth;
context.strokeRect(
borderWidth / 2,
top + borderWidth / 2,
Math.max(0, width - borderWidth),
Math.max(0, labelHeight - borderWidth),
);
}
context.fillStyle = textColor;
if (!label.symbol) {
const maxValueWidth = width - horizontalPadding * 2;
const value = fitText(context, label.value, maxValueWidth);
context.textAlign = 'center';
context.fillText(value, width / 2, coordinate);
return;
}
const availableWidth = width - horizontalPadding * 2;
const measuredSymbolWidth = context.measureText(label.symbol).width + horizontalPadding * 2;
const symbolWidth = Math.min(
Math.max(measuredSymbolWidth, availableWidth * SYMBOL_MIN_WIDTH_RATIO),
availableWidth * SYMBOL_MAX_WIDTH_RATIO,
);
const separatorX = Math.round(horizontalPadding + symbolWidth);
const separatorWidth = Math.max(1, Math.round(horizontalPixelRatio));
const symbolMaxWidth = symbolWidth - horizontalPadding * 2;
const valueAreaWidth = width - separatorX;
const valueMaxWidth = valueAreaWidth - horizontalPadding * 2;
const symbol = fitText(context, label.symbol, symbolMaxWidth);
const value = fitText(context, label.value, valueMaxWidth);
context.textAlign = 'center';
context.fillText(
symbol,
horizontalPadding + symbolWidth / 2,
coordinate,
);
context.globalAlpha = 0.5;
context.fillRect(
separatorX,
top + Math.round(3 * verticalPixelRatio),
separatorWidth,
labelHeight - Math.round(6 * verticalPixelRatio),
);
context.globalAlpha = 1;
context.fillText(
value,
separatorX + valueAreaWidth / 2,
coordinate,
);
});
context.restore();
},
);
}
}
class PriceAxisLabelsPaneView implements IPrimitivePaneView {
private readonly rendererInstance: PriceAxisLabelsRenderer;
constructor(private readonly getLabels: () => readonly PriceAxisLabel[]) {
this.rendererInstance = new PriceAxisLabelsRenderer(getLabels);
}
public renderer(): IPrimitivePaneRenderer | null {
return this.getLabels().length > 0 ? this.rendererInstance : null;
}
public zOrder(): PrimitivePaneViewZOrder {
return 'top';
}
}
export class PriceAxisLabelsPrimitive implements ISeriesPrimitive<Time> {
private readonly paneView: PriceAxisLabelsPaneView;
private readonly paneViews: readonly IPrimitivePaneView[];
private labels: readonly PriceAxisLabel[] = [];
private requestUpdate: (() => void) | null = null;
constructor() {
this.paneView = new PriceAxisLabelsPaneView(() => this.labels);
this.paneViews = [this.paneView];
}
public attached({ requestUpdate }: SeriesAttachedParameter<Time>): void {
this.requestUpdate = requestUpdate;
this.requestUpdate();
}
public detached(): void {
this.requestUpdate = null;
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return this.paneViews;
}
public updateAllViews(): void {}
public setLabels(labels: readonly PriceAxisLabel[]): void {
this.labels = labels;
this.requestUpdate?.();
}
public clear(): void {
if (this.labels.length === 0) {
return;
}
this.labels = [];
this.requestUpdate?.();
}
}
import {
IPriceLine,
LogicalRange,
MismatchDirection,
PriceScaleMode,
} from 'lightweight-charts';
import { Observable, Subscription } from 'rxjs';
import { Indicator } from '@core/Indicator';
import {
PriceAxisLabel,
PriceAxisLabelsPrimitive,
} from '@core/PriceLabels/PriceAxisLabelsPrimitive';
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 { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';
type PriceLabelRole = 'main' | 'compare' | 'volume';
type PriceScaleSide = Direction.Left | Direction.Right;
interface PriceLabelsManagerParams {
mainSeries$: Observable<SeriesStrategies | null>;
mainSymbol$: Observable<string>;
compareEntities$: Observable<Indicator[]>;
indicatorEntities$: Observable<Indicator[]>;
}
interface SeriesRegistrationInput {
id: string;
role: PriceLabelRole;
series: SeriesStrategies;
symbol?: string;
}
interface RegisteredSeries extends SeriesRegistrationInput {
defaultLastValueVisible: boolean;
defaultPriceLineVisible: boolean;
handleDataChanged: () => void;
}
interface AxisLabelsGroup {
paneIndex: number;
side: PriceScaleSide;
labels: PriceAxisLabel[];
}
interface AxisLayer {
host: SeriesStrategies;
primitive: PriceAxisLabelsPrimitive;
}
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() 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 removeAlphaFromHex(candleColor);
}
}
if (typeof options.color === 'string') {
return removeAlphaFromHex(options.color);
}
return removeAlphaFromHex(getThemeStore().colors.chartLineColor);
}
function getContrastTextColor(color: string): string {
const normalizedColor = removeAlphaFromHex(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 symbolParts = symbol.split(':');
return symbolParts[symbolParts.length - 1] || symbol;
}
function getScaleSide(registration: RegisteredSeries): PriceScaleSide {
if (registration.role === 'volume') {
return Direction.Right;
}
return registration.series.options().priceScaleId === Direction.Left
? Direction.Left
: Direction.Right;
}
function getLayerKey(paneIndex: number, side: PriceScaleSide): string {
return `${paneIndex}:${side}`;
}
export class PriceLabelsManager {
private readonly registrations = new Map<string, RegisteredSeries>();
private readonly layers = new Map<string, AxisLayer>();
private readonly subscriptions = new Subscription();
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$,
}: PriceLabelsManagerParams) {
this.subscriptions.add(
mainSymbol$.subscribe((symbol) => {
this.mainSymbol = getShortSymbol(symbol);
this.scheduleUpdate();
}),
);
this.subscriptions.add(
mainSeries$.subscribe((series) => {
this.syncMainSeries(series);
}),
);
this.subscriptions.add(
compareEntities$.subscribe((entities) => {
this.syncCompareSeries(entities);
}),
);
this.subscriptions.add(
indicatorEntities$.subscribe((entities) => {
this.syncVolumeSeries(entities);
}),
);
}
public setVisibleLogicalRange(logicalRange: LogicalRange | null): void {
this.visibleLogicalRange = logicalRange;
const nextHistoryMode = this.getHistoryMode(logicalRange);
if (this.isHistoryMode !== nextHistoryMode) {
this.isHistoryMode = nextHistoryMode;
this.applyHistoryMode();
}
this.scheduleUpdate();
}
public invalidate(): void {
this.scheduleUpdate();
}
public destroy(): void {
if (this.updateFrame !== null && typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(this.updateFrame);
this.updateFrame = null;
}
this.subscriptions.unsubscribe();
Array.from(this.registrations.keys()).forEach((registrationId) => {
this.unregisterSeries(registrationId);
});
this.layers.forEach(({ host, primitive }) => {
try {
host.detachPrimitive(primitive);
} catch {
// Серия могла быть удалена раньше менеджера.
}
});
this.layers.clear();
this.removeCurrentPriceLine();
}
private syncMainSeries(series: SeriesStrategies | null): void {
const registrations: SeriesRegistrationInput[] = series
? [
{
id: 'main',
role: 'main',
series,
},
]
: [];
this.syncRegistrations('main', registrations);
this.recalculateHistoryMode();
}
private syncCompareSeries(entities: Indicator[]): void {
const registrations = entities.flatMap((entity) =>
Array.from(entity.getSeriesMap().entries()).map(([seriesId, series]) => ({
id: `compare:${entity.getId()}:${seriesId}`,
role: 'compare' as const,
series,
symbol: getShortSymbol(entity.getLabel()),
})),
);
this.syncRegistrations('compare', registrations);
}
private syncVolumeSeries(entities: Indicator[]): void {
const registrations = entities
.filter((entity) => entity.getType() === IndicatorsIds.Volume)
.flatMap((entity) =>
Array.from(entity.getSeriesMap().entries()).map(([seriesId, series]) => ({
id: `volume:${entity.getId()}:${seriesId}`,
role: 'volume' as const,
series,
})),
);
this.syncRegistrations('volume', registrations);
}
private syncRegistrations(
role: PriceLabelRole,
nextRegistrations: SeriesRegistrationInput[],
): void {
const nextRegistrationIds = new Set(
nextRegistrations.map(({ id }) => id),
);
this.registrations.forEach((registration, registrationId) => {
if (registration.role === role && !nextRegistrationIds.has(registrationId)) {
this.unregisterSeries(registrationId);
}
});
nextRegistrations.forEach((nextRegistration) => {
const currentRegistration = this.registrations.get(nextRegistration.id);
if (
currentRegistration &&
currentRegistration.series === nextRegistration.series
) {
currentRegistration.symbol = nextRegistration.symbol;
return;
}
if (currentRegistration) {
this.unregisterSeries(nextRegistration.id);
}
this.registerSeries(nextRegistration);
});
this.scheduleUpdate();
}
private registerSeries(registration: SeriesRegistrationInput): void {
const handleDataChanged = () => {
this.scheduleUpdate();
};
registration.series.subscribeDataChanged(handleDataChanged);
const registeredSeries: RegisteredSeries = {
...registration,
defaultLastValueVisible: registration.series.options().lastValueVisible,
defaultPriceLineVisible: registration.series.options().priceLineVisible,
handleDataChanged,
};
this.registrations.set(registration.id, registeredSeries);
if (registration.role === 'volume') {
registration.series.applyOptions({
lastValueVisible: false,
priceLineVisible: false,
});
return;
}
if (this.isHistoryMode) {
registration.series.applyOptions({
lastValueVisible: false,
});
}
}
private unregisterSeries(registrationId: string): void {
const registration = this.registrations.get(registrationId);
if (!registration) {
return;
}
registration.series.unsubscribeDataChanged(registration.handleDataChanged);
try {
registration.series.applyOptions({
lastValueVisible: registration.defaultLastValueVisible,
priceLineVisible: registration.defaultPriceLineVisible,
});
} catch {
// Серия могла быть удалена раньше регистрации.
}
if (
registration.role === 'main' &&
this.currentPriceLineHost === registration.series
) {
this.removeCurrentPriceLine();
}
this.registrations.delete(registrationId);
}
private getHistoryMode(logicalRange: LogicalRange | null): boolean {
if (!logicalRange) {
return false;
}
const mainRegistration = this.registrations.get('main');
if (!mainRegistration) {
return false;
}
const barsInfo = mainRegistration.series.barsInLogicalRange(logicalRange);
return (barsInfo?.barsAfter ?? 0) > 0;
}
private recalculateHistoryMode(): void {
const nextHistoryMode = this.getHistoryMode(this.visibleLogicalRange);
if (this.isHistoryMode === nextHistoryMode) {
this.scheduleUpdate();
return;
}
this.isHistoryMode = nextHistoryMode;
this.applyHistoryMode();
this.scheduleUpdate();
}
private applyHistoryMode(): void {
this.registrations.forEach((registration) => {
if (registration.role === 'volume') {
return;
}
registration.series.applyOptions({
lastValueVisible: this.isHistoryMode
? false
: registration.defaultLastValueVisible,
});
});
if (!this.isHistoryMode) {
this.hideCurrentPriceLine();
}
}
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 {
this.updateCurrentPriceLine();
const labelGroups = this.collectAxisLabels();
this.updateLayers(labelGroups);
}
private collectAxisLabels(): Map<string, AxisLabelsGroup> {
const labelGroups = new Map<string, AxisLabelsGroup>();
this.registrations.forEach((registration) => {
if (!registration.series.isVisible()) {
return;
}
if (registration.role === 'volume') {
const volumeLabel = this.createVolumeLabel(registration);
if (volumeLabel) {
this.addLabelToGroup(
labelGroups,
registration,
volumeLabel,
Direction.Right,
);
}
return;
}
if (!this.isHistoryMode) {
return;
}
const historicalLabel = this.createHistoricalLabel(registration);
if (!historicalLabel) {
return;
}
this.addLabelToGroup(
labelGroups,
registration,
historicalLabel,
getScaleSide(registration),
);
});
return labelGroups;
}
private createHistoricalLabel(
registration: RegisteredSeries,
): PriceAxisLabel | null {
const logicalRange = this.visibleLogicalRange;
if (!logicalRange) {
return null;
}
const data = registration.series.dataByIndex(
Math.floor(logicalRange.to),
MismatchDirection.NearestLeft,
);
const price = getDataPrice(data);
if (price === null) {
return null;
}
const coordinate = registration.series.priceToCoordinate(price);
if (coordinate === null) {
return null;
}
return {
id: `${registration.id}:history`,
coordinate,
value: this.formatValue(registration.series, price, logicalRange),
symbol:
registration.role === 'main'
? this.mainSymbol
: registration.symbol,
color: getSeriesColor(registration.series, data),
style: 'outlined',
priority: registration.role === 'main' ? 100 : 50,
};
}
private createVolumeLabel(
registration: RegisteredSeries,
): PriceAxisLabel | null {
const data = this.visibleLogicalRange
? registration.series.dataByIndex(
Math.floor(this.visibleLogicalRange.to),
MismatchDirection.NearestLeft,
)
: registration.series.data()[registration.series.data().length - 1];
const value = getDataPrice(data);
if (value === null) {
return null;
}
const coordinate = registration.series.priceToCoordinate(value);
if (coordinate === null) {
return null;
}
return {
id: `${registration.id}:volume`,
coordinate,
value: registration.series.priceFormatter().format(value),
color: getSeriesColor(registration.series, data),
style: 'filled',
priority: 10,
};
}
private addLabelToGroup(
labelGroups: Map<string, AxisLabelsGroup>,
registration: RegisteredSeries,
label: PriceAxisLabel,
side: PriceScaleSide,
): void {
const paneIndex = registration.series.getPane().paneIndex();
const layerKey = getLayerKey(paneIndex, side);
const currentGroup = labelGroups.get(layerKey);
if (currentGroup) {
currentGroup.labels.push(label);
return;
}
labelGroups.set(layerKey, {
paneIndex,
side,
labels: [label],
});
}
private updateLayers(labelGroups: Map<string, AxisLabelsGroup>): void {
const activeLayerKeys = new Set<string>();
labelGroups.forEach((labelGroup, layerKey) => {
const layerHost = this.getLayerHost(
labelGroup.paneIndex,
labelGroup.side,
);
if (!layerHost) {
return;
}
activeLayerKeys.add(layerKey);
const layer = this.getOrCreateLayer(layerKey, layerHost);
layer.primitive.setLabels(labelGroup.labels);
});
this.layers.forEach((layer, layerKey) => {
if (activeLayerKeys.has(layerKey)) {
return;
}
try {
layer.host.detachPrimitive(layer.primitive);
} catch {
// Серия могла быть удалена раньше слоя.
}
this.layers.delete(layerKey);
});
}
private getLayerHost(
paneIndex: number,
side: PriceScaleSide,
): SeriesStrategies | null {
const mainRegistration = this.registrations.get('main');
if (
paneIndex === MAIN_PANE_INDEX &&
side === Direction.Right &&
mainRegistration
) {
return mainRegistration.series;
}
const registration = Array.from(this.registrations.values()).find(
(currentRegistration) =>
currentRegistration.role !== 'volume' &&
currentRegistration.series.getPane().paneIndex() === paneIndex &&
getScaleSide(currentRegistration) === side,
);
return registration?.series ?? null;
}
private getOrCreateLayer(
layerKey: string,
layerHost: SeriesStrategies,
): AxisLayer {
const currentLayer = this.layers.get(layerKey);
if (currentLayer?.host === layerHost) {
return currentLayer;
}
if (currentLayer) {
try {
currentLayer.host.detachPrimitive(currentLayer.primitive);
} catch {
// Серия могла быть удалена раньше слоя.
}
}
const primitive = new PriceAxisLabelsPrimitive();
layerHost.attachPrimitive(primitive);
const nextLayer: AxisLayer = {
host: layerHost,
primitive,
};
this.layers.set(layerKey, nextLayer);
return nextLayer;
}
private updateCurrentPriceLine(): void {
const mainRegistration = this.registrations.get('main');
if (
!this.isHistoryMode ||
!mainRegistration ||
!mainRegistration.series.isVisible()
) {
this.hideCurrentPriceLine();
return;
}
const seriesData = mainRegistration.series.data();
const data = seriesData[seriesData.length - 1];
const price = getDataPrice(data);
if (price === null) {
this.hideCurrentPriceLine();
return;
}
const color = getSeriesColor(mainRegistration.series, data);
const priceLine = this.getCurrentPriceLine(
mainRegistration.series,
color,
);
priceLine.applyOptions({
price,
color,
lineVisible: false,
axisLabelVisible: true,
axisLabelColor: color,
axisLabelTextColor: getContrastTextColor(color),
title: this.mainSymbol,
});
}
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 {
// Серия могла быть удалена раньше price line.
}
}
this.currentPriceLine = null;
this.currentPriceLineHost = null;
}
private formatValue(
series: SeriesStrategies,
price: number,
logicalRange: LogicalRange,
): string {
const priceScaleMode = series.priceScale().options().mode;
if (
priceScaleMode !== PriceScaleMode.Percentage &&
priceScaleMode !== PriceScaleMode.IndexedTo100
) {
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 (priceScaleMode === PriceScaleMode.Percentage) {
const percentage = ((price - firstVisiblePrice) / firstVisiblePrice) * 100;
return `${percentage.toFixed(2)}%`;
}
return ((price / firstVisiblePrice) * 100).toFixed(2);
}
}