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


import { CrosshairMode } from 'lightweight-charts';
import { Subject, Subscription } from 'rxjs';

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import { DrawingHandlesPrimitive } from '@core/Drawings/handles';
import {
  getAnchorFromPoint,
  getPointerPoint as getPointerPointFromEvent,
  getRawPointerPoint,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
} from '@core/Drawings/helpers';

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

import type { DrawingHandle } from '@core/Drawings/handles';
import type {
  Anchor,
  AxisLabel,
  AxisSegment,
  Point,
  SeriesApi,
} from '@core/Drawings/types';
import type {
  AutoscaleInfo,
  IChartApi,
  IPrimitivePaneView,
  ISeriesApi,
  ISeriesPrimitive,
  ISeriesPrimitiveAxisView,
  Logical,
  MouseEventParams,
  PrimitiveHoveredItem,
  PrimitivePaneViewZOrder,
  SeriesAttachedParameter,
  SeriesOptionsMap,
  SeriesType,
  Time,
  TouchMouseEventData,
} from 'lightweight-charts';
import type { Observable } from 'rxjs';
import type {
  ChartOptionsModel,
  SettingsTab,
  SettingsValues,
} from '@src/types';

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 DrawingBaseParams {
  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 DrawingBase<
  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 }: DrawingBaseParams) {
    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 isAxisLabelAvailable(): boolean {
    return !this.hidden && !this.isCreationPending();
  }

  protected shouldShowInteractiveAxis(): boolean {
    return this.isSelected() || this.isCreationPending();
  }

  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 createAnchorFromPoint(point: Point): Anchor | null {
    return getAnchorFromPoint(this.chart, this.series, point);
  }

  protected getPointFromAnchor(anchor: Anchor | null): Point | null {
    if (!anchor) {
      return null;
    }

    const x = getXCoordinateFromTime(this.chart, anchor.time, this.series);
    const y = getYCoordinateFromPrice(this.series, anchor.price);

    if (x === null || y === null) {
      return null;
    }

    return {
      x: Number(x),
      y: Number(y),
    };
  }

  protected createTimeAxisView(labelKind: string): CustomTimeAxisView {
    return new CustomTimeAxisView({
      getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
      labelKind,
      viewport: this.container,
    });
  }

  protected createPriceAxisView(labelKind: string): CustomPriceAxisView {
    return new CustomPriceAxisView({
      getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
      labelKind,
      viewport: this.container,
    });
  }

  protected createTimeAxisPaneView(
    zOrder: PrimitivePaneViewZOrder = 'bottom',
  ): CustomTimeAxisPaneView {
    return new CustomTimeAxisPaneView({
      getAxisSegments: () => this.getTimeAxisSegments(),
      zOrder,
    });
  }

  protected createPriceAxisPaneView(
    zOrder: PrimitivePaneViewZOrder = 'bottom',
  ): CustomPriceAxisPaneView {
    return new CustomPriceAxisPaneView({
      getAxisSegments: () => this.getPriceAxisSegments(),
      zOrder,
    });
  }

  protected createAxisLabel(
    coordinate: number | null,
    text: string,
    style?: Partial<Pick<AxisLabel, 'textColor' | 'backgroundColor'>>,
  ): AxisLabel | null {
    if (coordinate === null || !text) {
      return null;
    }

    const { colors } = getThemeStore();

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

  protected createAxisSegment(from: number, to: number, color?: string): AxisSegment {
    const { colors } = getThemeStore();

    return {
      from,
      to,
      color: color ?? colors.axisMarkerAreaFill,
    };
  }

  protected subscribeFormat(
    formatObservable: Observable<ChartOptionsModel> | undefined,
    callback: (format: ChartOptionsModel) => void,
  ): void {
    if (!formatObservable) {
      return;
    }

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

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

import { DrawingBase } from '@core/Drawings/DrawingBase';
import {
  clampPointToContainer as clampPointToContainerInElement,
  getContainerSize as getElementContainerSize,
  getPriceDelta,
  shiftTimeByPixels,
} from '@core/Drawings/helpers';

import type {
  Anchor,
  Bounds,
  ContainerSize,
  Point,
} from '@core/Drawings/types';
import type { SettingsValues } from '@src/types';

export type TwoPointAnchorKind = 'start' | 'end';

export interface TwoPointGeometry extends Bounds {
  startPoint: Point;
  endPoint: Point;
}

export abstract class TwoPointDrawingBase<
  TSettings extends SettingsValues = SettingsValues,
  THandleId extends string = string,
> extends DrawingBase<TSettings, THandleId> {
  protected startAnchor: Anchor | null = null;
  protected endAnchor: Anchor | null = null;

  protected getAnchor(kind: TwoPointAnchorKind): Anchor | null {
    return kind === 'start' ? this.startAnchor : this.endAnchor;
  }

  protected setAnchor(kind: TwoPointAnchorKind, anchor: Anchor | null): void {
    if (kind === 'start') {
      this.startAnchor = anchor;
      return;
    }

    this.endAnchor = anchor;
  }

  protected setAnchors(
    startAnchor: Anchor | null,
    endAnchor: Anchor | null,
  ): void {
    this.startAnchor = startAnchor;
    this.endAnchor = endAnchor;
  }

  protected createAnchor(point: Point): Anchor | null {
    return this.createAnchorFromPoint(point);
  }

  protected setAnchorFromPoint(
    kind: TwoPointAnchorKind,
    point: Point,
  ): boolean {
    const anchor = this.createAnchor(point);

    if (!anchor) {
      return false;
    }

    this.setAnchor(kind, anchor);

    return true;
  }

  protected setAnchorsFromPoints(
    startPoint: Point,
    endPoint: Point,
  ): boolean {
    const startAnchor = this.createAnchor(startPoint);
    const endAnchor = this.createAnchor(endPoint);

    if (!startAnchor || !endAnchor) {
      return false;
    }

    this.setAnchors(startAnchor, endAnchor);

    return true;
  }

  protected getAnchorTimeCoordinate(kind: TwoPointAnchorKind): number | null {
    const point = this.getPointFromAnchor(this.getAnchor(kind));

    return point?.x ?? null;
  }

  protected getAnchorPriceCoordinate(kind: TwoPointAnchorKind): number | null {
    const point = this.getPointFromAnchor(this.getAnchor(kind));

    return point?.y ?? null;
  }

  protected moveAnchorsByPixels(
    startAnchor: Anchor | null,
    endAnchor: Anchor | null,
    dragStartPoint: Point | null,
    point: Point,
  ): boolean {
    if (!dragStartPoint) {
      return false;
    }

    const offsetX = point.x - dragStartPoint.x;
    const priceOffset = getPriceDelta(
      this.series,
      dragStartPoint.y,
      point.y,
    );

    return this.moveAnchors(
      startAnchor,
      endAnchor,
      offsetX,
      priceOffset,
    );
  }

  protected moveAnchorsWithinContainer(
    startAnchor: Anchor | null,
    endAnchor: Anchor | null,
    dragStartPoint: Point | null,
    point: Point,
    bounds: Bounds,
  ): boolean {
    if (!dragStartPoint) {
      return false;
    }

    const { width, height } = this.getContainerSize();
    const rawOffsetX = point.x - dragStartPoint.x;
    const rawOffsetY = point.y - dragStartPoint.y;
    const offsetX = clamp(rawOffsetX, -bounds.left, width - bounds.right);
    const offsetY = clamp(rawOffsetY, -bounds.top, height - bounds.bottom);
    const priceOffset = getPriceDelta(
      this.series,
      dragStartPoint.y,
      dragStartPoint.y + offsetY,
    );

    return this.moveAnchors(
      startAnchor,
      endAnchor,
      offsetX,
      priceOffset,
    );
  }

  protected getContainerSize(): ContainerSize {
    return getElementContainerSize(this.container);
  }

  protected clampPointToContainer(point: Point): Point {
    return clampPointToContainerInElement(point, this.container);
  }

  protected getTwoPointGeometry(): TwoPointGeometry | null {
    const startPoint = this.getPointFromAnchor(this.startAnchor);
    const endPoint = this.getPointFromAnchor(this.endAnchor);

    if (!startPoint || !endPoint) {
      return null;
    }

    return {
      startPoint,
      endPoint,
      left: Math.min(startPoint.x, endPoint.x),
      right: Math.max(startPoint.x, endPoint.x),
      top: Math.min(startPoint.y, endPoint.y),
      bottom: Math.max(startPoint.y, endPoint.y),
    };
  }

  private moveAnchors(
    startAnchor: Anchor | null,
    endAnchor: Anchor | null,
    offsetX: number,
    priceOffset: number,
  ): boolean {
    if (!startAnchor || !endAnchor) {
      return false;
    }

    const nextStartTime = shiftTimeByPixels(
      this.chart,
      startAnchor.time,
      offsetX,
      this.series,
    );

    const nextEndTime = shiftTimeByPixels(
      this.chart,
      endAnchor.time,
      offsetX,
      this.series,
    );

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

    this.startAnchor = {
      time: nextStartTime,
      price: startAnchor.price + priceOffset,
    };

    this.endAnchor = {
      time: nextEndTime,
      price: endAnchor.price + priceOffset,
    };

    return true;
  }
}

















import { clamp } from 'lodash-es';
import {
  IPrimitivePaneView,
  PrimitiveHoveredItem,
  UTCTimestamp,
} from 'lightweight-charts';

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

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

import { TextPaneView } from './paneView';
import {
  createDefaultSettings,
  getTextSettingsTabs,
  TextContentStyle,
  TextSettings,
  TextStyle,
} from './settings';

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

type TextMode = 'idle' | 'dragging' | 'ready';

export interface TextState {
  hidden: boolean;
  mode: TextMode;
  point: Anchor | null;
  settings: TextSettings;
}

interface TextGeometry {
  point: Point;
  left: number;
  right: number;
  top: number;
  bottom: number;
  width: number;
  height: number;
  lines: string[];
  font: string;
  lineHeight: number;
}

export interface TextRenderData
  extends TextGeometry,
    TextStyle,
    TextContentStyle {
  showSelectionBorder: boolean;
}

type TextParams = BaseDrawingParams;

const UI = {
  padding: 6,
};

let measureCanvas: HTMLCanvasElement | null = null;

export class Text extends DrawingBase<TextSettings> implements ISeriesDrawing {
  private readonly openSettings?: () => void;

  protected mode: TextMode = 'idle';
  protected settings: TextSettings = createDefaultSettings();

  private point: Anchor | null = null;
  private dragPointerId: number | null = null;
  private dragStartPoint: Point | null = null;
  private dragGeometrySnapshot: TextGeometry | null = null;

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

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

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

    this.openSettings = openSettings;

    this.paneView = new TextPaneView(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 getSettingsTabs(): SettingsTab[] {
    return getTextSettingsTabs(this.settings);
  }

  public getState(): TextState {
    return {
      hidden: this.hidden,
      mode: this.mode,
      point: this.point,
      settings: { ...this.settings },
    };
  }

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

    const nextState = state as Partial<TextState>;

    this.hidden =
      typeof nextState.hidden === 'boolean'
        ? nextState.hidden
        : this.hidden;

    this.mode = nextState.mode ?? this.mode;

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

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

    this.render();
  }

  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.timeAxisView];
  }

  public priceAxisViews() {
    return [this.priceAxisView];
  }

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      ...geometry,
      ...this.settings,
      showSelectionBorder: this.isSelected(),
    };
  }

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

    return {
      cursorStyle: 'move',
      externalId: 'text',
      zOrder: 'top',
    };
  }

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

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

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

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

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

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

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

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

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

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

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

      this.select();
      return;
    }

    if (containsPoint) {
      event.preventDefault();
      event.stopPropagation();

      this.startDragging(
        point,
        event.pointerId,
      );
      return;
    }

    this.deselect();
  };

  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 handlePointerMove = (event: PointerEvent): void => {
    if (
      this.dragPointerId !== event.pointerId ||
      this.mode !== 'dragging'
    ) {
      return;
    }

    event.preventDefault();

    this.movePoint(
      this.getEventPoint(event),
    );
    this.render();
  };

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

    this.finishDragging();
  };

  private startDrawing(point: Point): void {
    // todo: вынести в абстрактный класс абстрактным методом (и в соседних классах)
    const anchor = this.createAnchorFromPoint(
      clampPointToContainer(
        point,
        this.container,
      ),
    );

    if (!anchor) {
      return;
    }

    this.point = anchor;
    this.mode = 'ready';
    this.resolveReady?.();

    this.render();
  }

  private startDragging(
    point: Point,
    pointerId: number,
  ): void {
    this.mode = 'dragging';
    this.dragPointerId = pointerId;
    this.dragStartPoint = point;
    this.dragGeometrySnapshot = this.getGeometry();

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

  private finishDragging(): void {
    this.mode = 'ready';
    this.resolveReady?.();

    this.dragPointerId = null;
    this.dragStartPoint = null;
    this.dragGeometrySnapshot = null;

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

  private movePoint(eventPoint: Point): void {
    const geometry = this.dragGeometrySnapshot;
    const dragStartPoint = this.dragStartPoint;

    if (!geometry || !dragStartPoint) {
      return;
    }

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

    const offsetX =
      eventPoint.x - dragStartPoint.x;

    const offsetY =
      eventPoint.y - dragStartPoint.y;

    const nextLeft = clamp(
      geometry.left + offsetX,
      0,
      Math.max(
        0,
        width - geometry.width,
      ),
    );

    const nextTop = clamp(
      geometry.top + offsetY,
      0,
      Math.max(
        0,
        height - geometry.height,
      ),
    );

    const anchor = this.createAnchorFromPoint({
      x: nextLeft,
      y: nextTop,
    });

    if (!anchor) {
      return;
    }

    this.point = anchor;
  }

  protected getGeometry(): TextGeometry | null {
    const projectedPoint =
      this.getPointFromAnchor(this.point);

    if (!projectedPoint) {
      return null;
    }

    const {
      width: containerWidth,
      height: containerHeight,
    } = getContainerSize(this.container);

    const anchorPoint: Point = {
      x: clamp(
        Math.round(projectedPoint.x),
        0,
        containerWidth,
      ),
      y: clamp(
        Math.round(projectedPoint.y),
        0,
        containerHeight,
      ),
    };

    const lines =
      getTextLines(this.settings.text);

    const font =
      getFont(this.settings);

    const lineHeight =
      this.settings.fontSize;

    const measured =
      measureTextBlock(
        lines,
        font,
        lineHeight,
      );

    const width = Math.min(
      containerWidth,
      measured.width + UI.padding * 2,
    );

    const height = Math.min(
      containerHeight,
      measured.height + UI.padding * 2,
    );

    const left = clamp(
      anchorPoint.x,
      0,
      Math.max(
        0,
        containerWidth - width,
      ),
    );

    const top = clamp(
      anchorPoint.y,
      0,
      Math.max(
        0,
        containerHeight - height,
      ),
    );

    return {
      point: {
        x: left,
        y: top,
      },
      left,
      right: left + width,
      top,
      bottom: top + height,
      width,
      height,
      lines,
      font,
      lineHeight,
    };
  }

  private containsPoint(point: Point): boolean {
    const geometry = this.getGeometry();

    return (
      geometry !== null &&
      isPointInBounds(
        point,
        geometry,
        2,
      )
    );
  }
}

function getTextLines(text: string): string[] {
  const lines = text.split('\n');

  return lines.length
    ? lines
    : [''];
}

function getFont(settings: TextSettings): string {
  const italic =
    settings.isItalic
      ? 'italic '
      : '';

  const bold =
    settings.isBold
      ? '700 '
      : '';

  return `${italic}${bold}${settings.fontSize}px Inter, sans-serif`;
}

function measureTextBlock(
  lines: string[],
  font: string,
  lineHeight: number,
): {
  width: number;
  height: number;
} {
  const context = getMeasureContext();

  if (!context) {
    const estimatedWidth =
      Math.max(
        ...lines.map((line) =>
          Math.max(
            1,
            line.length,
          ),
        ),
      ) * 8;

    return {
      width: estimatedWidth,
      height: lines.length * lineHeight,
    };
  }

  context.font = font;

  const width = lines.reduce(
    (maxWidth, line) => {
      return Math.max(
        maxWidth,
        context.measureText(
          line || ' ',
        ).width,
      );
    },
    0,
  );

  return {
    width: Math.ceil(width),
    height: lines.length * lineHeight,
  };
}

function getMeasureContext(): CanvasRenderingContext2D | null {
  if (!measureCanvas) {
    measureCanvas =
      document.createElement('canvas');
  }

  return measureCanvas.getContext('2d');
}

















import { clamp } from 'lodash-es';
import {
  IPrimitivePaneView,
  PrimitiveHoveredItem,
} from 'lightweight-charts';

import {
  CustomPriceAxisPaneView,
  CustomTimeAxisPaneView,
} from '@core/Drawings/axis';
import { DrawingBase } from '@core/Drawings/DrawingBase';
import {
  clampPointToContainer,
  getContainerSize,
  isNearPoint,
} from '@core/Drawings/helpers';
import {
  getDistanceToSegment,
  updateViews,
} from '@core/Drawings/utils';

import { TraectoryPaneView } from './paneView';
import {
  createDefaultSettings,
  getTraectorySettingsTabs,
  TraectorySettings,
  TraectoryStyle,
} from './settings';

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

type TraectoryMode =
  | 'idle'
  | 'drawing'
  | 'ready'
  | 'dragging-point'
  | 'dragging-body';

type TraectoryHandleId = `${number}`;
type TraectoryParams = BaseDrawingParams;

export interface TraectoryState {
  hidden: boolean;
  mode: TraectoryMode;
  points: Anchor[];
  settings: TraectorySettings;
}

interface TraectoryGeometry {
  points: Point[];
  left: number;
  right: number;
  top: number;
  bottom: number;
}

export interface TraectoryRenderData
  extends TraectoryGeometry,
    TraectoryStyle {
  previewPoint: Point | null;
  showArrow: boolean;
}

const POINT_HIT_TOLERANCE = 8;
const SEGMENT_HIT_TOLERANCE = 6;
const MIN_POINTS_COUNT = 2;

export class Traectory
  extends DrawingBase<
    TraectorySettings,
    TraectoryHandleId
  >
  implements ISeriesDrawing
{
  private removeSelf?: () => void;
  private openSettings?: () => void;

  protected settings: TraectorySettings =
    createDefaultSettings();

  protected mode: TraectoryMode = 'idle';

  private points: Anchor[] = [];
  private previewAnchor: Anchor | null = null;

  private dragPointerId: number | null = null;
  private dragStartPoint: Point | null = null;
  private dragPointIndex: number | null = null;
  private dragGeometrySnapshot: TraectoryGeometry | null = null;

  private readonly paneView: TraectoryPaneView;
  private readonly timeAxisPaneView: CustomTimeAxisPaneView;
  private readonly priceAxisPaneView: CustomPriceAxisPaneView;

  constructor({
    chart,
    series,
    container,
    interaction,
    removeSelf,
    openSettings,
    initialEvent,
  }: TraectoryParams) {
    super({
      chart,
      series,
      container,
      interaction,
    });

    this.removeSelf = removeSelf;
    this.openSettings = openSettings;

    this.paneView =
      new TraectoryPaneView(this);

    this.timeAxisPaneView =
      this.createTimeAxisPaneView();

    this.priceAxisPaneView =
      this.createPriceAxisPaneView();

    this.series.attachPrimitive(this);

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

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

  public getState(): TraectoryState {
    return {
      hidden: this.hidden,
      mode: this.mode,
      points: this.points,
      settings: {
        ...this.settings,
      },
    };
  }

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

    const nextState =
      state as Partial<TraectoryState>;

    this.hidden =
      typeof nextState.hidden === 'boolean'
        ? nextState.hidden
        : this.hidden;

    this.mode =
      nextState.mode ??
      this.mode;

    this.points =
      Array.isArray(nextState.points)
        ? nextState.points
        : this.points;

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

    this.render();
  }

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

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

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

  public timeAxisPaneViews():
    readonly IPrimitivePaneView[] {
    return [
      this.timeAxisPaneView,
    ];
  }

  public priceAxisPaneViews():
    readonly IPrimitivePaneView[] {
    return [
      this.priceAxisPaneView,
    ];
  }

  public timeAxisViews() {
    return [];
  }

  public priceAxisViews() {
    return [];
  }

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

    const geometry =
      this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      ...geometry,
      previewPoint:
        this.getPreviewPoint(),
      showArrow:
        this.mode !== 'drawing' &&
        geometry.points.length > 1,
      ...this.settings,
    };
  }

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

    if (!geometry) {
      return [];
    }

    return geometry.points
      .map<
        DrawingHandle<TraectoryHandleId>
      >((point, index) => ({
        id: `${index}`,
        ...point,
        shape: 'circle',
      }))
      .reverse();
  }

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

    const point = {
      x,
      y,
    };

    if (!this.isSelected()) {
      if (
        !this.isPointNearTraectory(
          point,
        )
      ) {
        return null;
      }

      return {
        cursorStyle: 'move',
        externalId: 'traectory',
        zOrder: 'top',
      };
    }

    if (
      this.getPointIndexAt(
        point,
      ) !== null
    ) {
      return {
        cursorStyle: 'move',
        externalId: 'traectory',
        zOrder: 'top',
      };
    }

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

    return {
      cursorStyle: 'move',
      externalId: 'traectory',
      zOrder: 'top',
    };
  }

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

    const geometry =
      this.getGeometry();

    if (!geometry) {
      return [];
    }

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

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

    const geometry =
      this.getGeometry();

    if (!geometry) {
      return [];
    }

    return [
      this.createAxisSegment(
        geometry.top,
        geometry.bottom,
      ),
    ];
  }

  protected getPriceAxisLabel(
    _kind: string,
  ): AxisLabel | null {
    return null;
  }

  protected getTimeAxisLabel(
    _kind: string,
  ): AxisLabel | null {
    return null;
  }

  protected handleClick = (
    event: MouseEvent,
  ): void => {
    if (
      this.hidden ||
      this.mode !== 'drawing' ||
      event.detail !== 1
    ) {
      return;
    }

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

    this.appendPoint(
      this.getEventPoint(
        event as PointerEvent,
      ),
    );
  };

  protected handleDoubleClick = (
    event: MouseEvent,
  ): void => {
    if (this.hidden) {
      return;
    }

    if (
      this.mode === 'drawing'
    ) {
      event.preventDefault();
      event.stopPropagation();

      this.finishDrawing();
      return;
    }

    if (
      this.mode !== 'ready' ||
      !this.isSelected()
    ) {
      return;
    }

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

    if (
      this.getPointIndexAt(
        point,
      ) === null &&
      !this.isPointNearTraectory(
        point,
      )
    ) {
      return;
    }

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

    this.openSettings?.();
  };

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

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

    this.finishDrawing();
  };

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

    const point =
      this.getEventPoint(event);

    if (this.mode === 'idle') {
      event.preventDefault();
      event.stopPropagation();

      this.startDrawing(point);
      return;
    }

    if (
      this.mode === 'drawing'
    ) {
      return;
    }

    if (
      this.mode !== 'ready'
    ) {
      return;
    }

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

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

      this.select();
      return;
    }

    const pointIndex =
      this.getPointIndexAt(point);

    if (pointIndex !== null) {
      event.preventDefault();
      event.stopPropagation();

      this.startDraggingPoint(
        point,
        event.pointerId,
        pointIndex,
      );
      return;
    }

    if (
      !this.isPointNearTraectory(
        point,
      )
    ) {
      this.deselect();
      return;
    }

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

    this.startDraggingBody(
      point,
      event.pointerId,
    );
  };

  protected handlePointerMove = (
    event: PointerEvent,
  ): void => {
    const point =
      this.getEventPoint(event);

    if (
      this.mode === 'drawing'
    ) {
      this.updatePreview(point);
      return;
    }

    if (
      this.dragPointerId !==
      event.pointerId
    ) {
      return;
    }

    if (
      this.mode ===
      'dragging-point'
    ) {
      event.preventDefault();

      this.movePoint(point);
      this.render();
      return;
    }

    if (
      this.mode ===
      'dragging-body'
    ) {
      event.preventDefault();

      this.moveBody(point);
      this.render();
    }
  };

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

    if (
      this.mode ===
        'dragging-point' ||
      this.mode ===
        'dragging-body'
    ) {
      this.finishDragging();
    }
  };

  private startDrawing(
    point: Point,
  ): void {
    const anchor =
      this.createAnchorFromPoint(
        clampPointToContainer(
          point,
          this.container,
        ),
      );

    if (!anchor) {
      return;
    }

    this.points = [
      anchor,
    ];

    this.previewAnchor =
      anchor;

    this.mode = 'drawing';

    this.render();
  }

  private appendPoint(
    point: Point,
  ): void {
    const anchor =
      this.createAnchorFromPoint(
        clampPointToContainer(
          point,
          this.container,
        ),
      );

    if (!anchor) {
      return;
    }

    const lastPoint =
      this.points[
        this.points.length - 1
      ];

    if (
      lastPoint &&
      Number(lastPoint.time) ===
        Number(anchor.time) &&
      lastPoint.price ===
        anchor.price
    ) {
      this.previewAnchor =
        anchor;

      this.render();
      return;
    }

    this.points = [
      ...this.points,
      anchor,
    ];

    this.previewAnchor =
      anchor;

    this.render();
  }

  private updatePreview(
    point: Point,
  ): void {
    const anchor =
      this.createAnchorFromPoint(
        clampPointToContainer(
          point,
          this.container,
        ),
      );

    if (!anchor) {
      return;
    }

    this.previewAnchor =
      anchor;

    this.render();
  }

  private finishDrawing(): void {
    if (
      this.points.length <
      MIN_POINTS_COUNT
    ) {
      if (this.removeSelf) {
        this.removeSelf();
        return;
      }

      this.resetToIdle();
      return;
    }

    this.previewAnchor = null;
    this.mode = 'ready';
    this.resolveReady?.();

    this.render();
  }

  private startDraggingPoint(
    point: Point,
    pointerId: number,
    pointIndex: number,
  ): void {
    this.mode =
      'dragging-point';

    this.dragPointerId =
      pointerId;

    this.dragStartPoint =
      point;

    this.dragPointIndex =
      pointIndex;

    this.dragGeometrySnapshot =
      this.getGeometry();

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

  private startDraggingBody(
    point: Point,
    pointerId: number,
  ): void {
    this.mode =
      'dragging-body';

    this.dragPointerId =
      pointerId;

    this.dragStartPoint =
      point;

    this.dragPointIndex = null;

    this.dragGeometrySnapshot =
      this.getGeometry();

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

  private finishDragging(): void {
    this.mode = 'ready';
    this.resolveReady?.();

    this.dragPointerId = null;
    this.dragStartPoint = null;
    this.dragPointIndex = null;
    this.dragGeometrySnapshot = null;

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

  private resetToIdle(): void {
    this.mode = 'idle';

    this.points = [];
    this.previewAnchor = null;
    this.dragPointerId = null;
    this.dragStartPoint = null;
    this.dragPointIndex = null;
    this.dragGeometrySnapshot = null;

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

  private movePoint(
    point: Point,
  ): void {
    const geometry =
      this.dragGeometrySnapshot;

    const pointIndex =
      this.dragPointIndex;

    if (
      !geometry ||
      pointIndex === null
    ) {
      return;
    }

    const nextPoints = [
      ...geometry.points,
    ];

    nextPoints[pointIndex] =
      clampPointToContainer(
        point,
        this.container,
      );

    this.setAnchorsFromPoints(
      nextPoints,
    );
  }

  private moveBody(
    point: Point,
  ): void {
    const geometry =
      this.dragGeometrySnapshot;

    const dragStartPoint =
      this.dragStartPoint;

    if (
      !geometry ||
      !dragStartPoint
    ) {
      return;
    }

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

    const rawOffsetX =
      point.x -
      dragStartPoint.x;

    const rawOffsetY =
      point.y -
      dragStartPoint.y;

    const offsetX = clamp(
      rawOffsetX,
      -geometry.left,
      width - geometry.right,
    );

    const offsetY = clamp(
      rawOffsetY,
      -geometry.top,
      height - geometry.bottom,
    );

    const nextPoints =
      geometry.points.map(
        (item) => {
          return clampPointToContainer(
            {
              x:
                item.x +
                offsetX,
              y:
                item.y +
                offsetY,
            },
            this.container,
          );
        },
      );

    this.setAnchorsFromPoints(
      nextPoints,
    );
  }

  private setAnchorsFromPoints(
    points: Point[],
  ): void {
    const nextAnchors:
      Anchor[] = [];

    for (const point of points) {
      const anchor =
        this.createAnchorFromPoint(
          point,
        );

      if (!anchor) {
        return;
      }

      nextAnchors.push(
        anchor,
      );
    }

    this.points =
      nextAnchors;
  }

  protected getGeometry():
    TraectoryGeometry | null {
    if (!this.points.length) {
      return null;
    }

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

    const screenPoints:
      Point[] = [];

    for (
      const anchor of this.points
    ) {
      const point =
        this.getPointFromAnchor(
          anchor,
        );

      if (!point) {
        return null;
      }

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

    return {
      points: screenPoints,
      left: Math.min(
        ...screenPoints.map(
          (point) => point.x,
        ),
      ),
      right: Math.max(
        ...screenPoints.map(
          (point) => point.x,
        ),
      ),
      top: Math.min(
        ...screenPoints.map(
          (point) => point.y,
        ),
      ),
      bottom: Math.max(
        ...screenPoints.map(
          (point) => point.y,
        ),
      ),
    };
  }

  private getPreviewPoint():
    Point | null {
    if (
      this.mode !== 'drawing' ||
      !this.previewAnchor
    ) {
      return null;
    }

    const point =
      this.getPointFromAnchor(
        this.previewAnchor,
      );

    if (!point) {
      return null;
    }

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

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

  private getPointIndexAt(
    point: Point,
  ): number | null {
    const handle =
      this.getDrawingHandleAtPoint(
        point,
      );

    return handle
      ? Number(handle.id)
      : null;
  }

  private isPointNearTraectory(
    point: Point,
  ): boolean {
    const geometry =
      this.getGeometry();

    if (!geometry) {
      return false;
    }

    if (
      geometry.points.length === 1
    ) {
      const firstPoint =
        geometry.points[0];

      return isNearPoint(
        point,
        firstPoint.x,
        firstPoint.y,
        POINT_HIT_TOLERANCE,
      );
    }

    for (
      let index = 0;
      index <
      geometry.points.length - 1;
      index += 1
    ) {
      const startPoint =
        geometry.points[index];

      const endPoint =
        geometry.points[
          index + 1
        ];

      if (
        getDistanceToSegment(
          point,
          startPoint,
          endPoint,
        ) <=
        SEGMENT_HIT_TOLERANCE
      ) {
        return true;
      }
    }

    return false;
  }
}