Загрузка данных
import { Observable, skip } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
getPointerPoint as getPointerPointFromEvent,
getPriceDelta as getPriceDeltaFromCoordinates,
getPriceFromYCoordinate,
getPriceRangeInContainer,
getTimeFromXCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
isPointInBounds,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
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,
SliderPositionSettings,
SliderPositionStyle,
SliderPositionTextStyle,
} from './settings';
import type { ISeriesDrawing } from '@core/Drawings/common';
import type { AxisLabel, AxisSegment, Bounds, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
import type {
IChartApi,
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 TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'target' | 'entry' | 'stop';
interface SliderPositionParams {
side: SliderSide;
container: HTMLElement;
formatObservable?: Observable<ChartOptionsModel>;
resetTriggers?: Observable<unknown>[];
removeSelf?: () => void;
openSettings?: () => void;
}
interface SliderPositionState {
hidden: boolean;
active: 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 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, SliderPositionTextStyle {
targetText: string;
centerText: string;
stopText: string;
centerBoxColor: string;
showFill: boolean;
showHandles: boolean;
showLabels: boolean;
}
const HIT_TOLERANCE = 8;
const INITIAL_WIDTH_PX = 160;
const MIN_DISTANCE = 0.00000001;
export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> 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 didDrag = false;
private defaultRiskRewardRatio = 1;
private amount = 1000;
private tickSize = 1;
private ignoreNextChartClick = false;
private readonly clickHandler: MouseEventHandler<Time>;
private readonly paneView: SliderPaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly startTimeAxisView: CustomTimeAxisView;
private readonly endTimeAxisView: CustomTimeAxisView;
private readonly targetPriceAxisView: CustomPriceAxisView;
private readonly entryPriceAxisView: CustomPriceAxisView;
private readonly stopPriceAxisView: CustomPriceAxisView;
constructor(
chart: IChartApi,
series: SeriesApi,
{ side, container, formatObservable, resetTriggers = [], removeSelf, openSettings }: SliderPositionParams,
) {
super({ chart, series, container });
this.side = side;
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.clickHandler = (params) => this.handleChartClick(params);
this.paneView = new SliderPaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
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);
}
public isCreationPending(): boolean {
return this.mode === 'idle';
}
public getState(): SliderPositionState {
return {
hidden: this.hidden,
active: this.isActive.value,
mode: this.mode,
startTime: this.startTime,
endTime: this.endTime,
entryPrice: this.entryPrice,
stopPrice: this.stopPrice,
targetPrice: this.targetPrice,
riskRewardRatio: this.getCurrentRiskRewardRatio(),
amount: this.amount,
tickSize: this.tickSize,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
const next = state as Partial<SliderPositionState>;
this.hidden = next.hidden ?? this.hidden;
if (typeof next.active === 'boolean') {
this.isActive.next(next.active);
}
this.mode = next.mode ?? this.mode;
this.startTime = next.startTime ?? this.startTime;
this.endTime = next.endTime ?? this.endTime;
this.entryPrice = next.entryPrice ?? this.entryPrice;
this.stopPrice = next.stopPrice ?? this.stopPrice;
this.amount = next.amount ?? this.amount;
if (typeof next.tickSize === 'number' && next.tickSize > 0) {
this.tickSize = next.tickSize;
}
if (next.targetPrice !== undefined) {
this.targetPrice = 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);
this.targetPrice =
this.side === 'long'
? this.entryPrice + risk * next.riskRewardRatio
: this.entryPrice - risk * next.riskRewardRatio;
}
if ('settings' in next && next.settings) {
this.settings = {
...createDefaultSettings(),
...next.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getSliderPositionSettingsTabs(this.settings);
}
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 reward = Math.abs(geometry.targetPrice - geometry.entryPrice);
const qty = reward > 0 ? this.amount / reward : 0;
const selectedPrice = this.getPriceAtTime(this.endTime);
const pnl =
selectedPrice === null
? 0
: this.side === 'long'
? (selectedPrice - geometry.entryPrice) * qty
: (geometry.entryPrice - selectedPrice) * qty;
const { colors } = getThemeStore();
return {
...geometry,
targetText: this.getTargetText(geometry),
centerText: this.getCenterText(qty, pnl),
stopText: this.getStopText(geometry),
centerBoxColor: pnl >= 0 ? colors.chartCandleUp : colors.chartCandleDown,
showFill: true,
showHandles: this.shouldShowHandles(),
showLabels: this.isActive.value,
...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 '';
}
}
public getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
const point = { x, y };
if (!this.isActive.value) {
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.isActive.value) {
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.isActive.value) {
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.isActive.value || (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 (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.isActive.value) {
return;
}
const point = this.getLocalPoint(event as PointerEvent);
if (!this.containsPoint(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
private handleChartClick(params: MouseEventParams<Time>): void {
if (this.ignoreNextChartClick) {
this.ignoreNextChartClick = false;
return;
}
if (this.hidden || !params.point) {
return;
}
if (this.mode === 'idle') {
const anchor = this.createAnchor(params);
if (!anchor) {
return;
}
const distance = this.getInitialZoneDistance(anchor.price);
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, distance);
this.targetPrice = this.normalizeTarget(
anchor.price,
anchor.price + distance * targetDirection * this.defaultRiskRewardRatio,
distance,
);
this.isActive.next(true);
this.mode = 'ready';
this.resolveReady?.();
this.render();
return;
}
this.isActive.next(this.containsPoint({ x: params.point.x, y: params.point.y }));
this.render();
}
protected handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || this.mode !== 'ready' || !this.isActive.value) {
return;
}
const point = this.getLocalPoint(event);
const dragTarget = this.getHandleTarget(point);
if (!dragTarget) {
return;
}
event.preventDefault();
event.stopPropagation();
this.activeDragTarget = dragTarget;
this.dragPointerId = event.pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
this.didDrag = false;
this.mode = 'dragging';
};
protected handlePointerMove = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId || !this.dragStateSnapshot) {
return;
}
event.preventDefault();
this.didDrag = true;
this.applyDrag(this.getLocalPoint(event));
this.render();
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.ignoreNextChartClick = this.didDrag;
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.didDrag = false;
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 minDistance = this.getEditMinimumDistance();
const low = Math.min(snapshot.stopPrice, snapshot.targetPrice) + minDistance;
const high = Math.max(snapshot.stopPrice, snapshot.targetPrice) - minDistance;
if (low > high) {
return;
}
this.entryPrice = Math.max(low, Math.min(nextPrice, high));
}
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),
this.getEditMinimumDistance(),
);
}
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),
this.getEditMinimumDistance(),
);
}
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, minDistance = this.getEditMinimumDistance()): number {
const distance = this.getAllowedMinimumDistance(entryPrice, 'stop', minDistance);
return this.side === 'long' ? Math.min(rawPrice, entryPrice - distance) : Math.max(rawPrice, entryPrice + distance);
}
private normalizeTarget(entryPrice: number, rawPrice: number, minDistance = this.getEditMinimumDistance()): number {
const distance = this.getAllowedMinimumDistance(entryPrice, 'target', minDistance);
return this.side === 'long' ? Math.max(rawPrice, entryPrice + distance) : Math.min(rawPrice, entryPrice - distance);
}
private getAllowedMinimumDistance(entryPrice: number, kind: 'stop' | 'target', minDistance: number): number {
const range = this.getPriceScaleRange();
if (!range) {
return minDistance;
}
const availableDistance =
this.side === 'long'
? kind === 'stop'
? Math.max(entryPrice - range.min, 0)
: Math.max(range.max - entryPrice, 0)
: kind === 'stop'
? Math.max(range.max - entryPrice, 0)
: Math.max(entryPrice - range.min, 0);
return Math.min(minDistance, availableDistance);
}
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): number {
const fallback = Math.max(Math.abs(entryPrice) * 0.0075, this.tickSize * 3, MIN_DISTANCE);
const range = this.getPriceScaleRange();
if (!range) {
return fallback;
}
const size = range.max - range.min;
if (size <= 0) {
return fallback;
}
return Math.max(size * 0.05, this.tickSize * 3, MIN_DISTANCE);
}
private getEditMinimumDistance(): number {
return 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) {
return this.defaultRiskRewardRatio;
}
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 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 getTargetText(geometry: SliderGeometry): string {
const diff = Math.abs(geometry.targetPrice - geometry.entryPrice);
const percent = geometry.entryPrice !== 0 ? (diff / Math.abs(geometry.entryPrice)) * 100 : 0;
const ticks = this.tickSize > 0 ? diff / this.tickSize : 0;
const formattedDiff = formatPrice(diff) ?? '0';
const formattedTicks = formatPrice(ticks) ?? '0';
const formattedAmount = formatPrice(this.amount) ?? '0';
return `${t('Target')}: ${formattedDiff} (${formatPercent(percent)}) ${formattedTicks}, ${t('Amount')}: ${formattedAmount}`;
}
private getCenterText(qty: number, pnl: number): string {
const formattedQty = formatPrice(qty) ?? '0';
const formattedRatio = formatPrice(this.getCurrentRiskRewardRatio()) ?? '0';
return `${t('Open P&L')}: ${formatSignedNumber(pnl)}, ${t('Qty')}: ${formattedQty}\n${t('Risk/Reward Ratio')}: ${formattedRatio}`;
}
private getStopText(geometry: SliderGeometry): string {
const stopDiff = Math.abs(geometry.stopPrice - geometry.entryPrice);
const rewardDiff = Math.abs(geometry.targetPrice - geometry.entryPrice);
const percent = geometry.entryPrice !== 0 ? (stopDiff / Math.abs(geometry.entryPrice)) * 100 : 0;
const ticks = this.tickSize > 0 ? stopDiff / this.tickSize : 0;
const qty = rewardDiff > 0 ? this.amount / rewardDiff : 0;
const stopAmount = stopDiff * qty;
const formattedStopDiff = formatPrice(stopDiff) ?? '0';
const formattedTicks = formatPrice(ticks) ?? '0';
const formattedStopAmount = formatPrice(stopAmount) ?? '0';
return `${t('Stop')}: ${formattedStopDiff} (${formatPercent(percent)}) ${formattedTicks}, ${t('Amount')}: ${formattedStopAmount}`;
}
private getHandleTarget(point: Point): DragTarget {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
if (isNearPoint(point, geometry.startX, geometry.entryY, HIT_TOLERANCE)) {
return 'entry';
}
if (isNearPoint(point, geometry.startX, geometry.targetY, HIT_TOLERANCE)) {
return 'target';
}
if (isNearPoint(point, geometry.startX, geometry.stopY, HIT_TOLERANCE)) {
return 'stop';
}
if (isNearPoint(point, geometry.endX, geometry.entryY, HIT_TOLERANCE)) {
return 'end';
}
const minY = Math.min(geometry.targetY, geometry.stopY);
const maxY = Math.max(geometry.targetY, geometry.stopY);
const bounds: Bounds = {
left: geometry.leftX,
right: geometry.rightX,
top: minY,
bottom: maxY,
};
if (isPointInBounds(point, bounds)) {
return 'body';
}
return null;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
const minY = Math.min(geometry.targetY, geometry.stopY);
const maxY = Math.max(geometry.targetY, geometry.stopY);
const bounds: Bounds = {
left: geometry.leftX,
right: geometry.rightX,
top: minY,
bottom: maxY,
};
return isPointInBounds(point, bounds, HIT_TOLERANCE);
}
private getLocalPoint(event: PointerEvent): Point {
return getPointerPointFromEvent(this.container, event);
}
}
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { SettingField, SettingsTab, SettingsValues } from '@src/types';
export interface SliderPositionStyle {
lineColor: string;
positiveFillColor: string;
negativeFillColor: string;
}
export interface SliderPositionTextStyle {
textColor: string;
fontSize: number;
isBold: boolean;
isItalic: boolean;
}
export type SliderPositionSettings = SettingsValues & SliderPositionStyle & SliderPositionTextStyle;
export function createDefaultSettings(): SliderPositionSettings {
const { colors } = getThemeStore();
return {
lineColor: colors.chartCrosshairLine,
positiveFillColor: colors.sliderPositiveFill,
negativeFillColor: colors.sliderNegativeFill,
textColor: colors.chartPriceLineText,
fontSize: 10,
isBold: false,
isItalic: false,
};
}
export function getSliderPositionSettingsTabs(settings: SliderPositionSettings): SettingsTab[] {
const styleFields: SettingField[] = [
{
key: 'lineColor',
label: t('Line color'),
type: 'color',
defaultValue: settings.lineColor,
},
{
key: 'positiveFillColor',
label: t('Profit zone color'),
type: 'color',
defaultValue: settings.positiveFillColor,
toolbar: {
control: 'color',
role: 'fill',
},
},
{
key: 'negativeFillColor',
label: t('Loss zone color'),
type: 'color',
defaultValue: settings.negativeFillColor,
toolbar: {
control: 'color',
role: 'fill',
},
},
];
const textFields: SettingField[] = [
{
key: 'fontSize',
label: t('Font size'),
type: 'number',
defaultValue: settings.fontSize,
min: 8,
max: 24,
},
{
key: 'isBold',
label: t('Bold'),
type: 'boolean',
defaultValue: settings.isBold,
},
{
key: 'isItalic',
label: t('Italics'),
type: 'boolean',
defaultValue: settings.isItalic,
},
{
key: 'textColor',
label: t('Text color'),
type: 'color',
defaultValue: settings.textColor,
toolbar: {
control: 'color',
role: 'text',
},
},
];
return [
{
key: 'style',
label: t('Style'),
fields: styleFields,
},
{
key: 'text',
label: t('Text'),
fields: textFields,
},
];
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { getThemeStore } from '@src/theme';
import type { SliderPositionTextStyle } from './settings';
import type { SliderPosition } from './sliderPosition';
const UI = {
lineWidth: 1,
lineHeightMultiplier: 1.2,
mainBoxHeight: 28,
sideBoxHeight: 16,
padding: 4,
boxRadius: 4,
labelOffset: 10,
handleSize: 10,
handleRadius: 3,
handleBorderWidth: 1,
};
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 }) => {
const startX = data.startX * horizontalPixelRatio;
const endX = data.endX * horizontalPixelRatio;
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 sideBoxHeightPx = UI.sideBoxHeight * verticalPixelRatio;
const mainBoxHeightPx = UI.mainBoxHeight * verticalPixelRatio;
const labelOffsetPx = UI.labelOffset * verticalPixelRatio;
const targetLabelCenterY =
targetY < entryY
? targetY - labelOffsetPx - sideBoxHeightPx / 2
: targetY + labelOffsetPx + sideBoxHeightPx / 2;
const stopLabelCenterY =
stopY < entryY ? stopY - labelOffsetPx - sideBoxHeightPx / 2 : stopY + labelOffsetPx + sideBoxHeightPx / 2;
context.save();
if (data.showFill) {
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 = UI.lineWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
context.strokeStyle = data.lineColor;
drawHorizontalLine(context, left, right, entryY);
if (data.showHandles) {
drawHandle(context, startX, entryY, horizontalPixelRatio, verticalPixelRatio, 'circle');
drawHandle(context, endX, entryY, horizontalPixelRatio, verticalPixelRatio, 'rounded');
drawHandle(context, startX, targetY, horizontalPixelRatio, verticalPixelRatio, 'rounded');
drawHandle(context, startX, stopY, horizontalPixelRatio, verticalPixelRatio, 'rounded');
}
if (data.showLabels) {
drawTextBox(
context,
centerX,
targetLabelCenterY,
data.targetText,
data.positiveFillColor,
data,
horizontalPixelRatio,
verticalPixelRatio,
UI.sideBoxHeight,
);
drawTextBox(
context,
centerX,
entryY + labelOffsetPx + mainBoxHeightPx / 2,
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 drawHorizontalLine(context: CanvasRenderingContext2D, left: number, right: number, y: number): void {
context.beginPath();
context.moveTo(left, y);
context.lineTo(right, y);
context.stroke();
}
function drawHandle(
context: CanvasRenderingContext2D,
x: number,
y: number,
horizontalPixelRatio: number,
verticalPixelRatio: number,
shape: 'circle' | 'rounded',
): void {
const width = UI.handleSize * horizontalPixelRatio;
const height = UI.handleSize * verticalPixelRatio;
const left = x - width / 2;
const top = y - height / 2;
const { colors } = getThemeStore();
context.save();
context.fillStyle = colors.chartBackground;
context.strokeStyle = colors.chartLineColor;
context.lineWidth = UI.handleBorderWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
context.beginPath();
if (shape === 'circle') {
context.arc(x, y, Math.min(width, height) / 2, 0, Math.PI * 2);
} else {
drawRoundedRect(
context,
left,
top,
width,
height,
UI.handleRadius * Math.max(horizontalPixelRatio, verticalPixelRatio),
);
}
context.fill();
context.stroke();
context.restore();
}
function drawTextBox(
context: CanvasRenderingContext2D,
centerX: number,
centerY: number,
text: string,
fillColor: string,
textStyle: SliderPositionTextStyle,
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 paddingY = UI.padding * verticalPixelRatio;
const minBoxHeight = fixedHeight * verticalPixelRatio;
const boxHeight = Math.max(minBoxHeight, lines.length * lineHeight + paddingY * 2);
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 getTextFont(style: SliderPositionTextStyle, fontSize: number): string {
const italic = style.isItalic ? 'italic ' : '';
const bold = style.isBold ? '700 ' : '';
return `${italic}${bold}${fontSize}px Inter, sans-serif`;
}
function drawRoundedRect(
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
): void {
const safeRadius = Math.min(radius, width / 2, height / 2);
context.moveTo(x + safeRadius, y);
context.arcTo(x + width, y, x + width, y + height, safeRadius);
context.arcTo(x + width, y + height, x, y + height, safeRadius);
context.arcTo(x, y + height, x, y, safeRadius);
context.arcTo(x, y, x + width, y, safeRadius);
context.closePath();
}
import { IChartApi, IPrimitivePaneView, PrimitiveHoveredItem } from 'lightweight-charts';
import { Observable } from 'rxjs';
import { CustomPriceAxisPaneView, CustomTimeAxisPaneView } from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
clamp,
clampPointToContainer as clampPointToContainerInElement,
getAnchorFromPoint,
getContainerSize as getElementContainerSize,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
} from '@core/Drawings/helpers';
import { AxisLabel } from '@core/Drawings/types';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { TraectoryPaneView } from './paneView';
import { createDefaultSettings, getTraectorySettingsTabs, TraectorySettings, TraectoryStyle } from './settings';
import type { ISeriesDrawing } from '@core/Drawings/common';
import type { Anchor, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
type TraectoryMode = 'idle' | 'drawing' | 'ready' | 'dragging-point' | 'dragging-body';
interface TraectoryParams {
container: HTMLElement;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
openSettings?: () => void;
}
export interface TraectoryState {
hidden: boolean;
isActive: boolean;
mode: TraectoryMode;
points: Anchor[];
settings: TraectorySettings;
}
interface TraectoryGeometry {
points: Point[];
left: number;
right: number;
top: number;
bottom: number;
}
export interface TraectoryRenderData extends TraectoryGeometry, TraectoryStyle {
previewPoint: Point | null;
showHandles: boolean;
showArrow: boolean;
}
const POINT_HIT_TOLERANCE = 8;
const SEGMENT_HIT_TOLERANCE = 6;
const MIN_POINTS_COUNT = 2;
export class Traectory extends SeriesDrawingBase<TraectorySettings> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: TraectorySettings = createDefaultSettings();
protected mode: TraectoryMode = 'idle';
private points: Anchor[] = [];
private previewAnchor: Anchor | null = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragPointIndex: number | null = null;
private dragGeometrySnapshot: TraectoryGeometry | null = null;
private readonly paneView: TraectoryPaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
constructor(chart: IChartApi, series: SeriesApi, params: TraectoryParams) {
super({ chart, series, container: params.container });
const { removeSelf, openSettings } = params;
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.paneView = new TraectoryPaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.series.attachPrimitive(this);
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public getState(): TraectoryState {
return {
hidden: this.hidden,
isActive: this.isActive.value,
mode: this.mode,
points: this.points,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<TraectoryState>;
this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
if (typeof nextState.isActive === 'boolean') {
this.isActive.next(nextState.isActive);
}
this.mode = nextState.mode ?? this.mode;
this.points = Array.isArray(nextState.points) ? nextState.points : this.points;
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getTraectorySettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([this.paneView, this.timeAxisPaneView, this.priceAxisPaneView]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.priceAxisPaneView];
}
public timeAxisViews() {
return [];
}
public priceAxisViews() {
return [];
}
public getRenderData(): TraectoryRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
previewPoint: this.getPreviewPoint(),
showHandles: this.mode === 'drawing' || this.shouldShowHandles(),
showArrow: this.mode !== 'drawing' && geometry.points.length > 1,
...this.settings,
};
}
public getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
}
const point = { x, y };
const pointIndex = this.getPointIndexAt(point);
if (pointIndex !== null) {
return {
cursorStyle: 'move',
externalId: 'traectory',
zOrder: 'top',
};
}
if (!this.isPointNearTraectory(point)) {
return null;
}
return {
cursorStyle: 'move',
externalId: 'traectory',
zOrder: 'top',
};
}
protected getTimeAxisSegments(): AxisSegment[] {
if (!this.isActive.value) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.left,
to: geometry.right,
color: colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (!this.isActive.value) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.top,
to: geometry.bottom,
color: colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
return null;
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
return null;
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden) {
return;
}
if (this.mode === 'drawing') {
event.preventDefault();
event.stopPropagation();
const point = this.getEventPoint(event as PointerEvent);
this.appendPoint(point);
this.finishDrawing();
return;
}
if (this.mode !== 'ready' || !this.isActive.value) {
return;
}
const point = this.getEventPoint(event as PointerEvent);
if (this.getPointIndexAt(point) === null && !this.isPointNearTraectory(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
protected handleContextMenu = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'drawing') {
return;
}
event.preventDefault();
event.stopPropagation();
this.finishDrawing();
};
protected handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.mode === 'idle') {
event.preventDefault();
event.stopPropagation();
this.startDrawing(point);
return;
}
if (this.mode === 'drawing') {
event.preventDefault();
event.stopPropagation();
if (event.detail > 1) {
return;
}
this.appendPoint(point);
return;
}
if (this.mode !== 'ready') {
return;
}
const pointIndex = this.getPointIndexAt(point);
const isInsideTraectory = pointIndex !== null || this.isPointNearTraectory(point);
if (!this.isActive.value) {
if (!isInsideTraectory) {
return;
}
event.preventDefault();
event.stopPropagation();
this.isActive.next(true);
this.render();
return;
}
if (pointIndex !== null) {
event.preventDefault();
event.stopPropagation();
this.startDraggingPoint(point, event.pointerId, pointIndex);
return;
}
if (!this.isPointNearTraectory(point)) {
this.isActive.next(false);
this.render();
return;
}
event.preventDefault();
event.stopPropagation();
this.startDraggingBody(point, event.pointerId);
};
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing') {
this.updatePreview(point);
return;
}
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging-point') {
event.preventDefault();
this.movePoint(point);
this.render();
return;
}
if (this.mode === 'dragging-body') {
event.preventDefault();
this.moveBody(point);
this.render();
}
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging-point' || this.mode === 'dragging-body') {
this.finishDragging();
}
};
private startDrawing(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.points = [anchor];
this.previewAnchor = anchor;
this.isActive.next(true);
this.mode = 'drawing';
this.render();
}
private appendPoint(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
const lastPoint = this.points[this.points.length - 1];
if (lastPoint && Number(lastPoint.time) === Number(anchor.time) && lastPoint.price === anchor.price) {
this.previewAnchor = anchor;
this.render();
return;
}
this.points = [...this.points, anchor];
this.previewAnchor = anchor;
this.render();
}
private updatePreview(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.previewAnchor = anchor;
this.render();
}
private finishDrawing(): void {
if (this.points.length < MIN_POINTS_COUNT) {
if (this.removeSelf) {
this.removeSelf();
return;
}
this.resetToIdle();
return;
}
this.previewAnchor = null;
this.isActive.next(true);
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDraggingPoint(point: Point, pointerId: number, pointIndex: number): void {
this.mode = 'dragging-point';
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragPointIndex = pointIndex;
this.dragGeometrySnapshot = this.getGeometry();
this.hideCrosshair();
this.render();
}
private startDraggingBody(point: Point, pointerId: number): void {
this.mode = 'dragging-body';
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragPointIndex = null;
this.dragGeometrySnapshot = this.getGeometry();
this.hideCrosshair();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.resolveReady?.();
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragPointIndex = null;
this.dragGeometrySnapshot = null;
this.showCrosshair();
this.render();
}
private resetToIdle(): void {
this.isActive.next(false);
this.mode = 'idle';
this.points = [];
this.previewAnchor = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragPointIndex = null;
this.dragGeometrySnapshot = null;
this.showCrosshair();
this.render();
}
private movePoint(point: Point): void {
const geometry = this.dragGeometrySnapshot;
const pointIndex = this.dragPointIndex;
if (!geometry || pointIndex === null) {
return;
}
const nextPoint = this.clampPointToContainer(point);
const nextPoints = [...geometry.points];
nextPoints[pointIndex] = nextPoint;
this.setAnchorsFromPoints(nextPoints);
}
private moveBody(point: Point): void {
const geometry = this.dragGeometrySnapshot;
const { dragStartPoint } = this;
if (!geometry || !dragStartPoint) {
return;
}
const { width, height } = this.getContainerSize();
const rawOffsetX = point.x - dragStartPoint.x;
const rawOffsetY = point.y - dragStartPoint.y;
const offsetX = clamp(rawOffsetX, -geometry.left, width - geometry.right);
const offsetY = clamp(rawOffsetY, -geometry.top, height - geometry.bottom);
const nextPoints = geometry.points.map((item) =>
this.clampPointToContainer({
x: item.x + offsetX,
y: item.y + offsetY,
}),
);
this.setAnchorsFromPoints(nextPoints);
}
private setAnchorsFromPoints(points: Point[]): void {
const nextAnchors: Anchor[] = [];
for (const point of points) {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
nextAnchors.push(anchor);
}
this.points = nextAnchors;
}
private createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getGeometry(): TraectoryGeometry | null {
if (!this.points.length) {
return null;
}
const { width, height } = this.getContainerSize();
const screenPoints: Point[] = [];
for (const anchor of this.points) {
const x = getXCoordinateFromTime(this.chart, anchor.time, this.series);
const y = getYCoordinateFromPrice(this.series, anchor.price);
if (x === null || y === null) {
return null;
}
screenPoints.push({
x: clamp(Math.round(Number(x)), 0, width),
y: clamp(Math.round(Number(y)), 0, height),
});
}
const left = Math.round(Math.min(...screenPoints.map((point) => point.x)));
const right = Math.round(Math.max(...screenPoints.map((point) => point.x)));
const top = Math.round(Math.min(...screenPoints.map((point) => point.y)));
const bottom = Math.round(Math.max(...screenPoints.map((point) => point.y)));
return {
points: screenPoints,
left,
right,
top,
bottom,
};
}
private getPreviewPoint(): Point | null {
if (this.mode !== 'drawing' || !this.previewAnchor) {
return null;
}
const x = getXCoordinateFromTime(this.chart, this.previewAnchor.time, this.series);
const y = getYCoordinateFromPrice(this.series, this.previewAnchor.price);
if (x === null || y === null) {
return null;
}
const { width, height } = this.getContainerSize();
return {
x: clamp(Math.round(Number(x)), 0, width),
y: clamp(Math.round(Number(y)), 0, height),
};
}
private getPointIndexAt(point: Point): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
const index = geometry.points.findIndex((item) => isNearPoint(point, item.x, item.y, POINT_HIT_TOLERANCE));
return index >= 0 ? index : null;
}
private isPointNearTraectory(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
if (geometry.points.length === 1) {
const firstPoint = geometry.points[0];
return isNearPoint(point, firstPoint.x, firstPoint.y, POINT_HIT_TOLERANCE);
}
for (let index = 0; index < geometry.points.length - 1; index += 1) {
const startPoint = geometry.points[index];
const endPoint = geometry.points[index + 1];
if (this.getDistanceToSegment(point, startPoint, endPoint) <= SEGMENT_HIT_TOLERANCE) {
return true;
}
}
return false;
}
private getDistanceToSegment(point: Point, startPoint: Point, endPoint: Point): number {
const dx = endPoint.x - startPoint.x;
const dy = endPoint.y - startPoint.y;
if (dx === 0 && dy === 0) {
return Math.hypot(point.x - startPoint.x, point.y - startPoint.y);
}
const t = Math.max(
0,
Math.min(1, ((point.x - startPoint.x) * dx + (point.y - startPoint.y) * dy) / (dx * dx + dy * dy)),
);
const projectionX = startPoint.x + t * dx;
const projectionY = startPoint.y + t * dy;
return Math.hypot(point.x - projectionX, point.y - projectionY);
}
private getContainerSize(): { width: number; height: number } {
return getElementContainerSize(this.container);
}
private clampPointToContainer(point: Point): Point {
return clampPointToContainerInElement(point, this.container);
}
}
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { SettingField, SettingsTab, SettingsValues } from '@src/types';
export interface TraectoryStyle {
lineColor: string;
}
export type TraectorySettings = SettingsValues & TraectoryStyle;
export function createDefaultSettings(): TraectorySettings {
const { colors } = getThemeStore();
return {
lineColor: colors.chartLineColor,
};
}
export function getTraectorySettingsTabs(settings: TraectorySettings): SettingsTab[] {
const fields: SettingField[] = [
{
key: 'lineColor',
label: t('Line color'),
type: 'color',
defaultValue: settings.lineColor,
toolbar: {
control: 'color',
role: 'line',
},
},
];
return [{ key: 'style', label: t('Style'), fields }];
}
import { IChartApi, ISeriesApi, SeriesType } from 'lightweight-charts';
import { Subscription } from 'rxjs';
import { DOMObject, DOMObjectParams } from '@core/DOMObject';
import { ISeriesDrawing } from '@core/Drawings/common';
import { DrawingSnapshotItem } from '@core/DrawingsManager';
import { Hotkeys, Keys } from '@core/Hotkeys';
import { DrawingsNames } from '@src/constants';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { SettingsTab, SettingsValues, ToolbarSettingField } from '@src/types/settings';
type IDrawing = DOMObject;
interface DrawingParams extends DOMObjectParams {
drawingName: DrawingsNames;
lwcChart: IChartApi;
mainSeries: SeriesStrategies;
onDelete: (id: string) => void;
construct: (chart: IChartApi, series: ISeriesApi<SeriesType>) => ISeriesDrawing;
hotkeys: Hotkeys;
setCopyPasteBuffer: (copiedObject: DrawingSnapshotItem) => void;
resetActiveTool: () => void;
}
export class Drawing extends DOMObject implements IDrawing {
private lwcDrawing: ISeriesDrawing;
private mainSeries: SeriesStrategies;
private drawingName: DrawingsNames;
private hotkeys: Hotkeys;
private escapeUnregisterHash: string | null = null;
private deleteUnregisterHash: string | null = null;
private copyUnregisterHash: string | null = null;
constructor({
lwcChart,
name,
mainSeries,
drawingName,
id,
onDelete,
zIndex,
moveUp,
moveDown,
construct,
paneId,
hotkeys,
setCopyPasteBuffer,
resetActiveTool,
}: DrawingParams) {
super({ id, name, zIndex, onDelete, moveUp, moveDown, paneId });
this.hotkeys = hotkeys;
this.lwcDrawing = construct(lwcChart, mainSeries);
this.onDelete = onDelete;
this.escapeUnregisterHash = hotkeys.register({
keys: [Keys.escape],
callback: () => {
this.delete();
resetActiveTool();
},
});
this.lwcDrawing.subscribeIsSelected((isSelected) => {
if (isSelected) {
this.deleteUnregisterHash = hotkeys.register({
keys: [Keys.delete],
callback: () => {
this.delete();
},
});
this.copyUnregisterHash = hotkeys.register({
keys: [Keys.control, Keys.c],
callback: () => {
if (!this.isCreationPending()) {
const copiedObject = {
id: this.id,
drawingName: this.getDrawingName(),
state: this.getState(),
isLocked: this.isLocked(),
};
setCopyPasteBuffer(copiedObject);
}
},
});
} else {
hotkeys.unregister({
keys: [Keys.delete],
hash: this.deleteUnregisterHash,
});
hotkeys.unregister({
keys: [Keys.control, Keys.c],
hash: this.copyUnregisterHash,
});
}
});
this.mainSeries = mainSeries;
this.drawingName = drawingName;
this.afterCreation(() => {
hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
});
}
public delete(): void {
this.destroy();
super.delete();
}
public getDrawingName(): DrawingsNames {
return this.drawingName;
}
public getLwcDrawing(): ISeriesDrawing {
return this.lwcDrawing;
}
public show(): void {
this.lwcDrawing.show();
super.show();
}
public hide(): void {
this.lwcDrawing.hide();
super.hide();
}
public rebind = (nextMainSeries: SeriesStrategies): void => {
this.lwcDrawing.rebind(nextMainSeries);
this.mainSeries = nextMainSeries;
};
public isCreationPending(): boolean {
return this.lwcDrawing.isCreationPending();
}
public subscribeIsLocked(callback: (isLocked: boolean) => void): Subscription {
return this.lwcDrawing.subscribeIsLocked(callback);
}
public subscribeSettings(callback: (settings: SettingsValues) => void): Subscription {
return this.lwcDrawing.subscribeSettings(callback);
}
public isLocked(): boolean {
return this.lwcDrawing.isLocked();
}
public isSelected(): boolean {
return this.lwcDrawing.isSelected();
}
public setLocked(isLocked: boolean): void {
this.lwcDrawing.setLocked(isLocked);
}
public async waitForCreation(): Promise<void> {
return this.lwcDrawing.waitTillReady();
}
public shouldShowInObjectTree(): boolean {
return this.lwcDrawing.shouldShowInObjectTree();
}
public getState(): unknown {
return this.lwcDrawing.getState();
}
public setState(state: unknown): void {
this.lwcDrawing.setState(state);
}
public getSettings(): SettingsValues {
return this.lwcDrawing.getSettings();
}
public updateSettings(settings: SettingsValues): void {
this.lwcDrawing.updateSettings(settings);
}
public getSettingsTabs(): SettingsTab[] {
return this.lwcDrawing.getSettingsTabs();
}
public getToolbarSettings(): ToolbarSettingField[] {
return this.getSettingsTabs()
.flatMap((tab) => tab.fields)
.filter((field): field is ToolbarSettingField => field.toolbar !== undefined);
}
public hasSettings(): boolean {
return this.getSettingsTabs().some((tab) => tab.fields.length > 0);
}
public destroy(): void {
this.hotkeys.unregister({
keys: [Keys.delete],
hash: this.deleteUnregisterHash,
});
this.hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
this.hotkeys.unregister({
keys: [Keys.control, Keys.c],
hash: this.copyUnregisterHash,
});
this.mainSeries.detachPrimitive(this.lwcDrawing);
this.lwcDrawing.destroy();
}
private async afterCreation(callback: () => void): Promise<void> {
await this.lwcDrawing.waitTillReady();
callback();
}
}
import {
AutoscaleInfo,
CrosshairMode,
IChartApi,
IPrimitivePaneView,
ISeriesApi,
ISeriesPrimitive,
ISeriesPrimitiveAxisView,
Logical,
PrimitiveHoveredItem,
SeriesAttachedParameter,
SeriesOptionsMap,
SeriesType,
Time,
} from 'lightweight-charts';
import { BehaviorSubject, distinctUntilChanged, Subject, Subscription } from 'rxjs';
import { getPointerPoint as getPointerPointFromEvent } from '@core/Drawings/helpers';
import { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import { SettingsTab, SettingsValues } from '@src/types';
export interface ISeriesDrawing extends ISeriesPrimitive<Time> {
show(): void;
hide(): void;
rebind(series: ISeriesApi<SeriesType>): void;
destroy(): void;
isCreationPending(): boolean;
waitTillReady(): Promise<void>;
shouldShowInObjectTree(): boolean;
getState(): unknown;
setState(state: unknown): void;
getSettings(): SettingsValues;
updateSettings(settings: SettingsValues): void;
getSettingsTabs(): SettingsTab[];
subscribeSettings(callback: (settings: SettingsValues) => void): Subscription;
subscribeIsSelected(callback: (isSelected: boolean) => void): Subscription;
subscribeIsLocked(callback: (isLocked: boolean) => void): Subscription;
isSelected(): boolean;
isLocked(): boolean;
setLocked(isLocked: boolean): void;
getRenderData(): unknown;
}
interface SeriesDrawingBaseParams {
container: HTMLElement;
chart: IChartApi;
series: SeriesApi;
}
export abstract class SeriesDrawingBase<TSettings extends SettingsValues = SettingsValues> implements ISeriesDrawing {
protected hidden = false;
protected chart: IChartApi;
protected series: SeriesApi;
protected subscriptions = new Subscription();
protected abstract mode: unknown;
protected abstract settings: TSettings;
protected readonly container: HTMLElement;
protected isActive = new BehaviorSubject(false);
protected isBound = false;
private readonly isLockedSubject = new BehaviorSubject(false);
private readonly settingsSubject = new Subject<SettingsValues>();
protected readyPromise: Promise<void> | null = null;
protected resolveReady: (() => void) | null = null;
protected requestUpdate: (() => void) | null = null;
constructor({ chart, series, container }: SeriesDrawingBaseParams) {
this.chart = chart;
this.series = series;
this.container = container;
}
public subscribeIsSelected(callback: (isSelected: boolean) => void): Subscription {
return this.isActive.pipe(distinctUntilChanged()).subscribe(callback);
}
public subscribeIsLocked(callback: (isLocked: boolean) => void): Subscription {
return this.isLockedSubject.pipe(distinctUntilChanged()).subscribe(callback);
}
public subscribeSettings(callback: (settings: SettingsValues) => void): Subscription {
callback(this.getSettings());
return this.settingsSubject.subscribe(callback);
}
public isSelected(): boolean {
return this.isActive.value;
}
public isLocked(): boolean {
return this.isLockedSubject.value;
}
public setLocked(isLocked: boolean): void {
if (this.isLockedSubject.value === isLocked) {
return;
}
this.isLockedSubject.next(isLocked);
if (isLocked) {
this.showCrosshair();
}
this.render();
}
public show(): void {
this.hidden = false;
this.render();
}
public hide(): void {
this.hidden = true;
this.showCrosshair();
this.render();
}
public rebind(series: SeriesApi): void {
if (this.series === series) {
return;
}
this.showCrosshair();
this.unbindEvents();
this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.series = series;
this.requestUpdate = null;
this.series.attachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.render();
}
public destroy(): void {
this.showCrosshair();
this.unbindEvents();
this.subscriptions.unsubscribe();
this.isLockedSubject.complete();
this.settingsSubject.complete();
this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.requestUpdate = null;
this.resolveReady?.();
}
public waitTillReady(): Promise<void> {
if (this.mode === 'ready') {
return Promise.resolve();
}
if (!this.readyPromise) {
this.readyPromise = new Promise((resolve) => {
this.resolveReady = resolve;
});
}
return this.readyPromise;
}
public shouldShowInObjectTree(): boolean {
return this.mode !== 'idle';
}
public getSettings(): SettingsValues {
return { ...this.settings };
}
public updateSettings(settings: SettingsValues): void {
this.settings = {
...this.settings,
...settings,
};
this.settingsSubject.next(this.getSettings());
this.render();
}
public attached(param: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
this.requestUpdate = param.requestUpdate;
this.bindEvents();
}
public detached(): void {
this.showCrosshair();
this.unbindEvents();
this.requestUpdate = null;
}
public autoscaleInfo(_start: Logical, _end: Logical): AutoscaleInfo | null {
return null;
}
public hitTest(x: number, y: number): PrimitiveHoveredItem | null {
const hoveredItem = this.getHoveredItem(x, y);
if (!hoveredItem || !this.isLocked()) {
return hoveredItem;
}
return {
...hoveredItem,
cursorStyle: 'pointer',
};
}
public abstract getRenderData(): unknown;
public abstract getState(): unknown;
public abstract getSettingsTabs(): SettingsTab[];
public abstract isCreationPending(): boolean;
public abstract setState(state: unknown): void;
public abstract updateAllViews(): void;
public abstract paneViews(): readonly IPrimitivePaneView[];
public abstract priceAxisPaneViews(): readonly IPrimitivePaneView[];
public abstract priceAxisViews(): readonly ISeriesPrimitiveAxisView[];
public abstract timeAxisPaneViews(): readonly IPrimitivePaneView[];
public abstract timeAxisViews(): readonly ISeriesPrimitiveAxisView[];
protected shouldShowHandles(): boolean {
return this.isActive.value && !this.isLocked();
}
protected render(): void {
this.updateAllViews();
this.requestUpdate?.();
}
protected hideCrosshair(): void {
this.chart.applyOptions({
crosshair: {
mode: CrosshairMode.Hidden,
},
});
}
protected showCrosshair(): void {
this.chart.applyOptions({
crosshair: {
mode: CrosshairMode.Normal,
},
});
}
protected getEventPoint(event: PointerEvent): Point {
return getPointerPointFromEvent(this.container, event);
}
protected bindEvents(): void {
if (this.isBound) {
return;
}
this.isBound = true;
this.container.addEventListener('dblclick', this.handleDoubleClick);
this.container.addEventListener('pointerdown', this.handlePointerDownEvent);
this.container.addEventListener('contextmenu', this.handleContextMenu);
window.addEventListener('pointermove', this.handlePointerMove);
window.addEventListener('pointerup', this.handlePointerUp);
window.addEventListener('pointercancel', this.handlePointerUp);
}
protected unbindEvents(): void {
if (!this.isBound) {
return;
}
this.isBound = false;
this.container.removeEventListener('dblclick', this.handleDoubleClick);
this.container.removeEventListener('pointerdown', this.handlePointerDownEvent);
this.container.removeEventListener('contextmenu', this.handleContextMenu);
window.removeEventListener('pointermove', this.handlePointerMove);
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointercancel', this.handlePointerUp);
}
protected handleContextMenu(event: MouseEvent): void {}
protected handleDoubleClick(event: MouseEvent): void {}
protected handlePointerDown(event: PointerEvent): void {}
protected handlePointerMove(event: PointerEvent): void {}
protected handlePointerUp(event: PointerEvent): void {}
protected abstract getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null;
protected abstract getGeometry(): unknown;
protected abstract getTimeAxisSegments(): AxisSegment[];
protected abstract getPriceAxisSegments(): AxisSegment[];
protected abstract getTimeAxisLabel(kind: string): AxisLabel | null;
protected abstract getPriceAxisLabel(kind: string): AxisLabel | null;
private handlePointerDownEvent = (event: PointerEvent): void => {
if (!this.isLocked() || this.isCreationPending() || event.button !== 0) {
this.handlePointerDown(event);
return;
}
const point = this.getEventPoint(event);
const isDrawingHit = this.getHoveredItem(point.x, point.y) !== null;
if (this.isActive.value === isDrawingHit) {
return;
}
this.isActive.next(isDrawingHit);
this.render();
};
}