Загрузка данных


import { IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';

import { CustomPriceAxisView, CustomTimeAxisView } from '@core/Drawings/axis';
import { DrawingBase } from '@core/Drawings/DrawingBase';
import {
  getPriceFromYCoordinate,
  getTimeFromXCoordinate,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';

import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';

import { AxisLinePaneView } from './paneView';
import {
  AxisLineSettings,
  AxisLineStyle,
  AxisLineTextStyle,
  createDefaultSettings,
  getAxisLineSettingsTabs,
} from './settings';

import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type { ChartOptionsModel, SettingsTab } from '@src/types';

export type AxisLineDirection = 'vertical' | 'horizontal';

type AxisLineMode = 'idle' | 'ready' | 'dragging';
type AxisLineHandleKey = 'main';

interface AxisLineParams extends BaseDrawingParams {
  direction: AxisLineDirection;
}

interface AxisLineState {
  hidden: boolean;
  mode: AxisLineMode;
  time: Time | null;
  price: number | null;
  settings: AxisLineSettings;
}

export interface AxisLineRenderData extends AxisLineStyle, AxisLineTextStyle {
  direction: AxisLineDirection;
  coordinate: number;
}

const LINE_HIT_TOLERANCE = 6;

export class AxisLine
  extends DrawingBase<AxisLineSettings, AxisLineHandleKey>
  implements ISeriesDrawing
{
  private readonly openSettings?: () => void;
  private readonly direction: AxisLineDirection;

  protected settings: AxisLineSettings = createDefaultSettings();
  protected mode: AxisLineMode = 'idle';

  private time: Time | null = null;
  private price: number | null = null;
  private dragPointerId: number | null = null;

  private displayFormat: ChartOptionsModel = {
    dateFormat: Defaults.dateFormat,
    timeFormat: Defaults.timeFormat,
    showTime: Defaults.showTime,
  };

  private readonly paneView: AxisLinePaneView;
  private readonly timeAxisView: CustomTimeAxisView;
  private readonly priceAxisView: CustomPriceAxisView;

  constructor({
    chart,
    series,
    direction,
    container,
    interaction,
    formatObservable,
    openSettings,
    initialEvent,
  }: AxisLineParams) {
    super({ chart, series, container, interaction });

    this.direction = direction;
    this.openSettings = openSettings;

    this.paneView = new AxisLinePaneView(this);
    this.timeAxisView = this.createTimeAxisView('main');
    this.priceAxisView = this.createPriceAxisView('main');

    this.subscribeFormat(formatObservable, (format) => {
      this.displayFormat = format;
    });

    this.series.attachPrimitive(this);

    if (initialEvent?.sourceEvent) {
      this.startDrawing(this.getEventPoint(initialEvent.sourceEvent));
    }
  }

  public isCreationPending(): boolean {
    return this.mode === 'idle';
  }

  public getState(): AxisLineState {
    return {
      hidden: this.hidden,
      mode: this.mode,
      time: this.time,
      price: this.price,
      settings: { ...this.settings },
    };
  }

  public setState(state: unknown): void {
    if (!state || typeof state !== 'object') {
      return;
    }

    const nextState = state as Partial<AxisLineState>;

    if (typeof nextState.hidden === 'boolean') {
      this.hidden = nextState.hidden;
    }

    if (nextState.mode) {
      this.mode = nextState.mode;
    }

    if ('time' in nextState) {
      this.time = nextState.time ?? null;
    }

    if ('price' in nextState) {
      this.price = nextState.price ?? null;
    }

    if (nextState.settings) {
      this.settings = {
        ...createDefaultSettings(),
        ...nextState.settings,
      };
    }

    this.render();
  }

  public getSettingsTabs(): SettingsTab[] {
    return getAxisLineSettingsTabs(this.settings);
  }

  public updateAllViews(): void {
    updateViews([
      this.paneView,
      this.timeAxisView,
      this.priceAxisView,
    ]);
  }

  public paneViews(): readonly IPrimitivePaneView[] {
    return [this.paneView];
  }

  public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
    return [];
  }

  public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
    return [];
  }

  public timeAxisViews() {
    return this.direction === 'vertical'
      ? [this.timeAxisView]
      : [];
  }

  public priceAxisViews() {
    return this.direction === 'horizontal'
      ? [this.priceAxisView]
      : [];
  }

  public getRenderData(): AxisLineRenderData | null {
    if (this.hidden) {
      return null;
    }

    const coordinate = this.getCoordinate();

    if (coordinate === null) {
      return null;
    }

    return {
      direction: this.direction,
      coordinate,
      ...this.settings,
    };
  }

  protected getDrawingHandles(): readonly DrawingHandle<AxisLineHandleKey>[] {
    const coordinate = this.getCoordinate();

    if (coordinate === null) {
      return [];
    }

    const { width, height } = this.container.getBoundingClientRect();

    return [
      {
        id: 'main',
        x: this.direction === 'vertical' ? coordinate : width / 2,
        y: this.direction === 'vertical' ? height / 2 : coordinate,
        shape: 'rounded',
      },
    ];
  }

  protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
    if (this.hidden || this.mode === 'idle') {
      return null;
    }

    const point = { x, y };
    const coordinate = this.getCoordinate();

    if (coordinate === null) {
      return null;
    }

    if (this.isSelected() && this.getDrawingHandleAtPoint(point)) {
      return {
        cursorStyle: this.getCursorStyle(),
        externalId: 'axis-line',
        zOrder: 'top',
      };
    }

    if (!this.isPointNearLine(point, coordinate)) {
      return null;
    }

    return {
      cursorStyle: this.getCursorStyle(),
      externalId: 'axis-line',
      zOrder: 'top',
    };
  }

  protected getTimeAxisLabel(kind: string): AxisLabel | null {
    if (
      !this.isAxisLabelAvailable() ||
      kind !== 'main' ||
      this.direction !== 'vertical' ||
      typeof this.time !== 'number'
    ) {
      return null;
    }

    const coordinate = getXCoordinateFromTime(
      this.chart,
      this.time,
      this.series,
    );

    return this.createAxisLabel(
      coordinate === null ? null : Number(coordinate),
      formatDate(
        this.time as UTCTimestamp,
        this.displayFormat.dateFormat,
        this.displayFormat.timeFormat,
        this.displayFormat.showTime,
      ),
    );
  }

  protected getPriceAxisLabel(kind: string): AxisLabel | null {
    if (
      !this.isAxisLabelAvailable() ||
      kind !== 'main' ||
      this.direction !== 'horizontal' ||
      this.price === null
    ) {
      return null;
    }

    const coordinate = getYCoordinateFromPrice(
      this.series,
      this.price,
    );

    return this.createAxisLabel(
      coordinate === null ? null : Number(coordinate),
      formatPrice(this.price) ?? '',
    );
  }

  protected getTimeAxisSegments(): AxisSegment[] {
    return [];
  }

  protected getPriceAxisSegments(): AxisSegment[] {
    return [];
  }

  protected getGeometry(): null {
    return null;
  }

  protected handleDoubleClick = (event: MouseEvent): void => {
    if (this.hidden || this.mode !== 'ready') {
      return;
    }

    const coordinate = this.getCoordinate();

    if (coordinate === null) {
      return;
    }

    const point = this.getEventPoint(event as PointerEvent);
    const isNearHandle =
      this.isSelected() &&
      Boolean(this.getDrawingHandleAtPoint(point));

    const isNearLine = this.isPointNearLine(
      point,
      coordinate,
    );

    if (!isNearHandle && !isNearLine) {
      return;
    }

    event.preventDefault();
    event.stopPropagation();

    this.openSettings?.();
  };

  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 !== 'ready') {
      return;
    }

    const coordinate = this.getCoordinate();

    if (coordinate === null) {
      return;
    }

    const isNearHandle =
      this.isSelected() &&
      Boolean(this.getDrawingHandleAtPoint(point));

    const isNearLine = this.isPointNearLine(
      point,
      coordinate,
    );

    if (!this.isSelected()) {
      if (!isNearLine) {
        return;
      }

      event.preventDefault();
      event.stopPropagation();

      this.select();
      return;
    }

    if (!isNearHandle && !isNearLine) {
      this.deselect();
      return;
    }

    event.preventDefault();
    event.stopPropagation();

    this.mode = 'dragging';
    this.dragPointerId = event.pointerId;

    this.hideCrosshair();
    this.render();
  };

  protected handlePointerMove = (event: PointerEvent): void => {
    if (
      this.mode !== 'dragging' ||
      this.dragPointerId !== event.pointerId
    ) {
      return;
    }

    event.preventDefault();
    event.stopPropagation();

    this.updateLine(
      this.getEventPoint(event),
    );

    this.render();
  };

  protected handlePointerUp = (event: PointerEvent): void => {
    if (
      this.mode !== 'dragging' ||
      this.dragPointerId !== event.pointerId
    ) {
      return;
    }

    this.mode = 'ready';
    this.dragPointerId = null;

    this.resolveReady?.();
    this.showCrosshair();
    this.render();
  };

  private startDrawing(point: Point): void {
    this.updateLine(point);
    this.mode = 'ready';

    this.resolveReady?.();
    this.render();
  }

  private updateLine(point: Point): void {
    if (this.direction === 'vertical') {
      this.time = getTimeFromXCoordinate(
        this.chart,
        point.x,
      );
      return;
    }

    this.price = getPriceFromYCoordinate(
      this.series,
      point.y,
    );
  }

  private getCoordinate(): number | null {
    if (this.direction === 'vertical') {
      if (this.time === null) {
        return null;
      }

      const coordinate = getXCoordinateFromTime(
        this.chart,
        this.time,
        this.series,
      );

      return coordinate === null
        ? null
        : Number(coordinate);
    }

    if (this.price === null) {
      return null;
    }

    const coordinate = getYCoordinateFromPrice(
      this.series,
      this.price,
    );

    return coordinate === null
      ? null
      : Number(coordinate);
  }

  private isPointNearLine(
    point: Point,
    coordinate: number,
  ): boolean {
    return this.direction === 'vertical'
      ? Math.abs(point.x - coordinate) <= LINE_HIT_TOLERANCE
      : Math.abs(point.y - coordinate) <= LINE_HIT_TOLERANCE;
  }

  private getCursorStyle(): PrimitiveHoveredItem['cursorStyle'] {
    return this.direction === 'vertical'
      ? 'ew-resize'
      : 'ns-resize';
  }
}

















import { round } from 'lodash-es';
import { skip } from 'rxjs';

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import { DrawingBase } from '@core/Drawings/DrawingBase';
import {
  getPriceDelta as getPriceDeltaFromCoordinates,
  getPriceFromYCoordinate,
  getPriceRangeInContainer,
  getTimeFromXCoordinate,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  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,
  getPercentPrecision,
  getPricePrecisionStep,
} from '@src/utils';
import { formatDate } from '@src/utils/formatter';

import { SliderPaneView } from './paneView';
import {
  createDefaultSettings,
  getSliderPositionSettingsTabs,
} from './settings';

import type {
  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 '@core/Drawings/DrawingBase';
import type {
  ChartOptionsModel,
  SettingsTab,
  SettingsValues,
} from '@src/types';
import type {
  IPrimitivePaneView,
  MouseEventHandler,
  MouseEventParams,
  PrimitiveHoveredItem,
  Time,
  UTCTimestamp,
} from 'lightweight-charts';
import type { Observable } from 'rxjs';

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>[];
}

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;
  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 DEFAULT_RISK_REWARD_RATIO = 1;

export class SliderPosition
  extends DrawingBase<
    SliderPositionSettings,
    SliderHandleId
  >
  implements ISeriesDrawing
{
  protected settings: SliderPositionSettings =
    createDefaultSettings();

  protected mode: SliderMode = 'idle';

  private readonly side: SliderSide;
  private readonly removeSelf?: () => void;
  private readonly openSettings?: () => void;
  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;

  private displayFormat: ChartOptionsModel = {
    dateFormat: Defaults.dateFormat,
    timeFormat: Defaults.timeFormat,
    showTime: Defaults.showTime,
  };

  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;

  constructor({
    chart,
    series,
    side,
    container,
    interaction,
    formatObservable,
    resetTriggers = [],
    removeSelf,
    openSettings,
    initialEvent,
  }: SliderPositionParams) {
    super({
      chart,
      series,
      container,
      interaction,
    });

    this.side = side;
    this.removeSelf = removeSelf;
    this.openSettings = openSettings;
    this.clickHandler = (params) => this.handleChartClick(params);

    this.paneView = new SliderPaneView(this);

    this.timeAxisPaneView =
      this.createTimeAxisPaneView('normal');

    this.priceAxisPaneView =
      this.createPriceAxisPaneView('normal');

    this.startTimeAxisView =
      this.createTimeAxisView('start');

    this.endTimeAxisView =
      this.createTimeAxisView('end');

    this.targetPriceAxisView =
      this.createPriceAxisView('target');

    this.entryPriceAxisView =
      this.createPriceAxisView('entry');

    this.stopPriceAxisView =
      this.createPriceAxisView('stop');

    this.subscribeFormat(
      formatObservable,
      (format) => {
        this.displayFormat = format;
      },
    );

    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,
      settings: {
        ...this.settings,
      },
    };
  }

  public setState(state: unknown): void {
    if (
      !state ||
      typeof state !== 'object'
    ) {
      return;
    }

    const next =
      state as Partial<SliderPositionState>;

    const {
      hidden = this.hidden,
      mode = this.mode,
      startTime = this.startTime,
      endTime = this.endTime,
      entryPrice = this.entryPrice,
    } = next;

    this.hidden = hidden;
    this.mode = mode;
    this.startTime = startTime;
    this.endTime = endTime;
    this.entryPrice = entryPrice;

    if (next.stopPrice !== undefined) {
      this.stopPrice =
        next.stopPrice === null ||
        entryPrice === null
          ? next.stopPrice
          : this.normalizeStop(
              entryPrice,
              next.stopPrice,
            );
    }

    const targetPrice =
      this.resolveTargetPrice(
        next,
        entryPrice,
      );

    if (targetPrice !== undefined) {
      this.targetPrice = targetPrice;
    }

    if (next.settings) {
      this.settings = {
        ...createDefaultSettings(),
        ...next.settings,
      };
    }

    if (
      typeof next.amount === 'number' &&
      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,
      targetPrice,
      stopPrice,
      ...settings
    } = values as Partial<
      SliderPositionSettings &
        SliderPositionArguments
    >;

    this.applyPositionArguments({
      entryPrice,
      targetPrice,
      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 metrics =
      this.getPositionMetrics(
        geometry,
        this.getPriceAtTime(
          this.endTime,
        ),
      );

    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.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';
    } else 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 [];
    }

    return [
      this.createAxisSegment(
        bounds.left,
        bounds.right,
      ),
    ];
  }

  protected getPriceAxisSegments():
    AxisSegment[] {
    if (!this.isSelected()) {
      return [];
    }

    const geometry =
      this.getGeometry();

    if (!geometry) {
      return [];
    }

    return [
      this.createAxisSegment(
        geometry.profitTop,
        geometry.profitBottom,
      ),
      this.createAxisSegment(
        geometry.lossTop,
        geometry.lossBottom,
      ),
    ];
  }

  protected getTimeAxisLabel(
    kind: string,
  ): AxisLabel | null {
    if (
      !this.isAxisLabelAvailable() ||
      !this.isSelected() ||
      (
        kind !== 'start' &&
        kind !== 'end'
      )
    ) {
      return null;
    }

    const labelKind =
      kind as TimeLabelKind;

    return this.createAxisLabel(
      this.getTimeCoordinate(
        labelKind,
      ),
      this.getTimeText(
        labelKind,
      ),
    );
  }

  protected getPriceAxisLabel(
    kind: string,
  ): AxisLabel | null {
    if (
      !this.isAxisLabelAvailable() ||
      (
        kind !== 'target' &&
        kind !== 'entry' &&
        kind !== 'stop'
      )
    ) {
      return null;
    }

    const labelKind =
      kind as PriceLabelKind;

    const { colors } =
      getThemeStore();

    let backgroundColor =
      colors.axisMarkerLabelDefaultFill;

    if (
      labelKind === 'target'
    ) {
      backgroundColor =
        colors.axisMarkerLabelPositiveFill;
    } else if (
      labelKind === 'stop'
    ) {
      backgroundColor =
        colors.axisMarkerLabelNegativeFill;
    }

    return this.createAxisLabel(
      this.getPriceCoordinate(
        labelKind,
      ),
      this.getPriceText(
        labelKind,
      ),
      {
        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?.();
  };

  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();
  };

  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',
      },
    ];
  }

  private resolveTargetPrice(
    state: Partial<SliderPositionState>,
    entryPrice: number | null,
  ): number | null | undefined {
    if (
      state.targetPrice !== undefined
    ) {
      if (
        state.targetPrice === null ||
        entryPrice === null
      ) {
        return state.targetPrice;
      }

      return this.normalizeTarget(
        entryPrice,
        state.targetPrice,
      );
    }

    const ratio =
      state.riskRewardRatio;

    if (
      entryPrice === null ||
      this.stopPrice === null ||
      typeof ratio !== 'number' ||
      ratio < 0
    ) {
      return undefined;
    }

    const risk =
      Math.abs(
        entryPrice -
        this.stopPrice,
      );

    const direction =
      this.side === 'long'
        ? 1
        : -1;

    return this.normalizeTarget(
      entryPrice,
      entryPrice +
        risk *
          ratio *
          direction,
    );
  }

  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 *
            DEFAULT_RISK_REWARD_RATIO,
      );

    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 =
      getPricePrecisionStep();

    const minPrice =
      this.side === 'long'
        ? snapshot.stopPrice +
          minDistance
        : snapshot.targetPrice;

    const maxPrice =
      this.side === 'long'
        ? snapshot.targetPrice
        : snapshot.stopPrice -
          minDistance;

    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 ||
      !this.dragStartPoint
    ) {
      return;
    }

    const timeOffset =
      point.x -
      this.dragStartPoint.x;

    const priceOffset =
      getPriceDeltaFromCoordinates(
        this.series,
        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 =
      getPriceRangeInContainer(
        this.series,
        this.container,
      );

    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,
    price: number,
  ): number {
    const minDistance =
      getPricePrecisionStep();

    return this.side === 'long'
      ? Math.min(
          price,
          entryPrice -
            minDistance,
        )
      : Math.max(
          price,
          entryPrice +
            minDistance,
        );
  }

  private normalizeTarget(
    entryPrice: number,
    price: number,
  ): number {
    return this.side === 'long'
      ? Math.max(
          price,
          entryPrice,
        )
      : Math.min(
          price,
          entryPrice,
        );
  }

  private clampPriceToRange(
    price: number,
  ): number {
    const range =
      getPriceRangeInContainer(
        this.series,
        this.container,
      );

    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 distance =
      Math.min(
        stopDistance,
        targetDistance /
          DEFAULT_RISK_REWARD_RATIO,
      );

    return (
      distance >=
      getPricePrecisionStep()
        ? distance
        : null
    );
  }

  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 DEFAULT_RISK_REWARD_RATIO;
    }

    const risk =
      Math.abs(
        this.entryPrice -
        this.stopPrice,
      );

    if (risk === 0) {
      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;
      } else if (
        'value' in item &&
        typeof item.value ===
          'number'
      ) {
        lastPrice =
          item.value;
      }
    }

    return lastPrice;
  }

  protected getGeometry():
    SliderGeometry | null {
    if (
      this.startTime === null ||
      this.endTime === null ||
      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 entryPrice =
      this.entryPrice ?? 0;

    return {
      entryPrice,
      targetPrice:
        this.targetPrice ??
        entryPrice,
      stopPrice:
        this.stopPrice ??
        entryPrice,
    };
  }

  private applyPositionArguments(
    values: Partial<SliderPositionArguments>,
  ): void {
    if (
      typeof values.entryPrice ===
      'number'
    ) {
      this.entryPrice =
        values.entryPrice;
    }

    if (
      this.entryPrice === null
    ) {
      return;
    }

    if (
      typeof values.targetPrice ===
      'number'
    ) {
      this.targetPrice =
        this.normalizeTarget(
          this.entryPrice,
          values.targetPrice,
        );
    } else if (
      this.targetPrice !== null
    ) {
      this.targetPrice =
        this.normalizeTarget(
          this.entryPrice,
          this.targetPrice,
        );
    }

    if (
      typeof values.stopPrice ===
      'number'
    ) {
      this.stopPrice =
        this.normalizeStop(
          this.entryPrice,
          values.stopPrice,
        );
    } else if (
      this.stopPrice !== null
    ) {
      this.stopPrice =
        this.normalizeStop(
          this.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,
      risk,
      lotSize,
      leverage,
      quantityPrecision,
    } = this.settings;

    if (
      riskDistance === 0 ||
      geometry.entryPrice <= 0
    ) {
      return {
        qty: 0,
        targetAmount:
          accountSize,
        stopAmount:
          accountSize,
        riskRewardRatio: 0,
        openPnl:
          this.getOpenPnl(
            geometry.entryPrice,
            selectedPrice,
          ),
      };
    }

    const riskSize =
      (risk / 100) *
      accountSize;

    const qtyRisk =
      riskSize /
      riskDistance /
      lotSize;

    const qtyLeverage =
      accountSize *
      leverage /
      geometry.entryPrice /
      lotSize;

    const qty = round(
      Math.min(
        qtyRisk,
        qtyLeverage,
      ),
      quantityPrecision,
    );

    const multiplier =
      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 formattedDiff =
      formatPrice(diff) ?? '0';

    const formattedAmount =
      formatPrice(
        amount,
        2,
      ) ?? '0';

    return `${t('Target')}: ${formattedDiff} (${formatPercent(percent)}), ${t('Amount')}: ${formattedAmount}`;
  }

  private getCenterText(
    metrics: PositionMetrics,
  ): string {
    const formattedQty =
      formatPrice(
        metrics.qty,
        this.settings
          .quantityPrecision,
      ) ?? '0';

    const formattedRatio =
      formatPrice(
        metrics.riskRewardRatio,
        getPercentPrecision(),
      ) ?? '0';

    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 formattedDiff =
      formatPrice(diff) ?? '0';

    const formattedAmount =
      formatPrice(
        amount,
        2,
      ) ?? '0';

    return `${t('Stop')}: ${formattedDiff} (${formatPercent(percent)}), ${t('Amount')}: ${formattedAmount}`;
  }

  private getHandleTarget(
    point: Point,
  ): DragTarget {
    const geometry =
      this.getGeometry();

    if (!geometry) {
      return null;
    }

    const startHandle =
      this.getStartPriceHandleTarget(
        point,
        geometry,
      );

    if (startHandle) {
      return startHandle;
    }

    const handle =
      this.getDrawingHandleAtPoint(
        point,
      );

    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 getStartPriceHandleTarget(
    point: Point,
    geometry: SliderGeometry,
  ): SliderHandleId | null {
    if (
      Math.abs(
        point.x -
        geometry.startX,
      ) >
      HIT_TOLERANCE
    ) {
      return null;
    }

    const entryDistance =
      Math.abs(
        point.y -
        geometry.entryY,
      );

    if (entryDistance < 1) {
      return 'entry';
    }

    const pointerSide =
      Math.sign(
        point.y -
        geometry.entryY,
      );

    const targetSide =
      Math.sign(
        geometry.targetY -
        geometry.entryY,
      ) ||
      -Math.sign(
        geometry.stopY -
        geometry.entryY,
      );

    if (targetSide === 0) {
      return (
        entryDistance <=
        HIT_TOLERANCE
          ? 'entry'
          : null
      );
    }

    const isTargetSide =
      pointerSide ===
      targetSide;

    const handleY =
      isTargetSide
        ? geometry.targetY
        : geometry.stopY;

    if (
      Math.abs(
        point.y -
        handleY,
      ) <=
      HIT_TOLERANCE
    ) {
      return isTargetSide
        ? 'target'
        : 'stop';
    }

    return (
      entryDistance <=
      HIT_TOLERANCE
        ? 'entry'
        : 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,
    );
  }
}