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


import type { CanvasRenderingTarget2D } from 'fancy-canvas';
import type {
  IChartApi,
  IPrimitivePaneRenderer,
  IPrimitivePaneView,
  ISeriesPrimitive,
  PrimitivePaneViewZOrder,
  SeriesAttachedParameter,
  SeriesOptionsMap,
  Time,
} from 'lightweight-charts';

import { isNearPoint } from '@core/Drawings/helpers';

import { getThemeStore } from '@src/theme';

import type { Point, SeriesApi } from '@core/Drawings/types';

export type DrawingHandleShape = 'circle' | 'rounded';

export interface DrawingHandle<TId extends string = string> {
  id: TId;
  x: number;
  y: number;
  shape?: DrawingHandleShape;
}

const HANDLE_SIZE = 10;
const HANDLE_HIT_TOLERANCE = 8;
const HANDLE_RADIUS = 3;
const HANDLE_BORDER_WIDTH = 1;

export class DrawingHandlesPrimitive<TId extends string = string> implements ISeriesPrimitive<Time> {
  private chart: IChartApi | null = null;
  private series: SeriesApi | null = null;

  private readonly paneViewsList: readonly IPrimitivePaneView[];
  private readonly timeAxisPaneViewsList: readonly IPrimitivePaneView[];

  constructor(private readonly getHandles: () => readonly DrawingHandle<TId>[]) {
    const paneRenderer: IPrimitivePaneRenderer = {
      draw: (target) => this.drawPane(target),
    };

    const timeAxisRenderer: IPrimitivePaneRenderer = {
      draw: (target) => this.drawTimeAxis(target),
    };

    this.paneViewsList = [
      {
        renderer: () => paneRenderer,
        zOrder: (): PrimitivePaneViewZOrder => 'top',
      },
    ];

    this.timeAxisPaneViewsList = [
      {
        renderer: () => timeAxisRenderer,
        zOrder: (): PrimitivePaneViewZOrder => 'top',
      },
    ];
  }

  public attached({
    chart,
    series,
  }: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
    this.chart = chart;
    this.series = series;
  }

  public detached(): void {
    this.chart = null;
    this.series = null;
  }

  public updateAllViews(): void {}

  public paneViews(): readonly IPrimitivePaneView[] {
    return this.getHandles().length ? this.paneViewsList : [];
  }

  public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
    return this.getHandles().length ? this.timeAxisPaneViewsList : [];
  }

  public findHandle(point: Point): DrawingHandle<TId> | null {
    const handles = this.getHandles();

    for (let index = handles.length - 1; index >= 0; index -= 1) {
      const handle = handles[index];

      if (isNearPoint(point, handle.x, handle.y, HANDLE_HIT_TOLERANCE)) {
        return handle;
      }
    }

    return null;
  }

  public isTimeAxisHit(point: Point): boolean {
    if (!this.isTimeAxisAdjacent()) {
      return false;
    }

    const paneHeight = this.getPaneHeight();

    if (paneHeight <= 0 || point.y < paneHeight) {
      return false;
    }

    const handles = this.getHandles();

    for (let index = handles.length - 1; index >= 0; index -= 1) {
      const handle = handles[index];

      if (
        intersectsTimeAxis(handle, paneHeight) &&
        isNearPoint(point, handle.x, handle.y, HANDLE_HIT_TOLERANCE)
      ) {
        return true;
      }
    }

    return false;
  }

  private drawPane(target: CanvasRenderingTarget2D): void {
    const handles = this.getHandles();

    if (!handles.length) {
      return;
    }

    target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
      for (const handle of handles) {
        drawHandle(context, handle, horizontalPixelRatio, verticalPixelRatio);
      }
    });
  }

  private drawTimeAxis(target: CanvasRenderingTarget2D): void {
    if (!this.isTimeAxisAdjacent()) {
      return;
    }

    const paneHeight = this.getPaneHeight();

    if (paneHeight <= 0) {
      return;
    }

    const handles = this.getHandles();

    if (!handles.length) {
      return;
    }

    target.useBitmapCoordinateSpace(
      ({ context, horizontalPixelRatio, verticalPixelRatio, bitmapSize }) => {
        const axisHeight = bitmapSize.height / verticalPixelRatio;

        for (const handle of handles) {
          if (!intersectsTimeAxis(handle, paneHeight)) {
            continue;
          }

          const axisY = handle.y - paneHeight;
          const halfSize = HANDLE_SIZE / 2;

          if (axisY + halfSize <= 0 || axisY - halfSize >= axisHeight) {
            continue;
          }

          drawHandle(
            context,
            {
              ...handle,
              y: axisY,
            },
            horizontalPixelRatio,
            verticalPixelRatio,
          );
        }
      },
    );
  }

  private getPaneHeight(): number {
    return this.series?.getPane().getHeight() ?? 0;
  }

  private isTimeAxisAdjacent(): boolean {
    if (!this.chart || !this.series) {
      return false;
    }

    return this.series.getPane().paneIndex() === this.chart.panes().length - 1;
  }
}

function intersectsTimeAxis(handle: DrawingHandle, paneHeight: number): boolean {
  const halfSize = HANDLE_SIZE / 2;

  return handle.y - halfSize < paneHeight && handle.y + halfSize > paneHeight;
}

function drawHandle(
  context: CanvasRenderingContext2D,
  handle: DrawingHandle,
  horizontalPixelRatio: number,
  verticalPixelRatio: number,
): void {
  const width = HANDLE_SIZE * horizontalPixelRatio;
  const height = HANDLE_SIZE * verticalPixelRatio;

  const x = handle.x * horizontalPixelRatio;
  const y = handle.y * 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 = HANDLE_BORDER_WIDTH * Math.max(horizontalPixelRatio, verticalPixelRatio);

  context.beginPath();

  if (handle.shape === 'circle') {
    context.arc(x, y, Math.min(width, height) / 2, 0, Math.PI * 2);
  } else {
    drawRoundedRect(
      context,
      left,
      top,
      width,
      height,
      HANDLE_RADIUS * Math.max(horizontalPixelRatio, verticalPixelRatio),
    );
  }

  context.fill();
  context.stroke();

  context.restore();
}

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 { clamp } from 'lodash-es';

import type { Anchor, Bounds, ContainerSize, Point, SeriesApi } from './types';

import type { Coordinate, IChartApi, Logical, Time, TouchMouseEventData } from 'lightweight-charts';

interface SeriesTimeItem {
  time: Time;
}

interface TimePoint {
  time: number;
  logical: number;
}

export function getPriceFromYCoordinate(series: SeriesApi, yCoordinate: number): number | null {
  return series.coordinateToPrice(yCoordinate as Coordinate);
}

export function getYCoordinateFromPrice(series: SeriesApi, price: number): Coordinate | null {
  return series.priceToCoordinate(price);
}

export function getTimeFromXCoordinate(chart: IChartApi, xCoordinate: number): Time | null {
  return chart.timeScale().coordinateToTime(xCoordinate as Coordinate) ?? null;
}

export function getXCoordinateFromTime(chart: IChartApi, time: Time, series?: SeriesApi): Coordinate | null {
  const coordinate = chart.timeScale().timeToCoordinate(time);

  if (isValidCoordinate(coordinate)) {
    return coordinate;
  }

  if (!series) {
    return null;
  }

  const logical = getNearestLogicalFromTime(series, time);

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

  const projectedCoordinate = chart.timeScale().logicalToCoordinate(logical as Logical);

  if (!isValidCoordinate(projectedCoordinate)) {
    return null;
  }

  return projectedCoordinate;
}

export function getContainerSize(container: HTMLElement): ContainerSize {
  const rect = container.getBoundingClientRect();

  return {
    width: rect.width,
    height: rect.height,
  };
}

export function clampPointToContainer(point: Point, container: HTMLElement): Point {
  const { width, height } = getContainerSize(container);

  return {
    x: clamp(point.x, 0, width),
    y: clamp(point.y, 0, height),
  };
}

export function getRawPointerPoint(
  container: HTMLElement,
  event: MouseEvent | TouchMouseEventData,
): Point {
  const rect = container.getBoundingClientRect();

  return {
    x: event.clientX - rect.left,
    y: event.clientY - rect.top,
  };
}

export function getPointerPoint(
  container: HTMLElement,
  event: MouseEvent | TouchMouseEventData,
): Point {
  return clampPointToContainer(getRawPointerPoint(container, event), container);
}

export function isNearPoint(point: Point, x: number, y: number, tolerance: number): boolean {
  return Math.abs(point.x - x) <= tolerance && Math.abs(point.y - y) <= tolerance;
}

export function isPointInBounds(point: Point, bounds: Bounds, tolerance = 0): boolean {
  return (
    point.x >= bounds.left - tolerance &&
    point.x <= bounds.right + tolerance &&
    point.y >= bounds.top - tolerance &&
    point.y <= bounds.bottom + tolerance
  );
}

export function normalizeBounds(
  left: number,
  right: number,
  top: number,
  bottom: number,
  container: HTMLElement,
): Bounds {
  const { width, height } = getContainerSize(container);

  return {
    left: clamp(Math.min(left, right), 0, width),
    right: clamp(Math.max(left, right), 0, width),
    top: clamp(Math.min(top, bottom), 0, height),
    bottom: clamp(Math.max(top, bottom), 0, height),
  };
}

export function shiftTimeByPixels(
  chart: IChartApi,
  time: Time,
  offsetX: number,
  series?: SeriesApi,
): Time | null {
  const coordinate = getXCoordinateFromTime(chart, time, series);

  if (!isValidCoordinate(coordinate)) {
    return null;
  }

  return getTimeFromXCoordinate(chart, Number(coordinate) + offsetX);
}

export function getPriceDelta(series: SeriesApi, fromY: number, toY: number): number {
  const fromPrice = getPriceFromYCoordinate(series, fromY);
  const toPrice = getPriceFromYCoordinate(series, toY);

  if (fromPrice === null || toPrice === null) {
    return 0;
  }

  return toPrice - fromPrice;
}

export function getPriceRangeInContainer(
  series: SeriesApi,
  container: HTMLElement,
): { min: number; max: number } | null {
  const { height } = getContainerSize(container);

  if (!height) {
    return null;
  }

  const topPrice = getPriceFromYCoordinate(series, 0);
  const bottomPrice = getPriceFromYCoordinate(series, height);

  if (topPrice === null || bottomPrice === null) {
    return null;
  }

  return {
    min: Math.min(topPrice, bottomPrice),
    max: Math.max(topPrice, bottomPrice),
  };
}

export function getAnchorFromPoint(chart: IChartApi, series: SeriesApi, point: Point): Anchor | null {
  const time = getTimeFromXCoordinate(chart, point.x);
  const price = getPriceFromYCoordinate(series, point.y);

  if (time === null || price === null) {
    return null;
  }

  return {
    time,
    price,
  };
}

function getNearestLogicalFromTime(series: SeriesApi, time: Time): number | null {
  const targetTime = getNumericTime(time);

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

  const points = getSeriesTimePoints(series);

  if (!points.length) {
    return null;
  }

  const lastIndex = points.length - 1;

  if (targetTime <= points[0].time) {
    return points[0].logical;
  }

  if (targetTime >= points[lastIndex].time) {
    return points[lastIndex].logical;
  }

  let left = 0;
  let right = lastIndex;

  while (left <= right) {
    const middleIndex = Math.floor((left + right) / 2);
    const middleTime = points[middleIndex].time;

    if (middleTime === targetTime) {
      return points[middleIndex].logical;
    }

    if (middleTime < targetTime) {
      left = middleIndex + 1;
    } else {
      right = middleIndex - 1;
    }
  }

  // Если точного времени нет на текущем таймфрейме, left и right становятся соседними свечами вокруг targetTime
  // Для отображения дровинга берём ближайшую существующую свечу, но исходный state дровинга не меняем
  const previousPoint = points[right] ?? null;
  const nextPoint = points[left] ?? null;

  return getNearestLogicalByTime(targetTime, previousPoint, nextPoint);
}

function getNearestLogicalByTime(
  targetTime: number,
  previousPoint: TimePoint | null,
  nextPoint: TimePoint | null,
): number | null {
  if (!previousPoint && !nextPoint) {
    return null;
  }

  if (!previousPoint) {
    return nextPoint?.logical ?? null;
  }

  if (!nextPoint) {
    return previousPoint.logical;
  }

  const previousDistance = Math.abs(targetTime - previousPoint.time);
  const nextDistance = Math.abs(nextPoint.time - targetTime);

  return nextDistance < previousDistance ? nextPoint.logical : previousPoint.logical;
}

function getSeriesTimePoints(series: SeriesApi): TimePoint[] {
  const data = series.data() as readonly SeriesTimeItem[];

  return data.reduce<TimePoint[]>((points, item, logical) => {
    const time = getNumericTime(item.time);

    if (time === null) {
      return points;
    }

    points.push({
      time,
      logical,
    });

    return points;
  }, []);
}

function getNumericTime(time: Time): number | null {
  if (typeof time !== 'number') {
    return null;
  }

  return Number.isFinite(time) ? time : null;
}

function isValidCoordinate(coordinate: Coordinate | null): coordinate is Coordinate {
  return coordinate !== null && Number.isFinite(Number(coordinate));
}




import {
  AutoscaleInfo,
  CrosshairMode,
  IChartApi,
  IPrimitivePaneView,
  ISeriesApi,
  ISeriesPrimitive,
  ISeriesPrimitiveAxisView,
  Logical,
  MouseEventParams,
  PrimitiveHoveredItem,
  SeriesAttachedParameter,
  SeriesOptionsMap,
  SeriesType,
  Time,
  TouchMouseEventData,
} from 'lightweight-charts';
import { Observable, Subject, Subscription } from 'rxjs';

import { DrawingHandlesPrimitive } from '@core/Drawings/handles';
import {
  getPointerPoint as getPointerPointFromEvent,
  getRawPointerPoint,
} from '@core/Drawings/helpers';
import { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';

import { ChartOptionsModel, SettingsTab, SettingsValues } from '@src/types';

import type { DrawingHandle } from '@core/Drawings/handles';

export interface DrawingInteraction {
  selected$: Observable<boolean>;
  locked$: Observable<boolean>;

  isSelected(): boolean;
  isLocked(): boolean;

  select(): void;
  deselect(): void;
}

export interface ISeriesDrawing extends ISeriesPrimitive<Time> {
  show(): void;
  hide(): void;
  rebind(series: ISeriesApi<SeriesType>): void;
  destroy(): void;

  waitTillReady(): Promise<void>;
  isCreationPending(): boolean;
  shouldShowInObjectTree(): boolean;

  getState(): unknown;
  setState(state: unknown): void;

  getSettings(): SettingsValues;
  getSettingsTabs(): SettingsTab[];
  updateSettings(settings: SettingsValues): void;

  subscribeSettings(callback: (settings: SettingsValues) => void): Subscription;

  isHit(event: MouseEvent): boolean;
  pointerDown(event: PointerEvent): void;
  click(event: MouseEvent): void;
  doubleClick(event: MouseEvent): void;
  contextMenu(event: MouseEvent): void;

  getRenderData(): unknown;
}

export type StartPoint = unknown;

interface SeriesDrawingBaseParams {
  container: HTMLElement;
  chart: IChartApi;
  series: SeriesApi;
  interaction: DrawingInteraction;
}

export interface BaseDrawingParams {
  chart: IChartApi;
  series: SeriesApi;
  container: HTMLElement;
  interaction: DrawingInteraction;
  formatObservable?: Observable<ChartOptionsModel>;
  removeSelf?: () => void;
  openSettings?: () => void;
  initialEvent?: MouseEventParams;
}

export abstract class SeriesDrawingBase<
  TSettings extends SettingsValues = SettingsValues,
  THandleId extends string = string,
> implements ISeriesDrawing {
  protected hidden = false;
  protected chart: IChartApi;
  protected series: SeriesApi;
  protected subscriptions = new Subscription();
  protected abstract mode: unknown; // todo: хочется иметь единый mode
  protected abstract settings: TSettings;
  protected readonly container: HTMLElement;
  protected isBound = false;

  private readonly interaction: DrawingInteraction;
  private readonly settingsSubject = new Subject<SettingsValues>();
  private readonly handlesPrimitive: DrawingHandlesPrimitive<THandleId>;
  private isInteractionBound = false;

  protected readyPromise: Promise<void> | null = null;
  protected resolveReady: (() => void) | null = null;
  protected requestUpdate: (() => void) | null = null;

  constructor({ chart, series, container, interaction }: SeriesDrawingBaseParams) {
    this.chart = chart;
    this.series = series;
    this.container = container;
    this.interaction = interaction;

    this.handlesPrimitive = new DrawingHandlesPrimitive(() => {
      if (this.hidden || !this.shouldShowHandles()) {
        return [];
      }

      return this.getDrawingHandles();
    });
  }

  public subscribeSettings(callback: (settings: SettingsValues) => void): Subscription {
    callback(this.getSettings());

    return this.settingsSubject.subscribe(callback);
  }

  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.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.series.attachPrimitive(this.handlesPrimitive);

    this.bindInteraction();
    this.bindEvents();
  }

  public detached(): void {
    this.series.detachPrimitive(this.handlesPrimitive);

    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 isHit(event: MouseEvent): boolean {
    if (!this.isEventInside(event)) {
      return false;
    }

    const point = this.getEventPoint(event as PointerEvent);

    return this.getHoveredItem(point.x, point.y) !== null;
  }

  public pointerDown(event: PointerEvent): void {
    if (!this.isEventInside(event)) {
      return;
    }

    if (!this.isLocked() || this.isCreationPending() || event.button !== 0) {
      this.handlePointerDown(event);

      return;
    }

    const point = this.getEventPoint(event);

    if (this.getHoveredItem(point.x, point.y)) {
      this.select();

      return;
    }

    if (this.isSelected()) {
      this.deselect();
    }
  }

  public click(event: MouseEvent): void {
    if (!this.isEventInside(event)) {
      return;
    }

    this.handleClick(event);
  }

  public doubleClick(event: MouseEvent): void {
    if (!this.isEventInside(event)) {
      return;
    }

    this.handleDoubleClick(event);
  }

  public contextMenu(event: MouseEvent): void {
    if (!this.isEventInside(event)) {
      return;
    }

    this.handleContextMenu(event);
  }

  public abstract getRenderData(): unknown; // todo: make proper type
  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 isSelected(): boolean {
    return this.interaction.isSelected();
  }

  protected isLocked(): boolean {
    return this.interaction.isLocked();
  }

  protected select(): void {
    this.interaction.select();
  }

  protected deselect(): void {
    this.interaction.deselect();
  }

  protected shouldShowHandles(): boolean {
    return !this.isLocked() && (this.isSelected() || this.isCreationPending());
  }

  protected getDrawingHandles(): readonly DrawingHandle<THandleId>[] {
    return [];
  }

  protected getDrawingHandleAtPoint(point: Point): DrawingHandle<THandleId> | null {
    return this.handlesPrimitive.findHandle(point);
  }

  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 | TouchMouseEventData): Point {
    return getPointerPointFromEvent(this.container, event);
  }

  protected bindEvents(): void {
    if (this.isBound) {
      return;
    }

    this.isBound = true;

    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;

    window.removeEventListener('pointermove', this.handlePointerMove);
    window.removeEventListener('pointerup', this.handlePointerUp);
    window.removeEventListener('pointercancel', this.handlePointerUp);
  }

  // todo: хочется общую реализацию для каждой кнопки
  protected handleClick(event: MouseEvent): void {}
  protected handleContextMenu(event: MouseEvent): void {}
  protected handleDoubleClick(event: MouseEvent): void {}
  protected handlePointerMove(event: PointerEvent): void {}
  protected handlePointerUp(event: PointerEvent): void {}
  protected handlePointerDown(event: PointerEvent | TouchMouseEventData): void {}

  protected abstract getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null;
  protected abstract getGeometry(): unknown; // todo: make proper type
  protected abstract getTimeAxisSegments(): AxisSegment[];
  protected abstract getPriceAxisSegments(): AxisSegment[];
  protected abstract getTimeAxisLabel(kind: string): AxisLabel | null;
  protected abstract getPriceAxisLabel(kind: string): AxisLabel | null;

  private bindInteraction(): void {
    if (this.isInteractionBound) {
      return;
    }

    this.isInteractionBound = true;

    this.subscriptions.add(
      this.interaction.selected$.subscribe(() => {
        this.render();
      }),
    );

    this.subscriptions.add(
      this.interaction.locked$.subscribe((isLocked) => {
        if (isLocked) {
          this.showCrosshair();
        }

        this.render();
      }),
    );
  }

  private isEventInside(event: MouseEvent): boolean {
    if (event.target instanceof Node && this.container.contains(event.target)) {
      return true;
    }

    return this.handlesPrimitive.isTimeAxisHit(
      getRawPointerPoint(this.container, event),
    );
  }
}



import { CanvasRenderingTarget2D } from 'fancy-canvas';
import {
  IPrimitivePaneRenderer,
  IPrimitivePaneView,
  ISeriesPrimitiveAxisView,
  PrimitivePaneViewZOrder,
} from 'lightweight-charts';

import type { AxisLabel, AxisSegment } from '@core/Drawings/types';

interface CustomTimeAxisPaneViewParams {
  getAxisSegments(): AxisSegment[];
  zOrder?: PrimitivePaneViewZOrder;
}

interface CustomTimeAxisViewParams {
  getAxisLabel(labelKind: string): AxisLabel | null;
  labelKind: string;
}

export class CustomTimeAxisPaneView implements IPrimitivePaneView {
  private readonly paneZOrder: PrimitivePaneViewZOrder;
  private readonly rendererInstance: CustomTimeAxisPaneRenderer;

  constructor({
    getAxisSegments,
    zOrder = 'bottom',
  }: CustomTimeAxisPaneViewParams) {
    this.paneZOrder = zOrder;
    this.rendererInstance = new CustomTimeAxisPaneRenderer(getAxisSegments);
  }

  public update(): void {}

  public renderer(): IPrimitivePaneRenderer {
    return this.rendererInstance;
  }

  public zOrder(): PrimitivePaneViewZOrder {
    return this.paneZOrder;
  }
}

class CustomTimeAxisPaneRenderer implements IPrimitivePaneRenderer {
  constructor(private readonly getAxisSegments: () => AxisSegment[]) {}

  public draw(target: CanvasRenderingTarget2D): void {
    const axisSegments = this.getAxisSegments();

    if (!axisSegments.length) {
      return;
    }

    target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, bitmapSize }) => {
      context.save();

      for (const axisSegment of axisSegments) {
        const segmentStart = Math.min(axisSegment.from, axisSegment.to) * horizontalPixelRatio;
        const segmentEnd = Math.max(axisSegment.from, axisSegment.to) * horizontalPixelRatio;

        context.fillStyle = axisSegment.color;
        context.fillRect(
          segmentStart,
          0,
          segmentEnd - segmentStart,
          bitmapSize.height,
        );
      }

      context.restore();
    });
  }
}

export class CustomTimeAxisView implements ISeriesPrimitiveAxisView {
  private readonly getAxisLabel: (labelKind: string) => AxisLabel | null;
  private readonly labelKind: string;

  constructor({ getAxisLabel, labelKind }: CustomTimeAxisViewParams) {
    this.getAxisLabel = getAxisLabel;
    this.labelKind = labelKind;
  }

  public update(): void {}

  public visible(): boolean {
    return Boolean(this.getCurrentLabel()?.text);
  }

  public tickVisible(): boolean {
    return false;
  }

  public coordinate(): number {
    return this.getCurrentLabel()?.coordinate ?? 0;
  }

  public text(): string {
    return this.getCurrentLabel()?.text ?? '';
  }

  public textColor(): string {
    return this.getCurrentLabel()?.textColor ?? '';
  }

  public backColor(): string {
    return this.getCurrentLabel()?.backgroundColor ?? '';
  }

  private getCurrentLabel(): AxisLabel | null {
    return this.getAxisLabel(this.labelKind);
  }
}



import { CanvasRenderingTarget2D } from 'fancy-canvas';
import {
  IPrimitivePaneRenderer,
  IPrimitivePaneView,
  ISeriesPrimitiveAxisView,
  PrimitivePaneViewZOrder,
} from 'lightweight-charts';

import type { AxisLabel, AxisSegment } from '@core/Drawings/types';

interface CustomPriceAxisPaneViewParams {
  getAxisSegments(): AxisSegment[];
  zOrder?: PrimitivePaneViewZOrder;
}

interface CustomPriceAxisViewParams {
  getAxisLabel(labelKind: string): AxisLabel | null;
  labelKind: string;
}

export class CustomPriceAxisPaneView implements IPrimitivePaneView {
  private readonly paneZOrder: PrimitivePaneViewZOrder;
  private readonly rendererInstance: CustomPriceAxisPaneRenderer;

  constructor({
    getAxisSegments,
    zOrder = 'bottom',
  }: CustomPriceAxisPaneViewParams) {
    this.paneZOrder = zOrder;
    this.rendererInstance = new CustomPriceAxisPaneRenderer(getAxisSegments);
  }

  public update(): void {}

  public renderer(): IPrimitivePaneRenderer {
    return this.rendererInstance;
  }

  public zOrder(): PrimitivePaneViewZOrder {
    return this.paneZOrder;
  }
}

class CustomPriceAxisPaneRenderer implements IPrimitivePaneRenderer {
  constructor(private readonly getAxisSegments: () => AxisSegment[]) {}

  public draw(target: CanvasRenderingTarget2D): void {
    const axisSegments = this.getAxisSegments();

    if (!axisSegments.length) {
      return;
    }

    target.useBitmapCoordinateSpace(({ context, verticalPixelRatio, bitmapSize }) => {
      context.save();

      for (const axisSegment of axisSegments) {
        const segmentStart = Math.min(axisSegment.from, axisSegment.to) * verticalPixelRatio;
        const segmentEnd = Math.max(axisSegment.from, axisSegment.to) * verticalPixelRatio;

        context.fillStyle = axisSegment.color;
        context.fillRect(
          0,
          segmentStart,
          bitmapSize.width,
          segmentEnd - segmentStart,
        );
      }

      context.restore();
    });
  }
}

export class CustomPriceAxisView implements ISeriesPrimitiveAxisView {
  private readonly getAxisLabel: (labelKind: string) => AxisLabel | null;
  private readonly labelKind: string;

  constructor({ getAxisLabel, labelKind }: CustomPriceAxisViewParams) {
    this.getAxisLabel = getAxisLabel;
    this.labelKind = labelKind;
  }

  public update(): void {}

  public visible(): boolean {
    return Boolean(this.getCurrentLabel()?.text);
  }

  public tickVisible(): boolean {
    return false;
  }

  public coordinate(): number {
    return this.getCurrentLabel()?.coordinate ?? 0;
  }

  public text(): string {
    return this.getCurrentLabel()?.text ?? '';
  }

  public textColor(): string {
    return this.getCurrentLabel()?.textColor ?? '';
  }

  public backColor(): string {
    return this.getCurrentLabel()?.backgroundColor ?? '';
  }

  private getCurrentLabel(): AxisLabel | null {
    return this.getAxisLabel(this.labelKind);
  }
}



import { Observable, skip } from 'rxjs';

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
  getPriceDelta as getPriceDeltaFromCoordinates,
  getPriceFromYCoordinate,
  getPriceRangeInContainer,
  getTimeFromXCoordinate,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  isPointInBounds,
  shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';

import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { Defaults } from '@src/types/defaults';
import { formatPercent, formatPrice, formatSignedNumber } from '@src/utils';
import { formatDate } from '@src/utils/formatter';

import { SliderPaneView } from './paneView';

import {
  createDefaultSettings,
  getSliderPositionSettingsTabs,
  SliderPositionSettings,
  SliderPositionStyle,
  SliderPositionTextStyle,
} from './settings';

import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment, Bounds, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
import type {
  IPrimitivePaneView,
  MouseEventHandler,
  MouseEventParams,
  PrimitiveHoveredItem,
  Time,
  UTCTimestamp,
} from 'lightweight-charts';

type SliderSide = 'long' | 'short';
type SliderMode = 'idle' | 'ready' | 'dragging';
type DragTarget = 'body' | 'entry' | 'target' | 'stop' | 'end' | null;
type SliderHandleId = Exclude<DragTarget, 'body' | null>;

type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'target' | 'entry' | 'stop';

interface SliderPositionParams extends BaseDrawingParams {
  side: SliderSide;
  resetTriggers?: Observable<unknown>[];
}

interface SliderPositionState {
  hidden: boolean;
  mode: SliderMode;
  startTime: Time | null;
  endTime: Time | null;
  entryPrice: number | null;
  stopPrice: number | null;
  targetPrice: number | null;
  riskRewardRatio: number;
  amount: number;
  tickSize: number;
  settings: SliderPositionSettings;
}

interface 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;

  targetLabelDirection: 'up' | 'down';
  stopLabelDirection: 'up' | 'down';

  showLabels: boolean;
}

const BODY_HIT_TOLERANCE = 8;
const INITIAL_WIDTH_PX = 160;
const MIN_DISTANCE = 0.00000001;

export class SliderPosition
  extends SeriesDrawingBase<SliderPositionSettings, SliderHandleId>
  implements ISeriesDrawing {
  private removeSelf?: () => void;
  private openSettings?: () => void;

  protected settings: SliderPositionSettings = createDefaultSettings();

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

  protected mode: SliderMode = 'idle';

  private side: SliderSide;

  private startTime: Time | null = null;
  private endTime: Time | null = null;

  private entryPrice: number | null = null;
  private stopPrice: number | null = null;
  private targetPrice: number | null = null;

  private activeDragTarget: DragTarget = null;
  private dragPointerId: number | null = null;
  private dragStartPoint: Point | null = null;
  private dragStateSnapshot: SliderPositionState | null = null;

  private defaultRiskRewardRatio = 1;
  private amount = 1000;
  private tickSize = 1;

  private clickHandler: MouseEventHandler<Time>;

  private paneView: SliderPaneView;

  private timeAxisPaneView: CustomTimeAxisPaneView;
  private priceAxisPaneView: CustomPriceAxisPaneView;

  private startTimeAxisView: CustomTimeAxisView;
  private endTimeAxisView: CustomTimeAxisView;

  private targetPriceAxisView: CustomPriceAxisView;
  private entryPriceAxisView: CustomPriceAxisView;
  private stopPriceAxisView: CustomPriceAxisView;

  constructor({
    chart,
    series,
    side,
    container,
    interaction,
    formatObservable,
    resetTriggers = [],
    removeSelf,
    openSettings,
    initialEvent,
  }: 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 = new CustomTimeAxisPaneView({
      getAxisSegments: () => this.getTimeAxisSegments(),
      zOrder: 'normal',
    });

    this.priceAxisPaneView = new CustomPriceAxisPaneView({
      getAxisSegments: () => this.getPriceAxisSegments(),
      zOrder: 'normal',
    });

    this.startTimeAxisView = new CustomTimeAxisView({
      getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
      labelKind: 'start',
    });

    this.endTimeAxisView = new CustomTimeAxisView({
      getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
      labelKind: 'end',
    });

    this.targetPriceAxisView = new CustomPriceAxisView({
      getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
      labelKind: 'target',
    });

    this.entryPriceAxisView = new CustomPriceAxisView({
      getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
      labelKind: 'entry',
    });

    this.stopPriceAxisView = new CustomPriceAxisView({
      getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
      labelKind: 'stop',
    });

    if (formatObservable) {
      this.subscriptions.add(
        formatObservable.subscribe((format) => {
          this.displayFormat = format;
          this.render();
        }),
      );
    }

    resetTriggers.forEach((trigger) => {
      this.subscriptions.add(
        trigger.pipe(skip(1)).subscribe(() => {
          this.removeSelf?.();
        }),
      );
    });

    this.series.attachPrimitive(this);

    if (initialEvent) {
      this.handleChartClick(initialEvent);
    }
  }

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

  public getState(): SliderPositionState {
    return {
      hidden: this.hidden,
      mode: this.mode,
      startTime: this.startTime,
      endTime: this.endTime,
      entryPrice: this.entryPrice,
      stopPrice: this.stopPrice,
      targetPrice: this.targetPrice,
      riskRewardRatio: this.getCurrentRiskRewardRatio(),
      amount: this.amount,
      tickSize: this.tickSize,
      settings: { ...this.settings },
    };
  }

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

    const next = state as Partial<SliderPositionState>;

    this.hidden = next.hidden ?? this.hidden;
    this.mode = next.mode ?? this.mode;

    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,

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

    if (dragTarget === 'end') {
      cursorStyle = 'ew-resize';
    }

    return {
      cursorStyle,
      externalId: 'slider-position',
      zOrder: 'top',
    };
  }

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

    const bounds = this.getTimeBounds();

    if (!bounds) {
      return [];
    }

    const { colors } = getThemeStore();

    return [
      {
        from: bounds.left,
        to: bounds.right,
        color: colors.axisMarkerAreaFill,
      },
    ];
  }

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

    return [
      {
        from: geometry.profitTop,
        to: geometry.profitBottom,
        color: colors.axisMarkerAreaFill,
      },
      {
        from: geometry.lossTop,
        to: geometry.lossBottom,
        color: colors.axisMarkerAreaFill,
      },
    ];
  }

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

    const labelKind = kind as TimeLabelKind;

    const coordinate =
      this.getTimeCoordinate(labelKind);

    const text =
      this.getTimeText(labelKind);

    if (coordinate === null || !text) {
      return null;
    }

    const { colors } = getThemeStore();

    return {
      coordinate,
      text,
      textColor: colors.chartPriceLineText,
      backgroundColor: colors.axisMarkerLabelFill,
    };
  }

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

    const labelKind = kind as PriceLabelKind;

    const coordinate =
      this.getPriceCoordinate(labelKind);

    const text =
      this.getPriceText(labelKind);

    if (coordinate === null || !text) {
      return null;
    }

    const { colors } = getThemeStore();

    let backgroundColor =
      colors.axisMarkerLabelDefaultFill;

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

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

    return {
      coordinate,
      text,
      textColor: colors.chartPriceLineText,
      backgroundColor,
    };
  }

  protected bindEvents(): void {
    if (this.isBound) {
      return;
    }

    super.bindEvents();

    this.chart.subscribeClick(this.clickHandler);
  }

  protected unbindEvents(): void {
    if (!this.isBound) {
      return;
    }

    this.chart.unsubscribeClick(this.clickHandler);

    super.unbindEvents();
  }

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

    const point =
      this.getEventPoint(event as PointerEvent);

    if (!this.containsPoint(point)) {
      return;
    }

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

    this.openSettings?.();
  };

  private handleChartClick(
    params: MouseEventParams<Time>,
  ): void {
    // todo: привести к общему виду дровингов как handleChartClick => startDrawing
    if (
      this.hidden ||
      !params.point ||
      this.mode !== 'idle'
    ) {
      return;
    }

    const anchor = this.createAnchor(params);

    if (!anchor) {
      return;
    }

    const distance =
      this.getInitialZoneDistance(anchor.price);

    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.mode = 'ready';

    this.resolveReady?.();

    this.render();
  }

  protected handlePointerDown = (
    event: PointerEvent,
  ): void => {
    if (
      this.hidden ||
      this.mode !== 'ready' ||
      event.button !== 0
    ) {
      return;
    }

    const point = this.getEventPoint(event);

    if (!this.isSelected()) {
      if (!this.containsPoint(point)) {
        return;
      }

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

      this.select();

      return;
    }

    const dragTarget =
      this.getHandleTarget(point);

    if (!dragTarget) {
      this.deselect();

      return;
    }

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

    this.activeDragTarget = dragTarget;
    this.dragPointerId = event.pointerId;
    this.dragStartPoint = point;
    this.dragStateSnapshot = this.getState();

    this.mode = 'dragging';
  };

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

    event.preventDefault();

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

    this.render();
  };

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

    this.activeDragTarget = null;
    this.dragPointerId = null;
    this.dragStartPoint = null;
    this.dragStateSnapshot = null;

    this.mode = 'ready';

    this.resolveReady?.();

    this.render();
  };

  private applyDrag(point: Point): void {
    const snapshot =
      this.dragStateSnapshot;

    if (!snapshot) {
      return;
    }

    switch (this.activeDragTarget) {
      case 'body':
        this.moveWhole(snapshot, point);
        break;

      case 'entry':
        this.moveEntry(snapshot, point);
        break;

      case 'target':
        this.moveTarget(snapshot, point);
        break;

      case 'stop':
        this.moveStop(snapshot, point);
        break;

      case 'end':
        this.resizeEnd(snapshot, point);
        break;

      default:
        break;
    }
  }

  private moveEntry(
    snapshot: SliderPositionState,
    point: Point,
  ): void {
    if (
      snapshot.stopPrice === null ||
      snapshot.targetPrice === null
    ) {
      return;
    }

    const nextPrice =
      getPriceFromYCoordinate(
        this.series,
        point.y,
      );

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

    const minPrice = Math.min(
      snapshot.stopPrice,
      snapshot.targetPrice,
    );

    const maxPrice = Math.max(
      snapshot.stopPrice,
      snapshot.targetPrice,
    );

    this.entryPrice = Math.max(
      minPrice,
      Math.min(nextPrice, maxPrice),
    );
  }

  private moveWhole(
    snapshot: SliderPositionState,
    point: Point,
  ): void {
    if (
      snapshot.startTime === null ||
      snapshot.endTime === null
    ) {
      return;
    }

    if (
      snapshot.entryPrice === null ||
      snapshot.stopPrice === null ||
      snapshot.targetPrice === null
    ) {
      return;
    }

    if (!this.dragStartPoint) {
      return;
    }

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

    const priceOffset =
      this.getPriceDelta(
        this.dragStartPoint.y,
        point.y,
      );

    const nextStartTime =
      this.shiftTime(
        snapshot.startTime,
        timeOffset,
      );

    const nextEndTime =
      this.shiftTime(
        snapshot.endTime,
        timeOffset,
      );

    if (
      nextStartTime === null ||
      nextEndTime === null
    ) {
      return;
    }

    let nextEntryPrice =
      snapshot.entryPrice +
      priceOffset;

    let nextStopPrice =
      snapshot.stopPrice +
      priceOffset;

    let nextTargetPrice =
      snapshot.targetPrice +
      priceOffset;

    const range =
      this.getPriceScaleRange();

    if (range) {
      const minValue = Math.min(
        nextEntryPrice,
        nextStopPrice,
        nextTargetPrice,
      );

      const maxValue = Math.max(
        nextEntryPrice,
        nextStopPrice,
        nextTargetPrice,
      );

      if (minValue < range.min) {
        const shift =
          range.min -
          minValue;

        nextEntryPrice += shift;
        nextStopPrice += shift;
        nextTargetPrice += shift;
      }

      if (maxValue > range.max) {
        const shift =
          maxValue -
          range.max;

        nextEntryPrice -= shift;
        nextStopPrice -= shift;
        nextTargetPrice -= shift;
      }
    }

    this.startTime = nextStartTime;
    this.endTime = nextEndTime;

    this.entryPrice = nextEntryPrice;
    this.stopPrice = nextStopPrice;
    this.targetPrice = nextTargetPrice;
  }

  private moveStop(
    snapshot: SliderPositionState,
    point: Point,
  ): void {
    if (snapshot.entryPrice === null) {
      return;
    }

    const nextPrice =
      getPriceFromYCoordinate(
        this.series,
        point.y,
      );

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

    this.stopPrice =
      this.normalizeStop(
        snapshot.entryPrice,
        this.clampPriceToRange(nextPrice),
      );
  }

  private moveTarget(
    snapshot: SliderPositionState,
    point: Point,
  ): void {
    if (snapshot.entryPrice === null) {
      return;
    }

    const nextPrice =
      getPriceFromYCoordinate(
        this.series,
        point.y,
      );

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

    this.targetPrice =
      this.normalizeTarget(
        snapshot.entryPrice,
        this.clampPriceToRange(nextPrice),
      );
  }

  private resizeEnd(
    snapshot: SliderPositionState,
    point: Point,
  ): void {
    const nextEndTime =
      getTimeFromXCoordinate(
        this.chart,
        point.x,
      );

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

    this.startTime =
      snapshot.startTime;

    this.endTime =
      nextEndTime;
  }

  private createAnchor(
    params: MouseEventParams<Time>,
  ): {
    time: Time;
    price: number;
  } | null {
    if (
      !params.point ||
      params.time === undefined
    ) {
      return null;
    }

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

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

    return {
      time: params.time,
      price,
    };
  }

  private normalizeStop(
    entryPrice: number,
    rawPrice: number,
    minDistance = 0,
  ): 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 = 0,
  ): 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 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,
    );

    const reward = Math.abs(
      this.targetPrice -
      this.entryPrice,
    );

    if (
      risk <= MIN_DISTANCE ||
      reward <= MIN_DISTANCE
    ) {
      return 0;
    }

    return reward / risk;
  }

  private getPriceAtTime(
    time: Time | null,
  ): number | null {
    if (typeof time !== 'number') {
      return null;
    }

    const data =
      this.series.data() ?? [];

    let lastPrice: number | null = null;

    for (const item of data) {
      if (typeof item.time !== 'number') {
        continue;
      }

      if (item.time > time) {
        break;
      }

      if (
        'close' in item &&
        typeof item.close === 'number'
      ) {
        lastPrice = item.close;

        continue;
      }

      if (
        'value' in item &&
        typeof item.value === 'number'
      ) {
        lastPrice = item.value;
      }
    }

    return lastPrice;
  }

  protected getDrawingHandles(): readonly DrawingHandle<SliderHandleId>[] {
    const geometry =
      this.getGeometry();

    if (!geometry) {
      return [];
    }

    return [
      {
        id: 'entry',
        x: geometry.startX,
        y: geometry.entryY,
        shape: 'circle',
      },
      {
        id: 'end',
        x: geometry.endX,
        y: geometry.entryY,
      },
      {
        id: 'target',
        x: geometry.startX,
        y: geometry.targetY,
      },
      {
        id: 'stop',
        x: geometry.startX,
        y: geometry.stopY,
      },
    ];
  }

  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 handle =
      this.getDrawingHandleAtPoint(point);

    if (handle) {
      return handle.id;
    }

    const geometry =
      this.getGeometry();

    if (!geometry) {
      return null;
    }

    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,
    )
      ? 'body'
      : 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,
      BODY_HIT_TOLERANCE,
    );
  }
}



import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';

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

export class SliderPaneRenderer implements IPrimitivePaneRenderer {
  private readonly slider: SliderPosition;

  constructor(slider: SliderPosition) {
    this.slider = slider;
  }

  public draw(target: CanvasRenderingTarget2D): void {
    const data = this.slider.getRenderData();

    if (!data) {
      return;
    }

    target.useBitmapCoordinateSpace(
      ({
        context,
        horizontalPixelRatio,
        verticalPixelRatio,
        bitmapSize,
      }) => {
        const left =
          data.leftX *
          horizontalPixelRatio;

        const right =
          data.rightX *
          horizontalPixelRatio;

        const entryY =
          data.entryY *
          verticalPixelRatio;

        const stopY =
          data.stopY *
          verticalPixelRatio;

        const targetY =
          data.targetY *
          verticalPixelRatio;

        const profitTop =
          data.profitTop *
          verticalPixelRatio;

        const profitBottom =
          data.profitBottom *
          verticalPixelRatio;

        const lossTop =
          data.lossTop *
          verticalPixelRatio;

        const lossBottom =
          data.lossBottom *
          verticalPixelRatio;

        const centerX =
          (left + right) / 2;

        const labelOffsetPx =
          UI.labelOffset *
          verticalPixelRatio;

        const targetBoxHeight =
          getTextBoxHeight(
            data.targetText,
            data,
            UI.sideBoxHeight,
            verticalPixelRatio,
          );

        const centerBoxHeight =
          getTextBoxHeight(
            data.centerText,
            data,
            UI.mainBoxHeight,
            verticalPixelRatio,
          );

        const stopBoxHeight =
          getTextBoxHeight(
            data.stopText,
            data,
            UI.sideBoxHeight,
            verticalPixelRatio,
          );

        const targetLabelCenterY =
          getOuterLabelCenterY(
            targetY,
            data.targetLabelDirection,
            targetBoxHeight,
            labelOffsetPx,
          );

        const stopLabelCenterY =
          getOuterLabelCenterY(
            stopY,
            data.stopLabelDirection,
            stopBoxHeight,
            labelOffsetPx,
          );

        const centerLabelCenterY =
          getCenterLabelCenterY(
            entryY,
            targetY,
            stopY,
            centerBoxHeight,
            labelOffsetPx,
            bitmapSize.height,
          );

        context.save();

        context.fillStyle =
          data.positiveFillColor;

        context.fillRect(
          left,
          profitTop,
          right - left,
          profitBottom - profitTop,
        );

        context.fillStyle =
          data.negativeFillColor;

        context.fillRect(
          left,
          lossTop,
          right - left,
          lossBottom - lossTop,
        );

        context.lineWidth =
          UI.lineWidth *
          Math.max(
            horizontalPixelRatio,
            verticalPixelRatio,
          );

        context.strokeStyle =
          data.lineColor;

        drawHorizontalLine(
          context,
          left,
          right,
          entryY,
        );

        if (data.showLabels) {
          drawTextBox(
            context,
            centerX,
            targetLabelCenterY,
            data.targetText,
            data.positiveFillColor,
            data,
            horizontalPixelRatio,
            verticalPixelRatio,
            UI.sideBoxHeight,
          );

          drawTextBox(
            context,
            centerX,
            centerLabelCenterY,
            data.centerText,
            data.centerBoxColor,
            data,
            horizontalPixelRatio,
            verticalPixelRatio,
            UI.mainBoxHeight,
          );

          drawTextBox(
            context,
            centerX,
            stopLabelCenterY,
            data.stopText,
            data.negativeFillColor,
            data,
            horizontalPixelRatio,
            verticalPixelRatio,
            UI.sideBoxHeight,
          );
        }

        context.restore();
      },
    );
  }
}

function getOuterLabelCenterY(
  coordinate: number,
  direction: 'up' | 'down',
  boxHeight: number,
  labelOffset: number,
): number {
  const offset =
    labelOffset +
    boxHeight / 2;

  return direction === 'up'
    ? coordinate - offset
    : coordinate + offset;
}

function getCenterLabelCenterY(
  entryY: number,
  targetY: number,
  stopY: number,
  boxHeight: number,
  labelOffset: number,
  viewportHeight: number,
): number {
  const targetSpace =
    Math.abs(targetY - entryY);

  const stopSpace =
    Math.abs(stopY - entryY);

  let direction: -1 | 1;

  if (targetSpace < stopSpace) {
    direction =
      stopY < entryY
        ? -1
        : 1;
  } else {
    direction =
      targetY < entryY
        ? -1
        : 1;
  }

  const centerY =
    entryY +
    direction *
      (
        labelOffset +
        boxHeight / 2
      );

  return clampTextBoxCenter(
    centerY,
    boxHeight,
    viewportHeight,
  );
}

function clampTextBoxCenter(
  coordinate: number,
  boxHeight: number,
  viewportHeight: number,
): number {
  if (viewportHeight <= boxHeight) {
    return viewportHeight / 2;
  }

  const halfHeight =
    boxHeight / 2;

  return Math.max(
    halfHeight,
    Math.min(
      coordinate,
      viewportHeight - halfHeight,
    ),
  );
}

function drawHorizontalLine(
  context: CanvasRenderingContext2D,
  left: number,
  right: number,
  y: number,
): void {
  context.beginPath();

  context.moveTo(left, y);
  context.lineTo(right, y);

  context.stroke();
}

function drawTextBox(
  context: CanvasRenderingContext2D,
  centerX: number,
  centerY: number,
  text: string,
  fillColor: string,
  textStyle: 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 boxHeight =
    getTextBoxHeight(
      text,
      textStyle,
      fixedHeight,
      verticalPixelRatio,
    );

  const radius =
    UI.boxRadius *
    Math.max(
      horizontalPixelRatio,
      verticalPixelRatio,
    );

  context.font =
    getTextFont(
      textStyle,
      fontSize,
    );

  context.textAlign = 'center';
  context.textBaseline = 'middle';

  let maxTextWidth = 0;

  for (const line of lines) {
    maxTextWidth =
      Math.max(
        maxTextWidth,
        context.measureText(line).width,
      );
  }

  const width =
    maxTextWidth +
    paddingX * 2;

  const x =
    centerX -
    width / 2;

  const y =
    centerY -
    boxHeight / 2;

  context.fillStyle =
    fillColor;

  context.beginPath();

  drawRoundedRect(
    context,
    x,
    y,
    width,
    boxHeight,
    radius,
  );

  context.fill();

  context.fillStyle =
    textStyle.textColor;

  if (lines.length === 1) {
    context.fillText(
      lines[0],
      centerX,
      centerY,
    );

    context.restore();

    return;
  }

  const textBlockHeight =
    lines.length *
    lineHeight;

  const firstLineCenterY =
    centerY -
    textBlockHeight / 2 +
    lineHeight / 2;

  lines.forEach(
    (line, index) => {
      context.fillText(
        line,
        centerX,
        firstLineCenterY +
          index *
          lineHeight,
      );
    },
  );

  context.restore();
}

function getTextBoxHeight(
  text: string,
  textStyle: SliderPositionTextStyle,
  fixedHeight: number,
  verticalPixelRatio: number,
): number {
  const linesCount =
    text.split('\n').length;

  const lineHeight =
    Math.round(
      textStyle.fontSize *
      UI.lineHeightMultiplier,
    ) *
    verticalPixelRatio;

  const paddingY =
    UI.padding *
    verticalPixelRatio;

  const minBoxHeight =
    fixedHeight *
    verticalPixelRatio;

  return Math.max(
    minBoxHeight,
    linesCount *
      lineHeight +
      paddingY *
      2,
  );
}

function getTextFont(
  style: 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();
}