Загрузка данных
export const russian = {
Indicators: 'Индикаторы',
Settings: 'Настройки',
'Add volume indicator': 'Добавить индикатор объёма',
tick: 'тик',
ticks: 'тики',
tick_few: 'тика',
tick_many: 'тиков',
second: 'секунда',
seconds: 'секунды',
second_few: 'секунды',
second_many: 'секунд',
minute: 'минута',
minutes: 'минуты',
minute_few: 'минуты',
minute_many: 'минут',
hour: 'час',
hours: 'часы',
hour_few: 'часа',
hour_many: 'часов',
day: 'день',
days: 'дни',
day_few: 'дня',
day_many: 'дней',
week: 'неделя',
weeks: 'недели',
week_few: 'недели',
week_many: 'недель',
month: 'месяц',
months: 'месяцы',
month_few: 'месяца',
month_many: 'месяцев',
Bars: 'Бары',
Line: 'Линия',
Candles: 'Свечи',
'Time format': 'Формат времени',
'Date format': 'Формат даты',
'Line start': 'Начало линии',
'Line end': 'Конец линии',
Normal: 'Обычный',
Crosshair: 'Перекрестие',
Arrow: 'Стрелка',
'Trend line': 'Линия тренда',
Ray: 'Луч',
'Horizontal line': 'Горизонтальная линия',
'Horizontal ray': 'Горизонтальный луч',
'Vertical line': 'Вертикальная линия',
'Vertical ray': 'Вертикальный луч',
'Long position': 'Длинная позиция',
'Short position': 'Короткая позиция',
'Dates range': 'Диапазон дат',
'Prices range': 'Диапазон цен',
'Fixed range volume profile': 'Профиль объема в заданном диапазоне',
'Anchored volume profile': 'Профиль объема с привязкой',
'Fibonacci retracement': 'Коррекция Фибоначчи',
Rectangle: 'Прямоугольник',
Traectory: 'Траектория',
'Geometric shapes': 'Геометрические фигуры',
Text: 'Текст',
'XABCD template': 'Шаблон XABCD',
'Measurement tools': 'Инструменты для измерения и прогнозирования',
Ruler: 'Линейка',
Upscale: 'Увеличить масштаб',
Downscale: 'Уменьшить масштаб',
'Gann and Fibonacci': 'Инструменты Ганна и Фибоначчи',
'Endless drawing mode': 'Продолжить в режиме рисования',
'Fix all objects': 'Зафиксировать все объекты',
'Hide all drawing objects': 'Скрыть все объекты рисования',
'Clear objects': 'Удалить объекты',
'DOM tree': 'Дерево объектов',
'Magnet allows you to attract objects points to the nearest bar prices':
'Магнит позволяет притягивать точки объектов к ближайшим ценам (откр., макс., мин., закр.) баров',
Pin: 'Значок',
Open: 'Откр.',
High: 'Макс.',
Low: 'Мин.',
Close: 'Закр.',
Change: 'Изм.',
Time: 'Время',
Arguments: 'Аргументы',
// settings
Area: 'Область',
Border: 'Граница',
'Font size': 'Размер текста',
Bold: 'Жирный',
Italic: 'Курсив',
'Text color': 'Цвет текста',
'Background color': 'Цвет фона',
Lock: 'Заблокировать',
Unlock: 'Разблокировать',
Remove: 'Удалить',
Style: 'Стиль',
'Background transparency': 'Прозрачность фона',
Background: 'Фон',
Opacity: 'Прозрачность',
Reverse: 'Переворот',
'Labels position': 'Положение меток',
'Show prices': 'Отображать цену',
'Show level values': 'Отображать уровень',
Color: 'Цвет',
Levels: 'Уровни',
Level: 'Уровень',
Left: 'Слева',
Right: 'Справа',
'Enter text': 'Введите текст',
'Profit zone': 'Зона прибыли',
'Loss zone': 'Зона убытка',
'Line color': 'Цвет линии',
'Growing volume': 'Растущий объём',
'Decreasing volume': 'Снижающий объём',
'POC-line': 'POC-линия',
'Parallel channel': 'Параллельный канал',
'Regression trend': 'Регрессионный тренд',
'Middle line': 'Средняя линия',
'Channel line': 'Граница канала',
'Upper area': 'Верхняя зона',
'Lower area': 'Нижняя зона',
'Show middle line': 'Показывать среднюю линию',
'Show correlation': 'Показывать корреляцию',
Length: 'Длинна',
Data: 'Данные',
'Open price': 'Цена открытия',
Max: 'Максимум',
Min: 'Минимум',
'Close price': 'Цена закрытия',
Offset: 'Отступ',
'Fast length': 'Длина Fast',
'Slow length': 'Длина Slow',
'Signal length': 'Длина сигнала',
'Oscillator MA type': 'Тип осцилятора MA',
'Signal MA type': 'Тип сигнала MA',
'MA length': 'Длина MA',
Save: 'Сохранить',
Reject: 'Отменить',
Volume: 'Объём',
'1D': '1Д',
'5D': '5Д',
'1M': '1М',
'3M': '3М',
'6M': '6М',
'1Y': '1Г',
All: 'Всё',
'12h': '12ч',
'24h': '24ч',
'Mon Q3 \'97': 'Пн кв3 \'97',
'Mon Q3 1997': 'Пн кв3 1997',
'Mon 29 Sep \'97': 'Пн 29 сен. \'97',
'Mon Sep \'97': 'Пн сен. \'97',
'Mon Sep 29, 1997': 'Пн сен. 29, 1997',
'Mon Sep 1997': 'Пн сен. 1997',
'Mon Sep 29': 'Пн сен. 29',
'Mon 29 Sep': 'Пн 29 сен.',
'Mon 1997-09-29': 'Пн 1997-09-29',
'Mon 97-09-29': 'Пн 97-09-29',
'Mon 97/09/29': 'Пн 97/09/29',
'Mon 1997/09/29': 'Пн 1997/09/29',
'Mon 29-09-1997': 'Пн 29-09-1997',
'Mon 29-09-97': 'Пн 29-09-97',
'Mon 29/09/97': 'Пн 29/09/97',
'Mon 29/09/1997': 'Пн 29/09/1997',
'Mon 09/29/97': 'Пн 09/29/97',
'Mon 09/29/1997': 'Пн 09/29/1997',
'09.29.1997 00:00:00': '09.29.1997 00:00:00',
'interval for': 'интервал для',
timeframe: 'таймфрейма',
'Open P&L': 'Откр. поз. П/Л',
Qty: 'Кол-во',
'Risk/Reward Ratio': 'Соотношение риск/прибыль',
Target: 'Цель',
Amount: 'Количество',
Stop: 'Стоп',
Vol: 'Объем',
bars: 'бары',
AutoScale: 'Автомасштаб',
Logarithmic: 'Логарифмическая',
'1t': '1т',
'10t': '10т',
'100t': '100т',
'1000t': '1000т',
'1s': '1с',
'5s': '5с',
'10s': '10с',
'15s': '15с',
'30s': '30с',
'45s': '45с',
'1m': '1м',
'2m': '2м',
'3m': '3м',
'5m': '5м',
'10m': '10м',
'15m': '15м',
'30m': '30м',
'45m': '45м',
'1h': '1ч',
'2h': '2ч',
'3h': '3ч',
'4h': '4ч',
'8h': '8ч',
'1d': '1д',
'3d': '3д',
'5d': '5д',
'1w': '1н',
'1М': '1M',
'3М': '3M',
};
import { Observable, skip } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
getPriceDelta as getPriceDeltaFromCoordinates,
getPriceFromYCoordinate,
getPriceRangeInContainer,
getTimeFromXCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isPointInBounds,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { Defaults } from '@src/types/defaults';
import { formatPercent, formatPrice, formatSignedNumber } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { SliderPaneView } from './paneView';
import {
createDefaultSettings,
getSliderPositionSettingsTabs,
SliderPositionArguments,
SliderPositionSettings,
SliderPositionStyle,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment, Bounds, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type { ChartOptionsModel, SettingsTab, SettingsValues } from '@src/types';
import type {
IPrimitivePaneView,
MouseEventHandler,
MouseEventParams,
PrimitiveHoveredItem,
Time,
UTCTimestamp,
} from 'lightweight-charts';
type SliderSide = 'long' | 'short';
type SliderMode = 'idle' | 'ready' | 'dragging';
type DragTarget = 'body' | 'entry' | 'target' | 'stop' | 'end' | null;
type SliderHandleId = Exclude<DragTarget, 'body' | null>;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'target' | 'entry' | 'stop';
interface SliderPositionParams extends BaseDrawingParams {
side: SliderSide;
resetTriggers?: Observable<unknown>[];
pointValue?: number;
}
interface SliderPositionState {
hidden: boolean;
mode: SliderMode;
startTime: Time | null;
endTime: Time | null;
entryPrice: number | null;
stopPrice: number | null;
targetPrice: number | null;
riskRewardRatio: number;
amount: number;
tickSize: number;
settings: SliderPositionSettings;
}
interface PositionMetrics {
qty: number;
targetAmount: number;
stopAmount: number;
riskRewardRatio: number;
openPnl: number;
}
interface SliderGeometry {
startX: number;
endX: number;
leftX: number;
rightX: number;
entryY: number;
stopY: number;
targetY: number;
entryPrice: number;
stopPrice: number;
targetPrice: number;
profitTop: number;
profitBottom: number;
lossTop: number;
lossBottom: number;
}
export interface SliderRenderData extends SliderGeometry, SliderPositionStyle {
targetText: string;
centerText: string;
stopText: string;
centerBoxColor: string;
targetLabelDirection: 'up' | 'down';
stopLabelDirection: 'up' | 'down';
showLabels: boolean;
}
const HIT_TOLERANCE = 8;
const INITIAL_WIDTH_PX = 160;
const INITIAL_ZONE_OFFSET_PX = 60;
const VALUE_PRECISION = 2;
const MIN_DISTANCE = 0.00000001;
export class SliderPosition
extends SeriesDrawingBase<SliderPositionSettings, SliderHandleId>
implements ISeriesDrawing
{
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: SliderPositionSettings = createDefaultSettings();
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
protected mode: SliderMode = 'idle';
private side: SliderSide;
private startTime: Time | null = null;
private endTime: Time | null = null;
private entryPrice: number | null = null;
private stopPrice: number | null = null;
private targetPrice: number | null = null;
private activeDragTarget: DragTarget = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragStateSnapshot: SliderPositionState | null = null;
private defaultRiskRewardRatio = 1;
private tickSize = 1;
private readonly pointValue: number;
private clickHandler: MouseEventHandler<Time>;
private paneView: SliderPaneView;
private timeAxisPaneView: CustomTimeAxisPaneView;
private priceAxisPaneView: CustomPriceAxisPaneView;
private startTimeAxisView: CustomTimeAxisView;
private endTimeAxisView: CustomTimeAxisView;
private targetPriceAxisView: CustomPriceAxisView;
private entryPriceAxisView: CustomPriceAxisView;
private stopPriceAxisView: CustomPriceAxisView;
constructor({
chart,
series,
side,
container,
interaction,
formatObservable,
resetTriggers = [],
removeSelf,
openSettings,
initialEvent,
pointValue = 1,
}: SliderPositionParams) {
super({ chart, series, container, interaction });
this.side = side;
this.pointValue = Number.isFinite(pointValue) && pointValue > 0 ? pointValue : 1;
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.clickHandler = (params) => this.handleChartClick(params);
this.paneView = new SliderPaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
zOrder: 'normal',
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
zOrder: 'normal',
});
this.startTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'start',
});
this.endTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'end',
});
this.targetPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'target',
});
this.entryPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'entry',
});
this.stopPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'stop',
});
if (formatObservable) {
this.subscriptions.add(
formatObservable.subscribe((format) => {
this.displayFormat = format;
this.render();
}),
);
}
resetTriggers.forEach((trigger) => {
this.subscriptions.add(
trigger.pipe(skip(1)).subscribe(() => {
this.removeSelf?.();
}),
);
});
this.series.attachPrimitive(this);
if (initialEvent) {
this.handleChartClick(initialEvent);
}
}
public isCreationPending(): boolean {
return this.mode === 'idle';
}
public getState(): SliderPositionState {
return {
hidden: this.hidden,
mode: this.mode,
startTime: this.startTime,
endTime: this.endTime,
entryPrice: this.entryPrice,
stopPrice: this.stopPrice,
targetPrice: this.targetPrice,
riskRewardRatio: this.getCurrentRiskRewardRatio(),
amount: this.settings.accountSize,
tickSize: this.getPriceStep(),
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const next = state as Partial<SliderPositionState>;
this.hidden = next.hidden ?? this.hidden;
this.mode = next.mode ?? this.mode;
if (next.startTime !== undefined) {
this.startTime = next.startTime;
}
if (next.endTime !== undefined) {
this.endTime = next.endTime;
}
if (next.entryPrice !== undefined) {
this.entryPrice = next.entryPrice;
}
if (typeof next.tickSize === 'number' && Number.isFinite(next.tickSize) && next.tickSize > 0) {
this.tickSize = next.tickSize;
}
if (next.stopPrice !== undefined) {
this.stopPrice =
next.stopPrice === null || this.entryPrice === null
? next.stopPrice
: this.normalizeStop(this.entryPrice, next.stopPrice);
}
if (next.targetPrice !== undefined) {
this.targetPrice =
next.targetPrice === null || this.entryPrice === null
? next.targetPrice
: this.normalizeTarget(this.entryPrice, next.targetPrice);
} else if (
this.entryPrice !== null &&
this.stopPrice !== null &&
typeof next.riskRewardRatio === 'number' &&
next.riskRewardRatio >= 0
) {
const risk = Math.abs(this.entryPrice - this.stopPrice);
const direction = this.side === 'long' ? 1 : -1;
const targetPrice = this.entryPrice + risk * next.riskRewardRatio * direction;
this.targetPrice = this.normalizeTarget(this.entryPrice, targetPrice);
}
if ('settings' in next && next.settings) {
this.settings = {
...createDefaultSettings(),
...next.settings,
};
}
if (typeof next.amount === 'number' && Number.isFinite(next.amount) && next.amount >= 0) {
this.settings.accountSize = next.amount;
}
this.render();
}
public getSettings(): SettingsValues {
return {
...this.settings,
...this.getPositionArguments(),
};
}
public updateSettings(values: SettingsValues): void {
const { entryPrice, targetTicks, targetPrice, stopTicks, stopPrice, ...settings } = values as Partial<
SliderPositionSettings & SliderPositionArguments
>;
this.applyPositionArguments({
entryPrice,
targetTicks,
targetPrice,
stopTicks,
stopPrice,
});
super.updateSettings(settings as SettingsValues);
}
public getSettingsTabs(): SettingsTab[] {
return getSliderPositionSettingsTabs(this.settings, this.getPositionArguments());
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.endTimeAxisView,
this.targetPriceAxisView,
this.entryPriceAxisView,
this.stopPriceAxisView,
]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.priceAxisPaneView];
}
public timeAxisViews() {
return [this.startTimeAxisView, this.endTimeAxisView];
}
public priceAxisViews() {
return [this.targetPriceAxisView, this.entryPriceAxisView, this.stopPriceAxisView];
}
public getRenderData(): SliderRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
const selectedPrice = this.getPriceAtTime(this.endTime);
const metrics = this.getPositionMetrics(geometry, selectedPrice);
const { colors } = getThemeStore();
return {
...geometry,
targetText: this.getTargetText(geometry, metrics.targetAmount),
centerText: this.getCenterText(metrics),
stopText: this.getStopText(geometry, metrics.stopAmount),
centerBoxColor: metrics.openPnl >= 0 ? colors.chartCandleUp : colors.chartCandleDown,
targetLabelDirection: this.side === 'long' ? 'up' : 'down',
stopLabelDirection: this.side === 'long' ? 'down' : 'up',
showLabels: this.settings.alwaysShowStats || this.isSelected(),
...this.settings,
};
}
public getTimeBounds(): { left: number; right: number } | null {
const start = this.getTimeCoordinate('start');
const end = this.getTimeCoordinate('end');
if (start === null || end === null) {
return null;
}
return {
left: Math.min(start, end),
right: Math.max(start, end),
};
}
public getTimeCoordinate(kind: TimeLabelKind): number | null {
const time = kind === 'start' ? this.startTime : this.endTime;
if (time === null) {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, time, this.series);
return coordinate === null ? null : Number(coordinate);
}
public getTimeText(kind: TimeLabelKind): string {
const time = kind === 'start' ? this.startTime : this.endTime;
if (typeof time !== 'number') {
return '';
}
return formatDate(
time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
public getPriceCoordinate(kind: PriceLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
switch (kind) {
case 'target':
return geometry.targetY;
case 'entry':
return geometry.entryY;
case 'stop':
return geometry.stopY;
default:
return null;
}
}
public getPriceText(kind: PriceLabelKind): string {
const geometry = this.getGeometry();
if (!geometry) {
return '';
}
switch (kind) {
case 'target':
return formatPrice(geometry.targetPrice) ?? '';
case 'entry':
return formatPrice(geometry.entryPrice) ?? '';
case 'stop':
return formatPrice(geometry.stopPrice) ?? '';
default:
return '';
}
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
const point = { x, y };
if (!this.isSelected()) {
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: 'pointer',
externalId: 'slider-position',
zOrder: 'top',
};
}
const dragTarget = this.getHandleTarget(point);
if (!dragTarget) {
return null;
}
let cursorStyle: PrimitiveHoveredItem['cursorStyle'] = 'grab';
if (dragTarget === 'target' || dragTarget === 'stop') {
cursorStyle = 'ns-resize';
}
if (dragTarget === 'end') {
cursorStyle = 'ew-resize';
}
return {
cursorStyle,
externalId: 'slider-position',
zOrder: 'top',
};
}
protected getTimeAxisSegments(): AxisSegment[] {
if (!this.isSelected()) {
return [];
}
const bounds = this.getTimeBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.left,
to: bounds.right,
color: colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (!this.isSelected()) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.profitTop,
to: geometry.profitBottom,
color: colors.axisMarkerAreaFill,
},
{
from: geometry.lossTop,
to: geometry.lossBottom,
color: colors.axisMarkerAreaFill,
},
];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.isSelected() || (kind !== 'start' && kind !== 'end')) {
return null;
}
const labelKind = kind as TimeLabelKind;
const coordinate = this.getTimeCoordinate(labelKind);
const text = this.getTimeText(labelKind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.settings.showPriceLabels) {
return null;
}
if (kind !== 'target' && kind !== 'entry' && kind !== 'stop') {
return null;
}
const labelKind = kind as PriceLabelKind;
const coordinate = this.getPriceCoordinate(labelKind);
const text = this.getPriceText(labelKind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
let backgroundColor = colors.axisMarkerLabelDefaultFill;
if (labelKind === 'target') {
backgroundColor = colors.axisMarkerLabelPositiveFill;
}
if (labelKind === 'stop') {
backgroundColor = colors.axisMarkerLabelNegativeFill;
}
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor,
};
}
protected bindEvents(): void {
if (this.isBound) {
return;
}
super.bindEvents();
this.chart.subscribeClick(this.clickHandler);
}
protected unbindEvents(): void {
if (!this.isBound) {
return;
}
this.chart.unsubscribeClick(this.clickHandler);
super.unbindEvents();
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready' || !this.isSelected()) {
return;
}
const point = this.getEventPoint(event as PointerEvent);
if (!this.containsPoint(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
private handleChartClick(params: MouseEventParams<Time>): void {
// todo: привести к общему виду дровингов как handleChartClick => startDrawing
if (this.hidden || !params.point || this.mode !== 'idle') {
return;
}
const anchor = this.createAnchor(params);
if (!anchor) {
return;
}
const distance = this.getInitialZoneDistance(anchor.price, params.point.y);
if (distance === null) {
this.mode = 'ready';
this.resolveReady?.();
this.removeSelf?.();
return;
}
const stopDirection = this.side === 'long' ? -1 : 1;
const targetDirection = -stopDirection;
this.startTime = anchor.time;
this.endTime = this.shiftTime(anchor.time, INITIAL_WIDTH_PX) ?? anchor.time;
this.entryPrice = anchor.price;
this.stopPrice = this.normalizeStop(anchor.price, anchor.price + distance * stopDirection);
this.targetPrice = this.normalizeTarget(
anchor.price,
anchor.price + distance * targetDirection * this.defaultRiskRewardRatio,
);
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
protected handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || this.mode !== 'ready' || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (!this.isSelected()) {
if (!this.containsPoint(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.select();
return;
}
const dragTarget = this.getHandleTarget(point);
if (!dragTarget) {
this.deselect();
return;
}
event.preventDefault();
event.stopPropagation();
this.activeDragTarget = dragTarget;
this.dragPointerId = event.pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
this.mode = 'dragging';
};
protected handlePointerMove = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId || !this.dragStateSnapshot) {
return;
}
event.preventDefault();
this.applyDrag(this.getEventPoint(event));
this.render();
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.mode = 'ready';
this.resolveReady?.();
this.render();
};
private applyDrag(point: Point): void {
const snapshot = this.dragStateSnapshot;
if (!snapshot) {
return;
}
switch (this.activeDragTarget) {
case 'body':
this.moveWhole(snapshot, point);
break;
case 'entry':
this.moveEntry(snapshot, point);
break;
case 'target':
this.moveTarget(snapshot, point);
break;
case 'stop':
this.moveStop(snapshot, point);
break;
case 'end':
this.resizeEnd(snapshot, point);
break;
default:
break;
}
}
private moveEntry(snapshot: SliderPositionState, point: Point): void {
if (snapshot.stopPrice === null || snapshot.targetPrice === null) {
return;
}
const nextPrice = getPriceFromYCoordinate(this.series, point.y);
if (nextPrice === null) {
return;
}
const priceStep = this.getPriceStep();
const minPrice = Math.min(snapshot.stopPrice, snapshot.targetPrice) + priceStep;
const maxPrice = Math.max(snapshot.stopPrice, snapshot.targetPrice) - priceStep;
if (minPrice > maxPrice) {
return;
}
this.entryPrice = Math.max(minPrice, Math.min(nextPrice, maxPrice));
}
private moveWhole(snapshot: SliderPositionState, point: Point): void {
if (snapshot.startTime === null || snapshot.endTime === null) {
return;
}
if (snapshot.entryPrice === null || snapshot.stopPrice === null || snapshot.targetPrice === null) {
return;
}
if (!this.dragStartPoint) {
return;
}
const timeOffset = point.x - this.dragStartPoint.x;
const priceOffset = this.getPriceDelta(this.dragStartPoint.y, point.y);
const nextStartTime = this.shiftTime(snapshot.startTime, timeOffset);
const nextEndTime = this.shiftTime(snapshot.endTime, timeOffset);
if (nextStartTime === null || nextEndTime === null) {
return;
}
let nextEntryPrice = snapshot.entryPrice + priceOffset;
let nextStopPrice = snapshot.stopPrice + priceOffset;
let nextTargetPrice = snapshot.targetPrice + priceOffset;
const range = this.getPriceScaleRange();
if (range) {
const minValue = Math.min(nextEntryPrice, nextStopPrice, nextTargetPrice);
const maxValue = Math.max(nextEntryPrice, nextStopPrice, nextTargetPrice);
if (minValue < range.min) {
const shift = range.min - minValue;
nextEntryPrice += shift;
nextStopPrice += shift;
nextTargetPrice += shift;
}
if (maxValue > range.max) {
const shift = maxValue - range.max;
nextEntryPrice -= shift;
nextStopPrice -= shift;
nextTargetPrice -= shift;
}
}
this.startTime = nextStartTime;
this.endTime = nextEndTime;
this.entryPrice = nextEntryPrice;
this.stopPrice = nextStopPrice;
this.targetPrice = nextTargetPrice;
}
private moveStop(snapshot: SliderPositionState, point: Point): void {
if (snapshot.entryPrice === null) {
return;
}
const nextPrice = getPriceFromYCoordinate(this.series, point.y);
if (nextPrice === null) {
return;
}
this.stopPrice = this.normalizeStop(snapshot.entryPrice, this.clampPriceToRange(nextPrice));
}
private moveTarget(snapshot: SliderPositionState, point: Point): void {
if (snapshot.entryPrice === null) {
return;
}
const nextPrice = getPriceFromYCoordinate(this.series, point.y);
if (nextPrice === null) {
return;
}
this.targetPrice = this.normalizeTarget(snapshot.entryPrice, this.clampPriceToRange(nextPrice));
}
private resizeEnd(snapshot: SliderPositionState, point: Point): void {
const nextEndTime = getTimeFromXCoordinate(this.chart, point.x);
if (nextEndTime === null) {
return;
}
this.startTime = snapshot.startTime;
this.endTime = nextEndTime;
}
private createAnchor(params: MouseEventParams<Time>): { time: Time; price: number } | null {
if (!params.point || params.time === undefined) {
return null;
}
const price = getPriceFromYCoordinate(this.series, params.point.y);
if (price === null) {
return null;
}
return {
time: params.time,
price,
};
}
private normalizeStop(entryPrice: number, rawPrice: number): number {
const priceStep = this.getPriceStep();
return this.side === 'long'
? Math.min(rawPrice, entryPrice - priceStep)
: Math.max(rawPrice, entryPrice + priceStep);
}
private normalizeTarget(entryPrice: number, rawPrice: number): number {
const priceStep = this.getPriceStep();
return this.side === 'long'
? Math.max(rawPrice, entryPrice + priceStep)
: Math.min(rawPrice, entryPrice - priceStep);
}
private clampPriceToRange(price: number): number {
const range = this.getPriceScaleRange();
if (!range) {
return price;
}
return Math.max(range.min, Math.min(price, range.max));
}
private getInitialZoneDistance(entryPrice: number, entryY: number): number | null {
const { height } = this.container.getBoundingClientRect();
const availableOffset = Math.min(entryY, height - entryY) - HIT_TOLERANCE;
const offset = Math.min(INITIAL_ZONE_OFFSET_PX, availableOffset);
if (offset <= 0) {
return null;
}
const upperPrice = getPriceFromYCoordinate(this.series, entryY - offset);
const lowerPrice = getPriceFromYCoordinate(this.series, entryY + offset);
if (upperPrice === null || lowerPrice === null) {
return null;
}
const minPrice = Math.min(upperPrice, lowerPrice);
const maxPrice = Math.max(upperPrice, lowerPrice);
const stopDistance = this.side === 'long' ? entryPrice - minPrice : maxPrice - entryPrice;
const targetDistance = this.side === 'long' ? maxPrice - entryPrice : entryPrice - minPrice;
if (stopDistance <= 0 || targetDistance <= 0) {
return null;
}
const riskRewardRatio = Math.max(this.defaultRiskRewardRatio, MIN_DISTANCE);
const distance = Math.min(stopDistance, targetDistance / riskRewardRatio);
const priceStep = this.getPriceStep();
const minDistance = Math.max(priceStep, priceStep / riskRewardRatio);
return distance >= minDistance ? distance : null;
}
private getPriceStep(): number {
const priceFormat = this.series.options?.().priceFormat;
const minMove = priceFormat && 'minMove' in priceFormat ? priceFormat.minMove : undefined;
return typeof minMove === 'number' && minMove > 0 ? minMove : Math.max(this.tickSize, MIN_DISTANCE);
}
private getPriceScaleRange(): { min: number; max: number } | null {
return getPriceRangeInContainer(this.series, this.container);
}
private getPriceDelta(fromY: number, toY: number): number {
return getPriceDeltaFromCoordinates(this.series, fromY, toY);
}
private shiftTime(time: Time, offsetX: number): Time | null {
return shiftTimeByPixels(this.chart, time, offsetX, this.series);
}
private getCurrentRiskRewardRatio(): number {
if (this.entryPrice === null || this.stopPrice === null || this.targetPrice === null) {
return this.defaultRiskRewardRatio;
}
const risk = Math.abs(this.entryPrice - this.stopPrice);
if (risk <= MIN_DISTANCE) {
return 0;
}
return Math.abs(this.targetPrice - this.entryPrice) / risk;
}
private getPriceAtTime(time: Time | null): number | null {
if (typeof time !== 'number') {
return null;
}
const data = this.series.data() ?? [];
let lastPrice: number | null = null;
for (const item of data) {
if (typeof item.time !== 'number') {
continue;
}
if (item.time > time) {
break;
}
if ('close' in item && typeof item.close === 'number') {
lastPrice = item.close;
continue;
}
if ('value' in item && typeof item.value === 'number') {
lastPrice = item.value;
}
}
return lastPrice;
}
protected getDrawingHandles(): readonly DrawingHandle<SliderHandleId>[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [
{
id: 'target',
x: geometry.startX,
y: geometry.targetY,
},
{
id: 'stop',
x: geometry.startX,
y: geometry.stopY,
},
{
id: 'end',
x: geometry.endX,
y: geometry.entryY,
},
{
id: 'entry',
x: geometry.startX,
y: geometry.entryY,
shape: 'circle',
},
];
}
protected getGeometry(): SliderGeometry | null {
if (this.startTime === null || this.endTime === null) {
return null;
}
if (this.entryPrice === null || this.stopPrice === null || this.targetPrice === null) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
const entryY = getYCoordinateFromPrice(this.series, this.entryPrice);
const stopY = getYCoordinateFromPrice(this.series, this.stopPrice);
const targetY = getYCoordinateFromPrice(this.series, this.targetPrice);
if (startX === null || endX === null || entryY === null || stopY === null || targetY === null) {
return null;
}
const start = Number(startX);
const end = Number(endX);
const entry = Number(entryY);
const stop = Number(stopY);
const target = Number(targetY);
return {
startX: start,
endX: end,
leftX: Math.min(start, end),
rightX: Math.max(start, end),
entryY: entry,
stopY: stop,
targetY: target,
entryPrice: this.entryPrice,
stopPrice: this.stopPrice,
targetPrice: this.targetPrice,
profitTop: Math.min(target, entry),
profitBottom: Math.max(target, entry),
lossTop: Math.min(stop, entry),
lossBottom: Math.max(stop, entry),
};
}
private getPositionArguments(): SliderPositionArguments {
const priceStep = this.getPriceStep();
const entryPrice = this.entryPrice ?? 0;
const targetPrice = this.targetPrice ?? entryPrice;
const stopPrice = this.stopPrice ?? entryPrice;
return {
entryPrice,
targetTicks: priceStep > 0 ? Math.round(Math.abs(targetPrice - entryPrice) / priceStep) : 0,
targetPrice,
stopTicks: priceStep > 0 ? Math.round(Math.abs(stopPrice - entryPrice) / priceStep) : 0,
stopPrice,
};
}
private applyPositionArguments(values: Partial<SliderPositionArguments>): void {
if (isFiniteNumber(values.entryPrice)) {
this.entryPrice = values.entryPrice;
}
if (this.entryPrice === null) {
return;
}
const { entryPrice } = this;
const priceStep = this.getPriceStep();
const targetDirection = this.side === 'long' ? 1 : -1;
const stopDirection = -targetDirection;
if (isFiniteNumber(values.targetPrice)) {
this.targetPrice = this.normalizeTarget(entryPrice, values.targetPrice);
} else if (isFiniteNumber(values.targetTicks)) {
this.targetPrice = this.normalizeTarget(
entryPrice,
entryPrice + Math.max(values.targetTicks, 1) * priceStep * targetDirection,
);
} else if (this.targetPrice !== null) {
this.targetPrice = this.normalizeTarget(entryPrice, this.targetPrice);
}
if (isFiniteNumber(values.stopPrice)) {
this.stopPrice = this.normalizeStop(entryPrice, values.stopPrice);
} else if (isFiniteNumber(values.stopTicks)) {
this.stopPrice = this.normalizeStop(
entryPrice,
entryPrice + Math.max(values.stopTicks, 1) * priceStep * stopDirection,
);
} else if (this.stopPrice !== null) {
this.stopPrice = this.normalizeStop(entryPrice, this.stopPrice);
}
}
private getPositionMetrics(geometry: SliderGeometry, selectedPrice: number | null): PositionMetrics {
const riskDistance = Math.abs(geometry.entryPrice - geometry.stopPrice);
const rewardDistance = Math.abs(geometry.targetPrice - geometry.entryPrice);
const accountSize = getNonNegativeValue(this.settings.accountSize);
const risk = getNonNegativeValue(this.settings.risk);
const { pointValue } = this;
const lotSize = getPositiveValue(this.settings.lotSize);
const leverage = getPositiveValue(this.settings.leverage);
const riskSize = this.settings.riskPercent ? (risk / 100) * accountSize : risk;
const canCalculateQty =
riskDistance > MIN_DISTANCE && geometry.entryPrice > MIN_DISTANCE && lotSize !== null && leverage !== null;
if (!canCalculateQty) {
return {
qty: 0,
targetAmount: accountSize,
stopAmount: accountSize,
riskRewardRatio: riskDistance > MIN_DISTANCE ? rewardDistance / riskDistance : 0,
openPnl: this.getOpenPnl(geometry.entryPrice, selectedPrice),
};
}
const qtyRisk = riskSize / (riskDistance * pointValue) / lotSize;
const qtyLeverage = (((accountSize * leverage) / geometry.entryPrice) * pointValue) / lotSize;
const qty = roundToPrecision(Math.min(qtyRisk, qtyLeverage), this.settings.quantityPrecision);
const multiplier = pointValue * lotSize;
return {
qty,
targetAmount: accountSize + rewardDistance * qty * multiplier,
stopAmount: accountSize - riskDistance * qty * multiplier,
riskRewardRatio: rewardDistance / riskDistance,
openPnl: this.getOpenPnl(geometry.entryPrice, selectedPrice),
};
}
private getOpenPnl(entryPrice: number, selectedPrice: number | null): number {
if (selectedPrice === null) {
return 0;
}
return this.side === 'long' ? selectedPrice - entryPrice : entryPrice - selectedPrice;
}
private getTargetText(geometry: SliderGeometry, amount: number): string {
const diff = Math.abs(geometry.targetPrice - geometry.entryPrice);
const percent = geometry.entryPrice !== 0 ? (diff / Math.abs(geometry.entryPrice)) * 100 : 0;
const ticks = diff / this.getPriceStep();
const formattedDiff = formatPrice(diff) ?? '0';
const formattedTicks = formatMetric(ticks, VALUE_PRECISION);
const formattedAmount = formatMetric(amount, VALUE_PRECISION);
return [
`${t('Target')}: ${formattedDiff} (${formatPercent(percent)}) ${formattedTicks}`,
`${t('Amount')}: ${formattedAmount}`,
].join(', ');
}
private getCenterText(metrics: PositionMetrics): string {
const formattedQty = formatMetric(metrics.qty, this.settings.quantityPrecision);
const formattedRatio = formatMetric(metrics.riskRewardRatio, VALUE_PRECISION);
return [
`${t('Open P&L')}: ${formatSignedNumber(metrics.openPnl)}, ${t('Qty')}: ${formattedQty}`,
`${t('Risk/Reward Ratio')}: ${formattedRatio}`,
].join('\n');
}
private getStopText(geometry: SliderGeometry, amount: number): string {
const diff = Math.abs(geometry.stopPrice - geometry.entryPrice);
const percent = geometry.entryPrice !== 0 ? (diff / Math.abs(geometry.entryPrice)) * 100 : 0;
const ticks = diff / this.getPriceStep();
const formattedDiff = formatPrice(diff) ?? '0';
const formattedTicks = formatMetric(ticks, VALUE_PRECISION);
const formattedAmount = formatMetric(amount, VALUE_PRECISION);
return [
`${t('Stop')}: ${formattedDiff} (${formatPercent(percent)}) ${formattedTicks}`,
`${t('Amount')}: ${formattedAmount}`,
].join(', ');
}
private getHandleTarget(point: Point): DragTarget {
const handle = this.getDrawingHandleAtPoint(point);
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
if (handle?.id === 'entry' && point.y !== geometry.entryY) {
const pointerSide = Math.sign(point.y - geometry.entryY);
const targetSide = Math.sign(geometry.targetY - geometry.entryY) || -Math.sign(geometry.stopY - geometry.entryY);
const stopSide = Math.sign(geometry.stopY - geometry.entryY) || -Math.sign(geometry.targetY - geometry.entryY);
if (pointerSide === targetSide && Math.abs(point.y - geometry.targetY) <= HIT_TOLERANCE) {
return 'target';
}
if (pointerSide === stopSide && Math.abs(point.y - geometry.stopY) <= HIT_TOLERANCE) {
return 'stop';
}
}
if (handle) {
return handle.id;
}
const bounds: Bounds = {
left: geometry.leftX,
right: geometry.rightX,
top: Math.min(geometry.targetY, geometry.stopY),
bottom: Math.max(geometry.targetY, geometry.stopY),
};
return isPointInBounds(point, bounds) ? 'body' : null;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
const bounds: Bounds = {
left: geometry.leftX,
right: geometry.rightX,
top: Math.min(geometry.targetY, geometry.stopY),
bottom: Math.max(geometry.targetY, geometry.stopY),
};
return isPointInBounds(point, bounds, HIT_TOLERANCE);
}
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function getNonNegativeValue(value: number): number {
return Number.isFinite(value) && value >= 0 ? value : 0;
}
function getPositiveValue(value: number): number | null {
return Number.isFinite(value) && value > 0 ? value : null;
}
function roundToPrecision(value: number, precision: number): number {
if (!Number.isFinite(value)) {
return 0;
}
const safePrecision = Math.max(0, Math.min(16, Math.trunc(precision)));
const factor = 10 ** safePrecision;
return Math.round((value + Number.EPSILON) * factor) / factor;
}
function formatMetric(value: number, precision: number): string {
return String(roundToPrecision(value, precision));
}
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { SettingField, SettingsTab, SettingsValues } from '@src/types';
export interface SliderPositionStyle {
lineColor: string;
lineWidth: number;
positiveFillColor: string;
negativeFillColor: string;
textColor: string;
fontSize: number;
showPriceLabels: boolean;
alwaysShowStats: boolean;
}
export interface SliderPositionCalculationSettings {
accountSize: number;
risk: number;
riskPercent: boolean;
lotSize: number;
leverage: number;
quantityPrecision: number;
}
export interface SliderPositionArguments {
entryPrice: number;
targetTicks: number;
targetPrice: number;
stopTicks: number;
stopPrice: number;
}
export type SliderPositionSettings = SettingsValues & SliderPositionStyle & SliderPositionCalculationSettings;
export function createDefaultSettings(): SliderPositionSettings {
const { colors } = getThemeStore();
return {
accountSize: 1000,
risk: 1,
riskPercent: true,
lotSize: 1,
leverage: 1,
quantityPrecision: 3,
lineColor: colors.chartCrosshairLine,
lineWidth: 1,
positiveFillColor: colors.sliderPositiveFill,
negativeFillColor: colors.sliderNegativeFill,
textColor: colors.chartPriceLineText,
fontSize: 10,
showPriceLabels: true,
alwaysShowStats: false,
};
}
export function getSliderPositionSettingsTabs(
settings: SliderPositionSettings,
position: SliderPositionArguments,
): SettingsTab[] {
const inputFields: SettingField[] = [
{
key: 'accountSize',
label: t('Account size'),
type: 'number',
defaultValue: settings.accountSize,
min: 0,
},
{
key: 'lotSize',
label: t('Lot size'),
type: 'number',
defaultValue: settings.lotSize,
min: 0.00000001,
},
{
key: 'risk',
label: t('Risk'),
type: 'number',
defaultValue: settings.risk,
min: 0,
},
{
key: 'riskPercent',
label: t('Risk in percent'),
type: 'boolean',
defaultValue: settings.riskPercent,
},
{
key: 'entryPrice',
label: t('Entry price'),
type: 'number',
defaultValue: position.entryPrice,
},
{
key: 'leverage',
label: t('Leverage'),
type: 'number',
defaultValue: settings.leverage,
min: 0.00000001,
},
{
key: 'targetTicks',
label: t('Profit level in ticks'),
type: 'number',
defaultValue: position.targetTicks,
min: 1,
},
{
key: 'targetPrice',
label: t('Profit price'),
type: 'number',
defaultValue: position.targetPrice,
},
{
key: 'stopTicks',
label: t('Stop level in ticks'),
type: 'number',
defaultValue: position.stopTicks,
min: 1,
},
{
key: 'stopPrice',
label: t('Stop price'),
type: 'number',
defaultValue: position.stopPrice,
},
{
key: 'quantityPrecision',
label: t('QTY precision'),
type: 'number',
defaultValue: settings.quantityPrecision,
min: 0,
max: 16,
},
];
const styleFields: SettingField[] = [
{
key: 'lineColor',
label: t('Line'),
type: 'color',
defaultValue: settings.lineColor,
},
{
key: 'lineWidth',
label: t('Line width'),
type: 'number',
defaultValue: settings.lineWidth,
min: 1,
max: 4,
},
{
key: 'negativeFillColor',
label: t('Stop color'),
type: 'color',
defaultValue: settings.negativeFillColor,
toolbar: {
control: 'color',
role: 'fill',
},
},
{
key: 'positiveFillColor',
label: t('Target color'),
type: 'color',
defaultValue: settings.positiveFillColor,
toolbar: {
control: 'color',
role: 'fill',
},
},
{
key: 'textColor',
label: t('Text'),
type: 'color',
defaultValue: settings.textColor,
toolbar: {
control: 'color',
role: 'text',
},
},
{
key: 'fontSize',
label: t('Font size'),
type: 'number',
defaultValue: settings.fontSize,
min: 8,
max: 24,
},
{
key: 'showPriceLabels',
label: t('Price labels'),
type: 'boolean',
defaultValue: settings.showPriceLabels,
},
{
key: 'alwaysShowStats',
label: t('Always show stats'),
type: 'boolean',
defaultValue: settings.alwaysShowStats,
},
];
return [
{
key: 'inputs',
label: t('Arguments'),
fields: inputFields,
},
{
key: 'style',
label: t('Style'),
fields: styleFields,
},
];
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { drawRoundedRect } from '@core/Drawings/utils';
import type { SliderPositionStyle } from './settings';
import type { SliderPosition } from './sliderPosition';
const UI = {
lineWidth: 1,
lineHeightMultiplier: 1.2,
mainBoxHeight: 28,
sideBoxHeight: 16,
padding: 4,
boxRadius: 4,
labelOffset: 10,
};
export class SliderPaneRenderer implements IPrimitivePaneRenderer {
private readonly slider: SliderPosition;
constructor(slider: SliderPosition) {
this.slider = slider;
}
public draw(target: CanvasRenderingTarget2D): void {
const data = this.slider.getRenderData();
if (!data) {
return;
}
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio, bitmapSize }) => {
const left = data.leftX * horizontalPixelRatio;
const right = data.rightX * horizontalPixelRatio;
const entryY = data.entryY * verticalPixelRatio;
const stopY = data.stopY * verticalPixelRatio;
const targetY = data.targetY * verticalPixelRatio;
const profitTop = data.profitTop * verticalPixelRatio;
const profitBottom = data.profitBottom * verticalPixelRatio;
const lossTop = data.lossTop * verticalPixelRatio;
const lossBottom = data.lossBottom * verticalPixelRatio;
const centerX = (left + right) / 2;
const labelOffsetPx = UI.labelOffset * verticalPixelRatio;
const targetBoxHeight = getTextBoxHeight(data.targetText, data, UI.sideBoxHeight, verticalPixelRatio);
const centerBoxHeight = getTextBoxHeight(data.centerText, data, UI.mainBoxHeight, verticalPixelRatio);
const stopBoxHeight = getTextBoxHeight(data.stopText, data, UI.sideBoxHeight, verticalPixelRatio);
const targetLabelCenterY = getOuterLabelCenterY(
targetY,
data.targetLabelDirection,
targetBoxHeight,
labelOffsetPx,
);
const stopLabelCenterY = getOuterLabelCenterY(stopY, data.stopLabelDirection, stopBoxHeight, labelOffsetPx);
const centerLabelCenterY = getCenterLabelCenterY(
entryY,
targetY,
stopY,
centerBoxHeight,
labelOffsetPx,
bitmapSize.height,
);
context.save();
context.fillStyle = data.positiveFillColor;
context.fillRect(left, profitTop, right - left, profitBottom - profitTop);
context.fillStyle = data.negativeFillColor;
context.fillRect(left, lossTop, right - left, lossBottom - lossTop);
context.lineWidth = data.lineWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
context.strokeStyle = data.lineColor;
drawHorizontalLine(context, left, right, targetY);
drawHorizontalLine(context, left, right, entryY);
drawHorizontalLine(context, left, right, stopY);
if (data.showLabels) {
drawTextBox(
context,
centerX,
targetLabelCenterY,
data.targetText,
data.positiveFillColor,
data,
horizontalPixelRatio,
verticalPixelRatio,
UI.sideBoxHeight,
);
drawTextBox(
context,
centerX,
centerLabelCenterY,
data.centerText,
data.centerBoxColor,
data,
horizontalPixelRatio,
verticalPixelRatio,
UI.mainBoxHeight,
);
drawTextBox(
context,
centerX,
stopLabelCenterY,
data.stopText,
data.negativeFillColor,
data,
horizontalPixelRatio,
verticalPixelRatio,
UI.sideBoxHeight,
);
}
context.restore();
});
}
}
function getOuterLabelCenterY(
coordinate: number,
direction: 'up' | 'down',
boxHeight: number,
labelOffset: number,
): number {
const offset = labelOffset + boxHeight / 2;
return direction === 'up' ? coordinate - offset : coordinate + offset;
}
function getCenterLabelCenterY(
entryY: number,
targetY: number,
stopY: number,
boxHeight: number,
labelOffset: number,
viewportHeight: number,
): number {
const targetSpace = Math.abs(targetY - entryY);
const stopSpace = Math.abs(stopY - entryY);
let direction: -1 | 1;
if (targetSpace < stopSpace) {
direction = stopY < entryY ? -1 : 1;
} else {
direction = targetY < entryY ? -1 : 1;
}
return clampTextBoxCenter(entryY + direction * (labelOffset + boxHeight / 2), boxHeight, viewportHeight);
}
function clampTextBoxCenter(coordinate: number, boxHeight: number, viewportHeight: number): number {
if (viewportHeight <= boxHeight) {
return viewportHeight / 2;
}
const halfHeight = boxHeight / 2;
return Math.max(halfHeight, Math.min(coordinate, viewportHeight - halfHeight));
}
function drawHorizontalLine(context: CanvasRenderingContext2D, left: number, right: number, y: number): void {
context.beginPath();
context.moveTo(left, y);
context.lineTo(right, y);
context.stroke();
}
function drawTextBox(
context: CanvasRenderingContext2D,
centerX: number,
centerY: number,
text: string,
fillColor: string,
textStyle: SliderPositionStyle,
horizontalPixelRatio: number,
verticalPixelRatio: number,
fixedHeight: number,
): void {
context.save();
const lines = text.split('\n');
const fontSize = textStyle.fontSize * verticalPixelRatio;
const lineHeight = Math.round(textStyle.fontSize * UI.lineHeightMultiplier) * verticalPixelRatio;
const paddingX = UI.padding * horizontalPixelRatio;
const boxHeight = getTextBoxHeight(text, textStyle, fixedHeight, verticalPixelRatio);
const radius = UI.boxRadius * Math.max(horizontalPixelRatio, verticalPixelRatio);
context.font = getTextFont(textStyle, fontSize);
context.textAlign = 'center';
context.textBaseline = 'middle';
let maxTextWidth = 0;
for (const line of lines) {
maxTextWidth = Math.max(maxTextWidth, context.measureText(line).width);
}
const width = maxTextWidth + paddingX * 2;
const x = centerX - width / 2;
const y = centerY - boxHeight / 2;
context.fillStyle = fillColor;
context.beginPath();
drawRoundedRect(context, x, y, width, boxHeight, radius);
context.fill();
context.fillStyle = textStyle.textColor;
if (lines.length === 1) {
context.fillText(lines[0], centerX, centerY);
context.restore();
return;
}
const textBlockHeight = lines.length * lineHeight;
const firstLineCenterY = centerY - textBlockHeight / 2 + lineHeight / 2;
lines.forEach((line, index) => {
context.fillText(line, centerX, firstLineCenterY + index * lineHeight);
});
context.restore();
}
function getTextBoxHeight(
text: string,
textStyle: SliderPositionStyle,
fixedHeight: number,
verticalPixelRatio: number,
): number {
const linesCount = text.split('\n').length;
const lineHeight = Math.round(textStyle.fontSize * UI.lineHeightMultiplier) * verticalPixelRatio;
const paddingY = UI.padding * verticalPixelRatio;
const minBoxHeight = fixedHeight * verticalPixelRatio;
return Math.max(minBoxHeight, linesCount * lineHeight + paddingY * 2);
}
function getTextFont(_style: SliderPositionStyle, fontSize: number): string {
return `${fontSize}px Inter, sans-serif`;
}