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


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 {
  getPointerPoint as getPointerPointFromEvent,
  getRawPointerPoint,
} from '@core/Drawings/helpers';

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

import type { DrawingHandle } from '@core/Drawings/handles';
import type {
  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 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,
      ),
    );
  }
}

















export {
  DrawingBase as SeriesDrawingBase,
} from './DrawingBase';

export type {
  BaseDrawingParams,
  DrawingInteraction,
  ISeriesDrawing,
  StartPoint,
} from './DrawingBase';

















import { clamp } from 'lodash-es';

import type {
  Anchor,
  Bounds,
  ContainerSize,
  Point,
  SeriesApi,
} from './types';
import type {
  Coordinate,
  IChartApi,
  Time,
  TouchMouseEventData,
} from 'lightweight-charts';

interface SeriesTimeItem {
  time: Time;
}

interface SeriesTimePoint {
  time: Time;
  timestamp: 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;
  }

  return getProjectedXCoordinate(
    chart,
    series,
    time,
  );
}

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 getProjectedXCoordinate(
  chart: IChartApi,
  series: SeriesApi,
  time: Time,
): Coordinate | null {
  const targetTime =
    getNumericTime(time);

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

  const points =
    getSeriesTimePoints(series);

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

  if (points.length === 1) {
    return getCoordinateForTime(
      chart,
      points[0].time,
    );
  }

  const lastIndex =
    points.length - 1;

  if (
    targetTime <=
    points[0].timestamp
  ) {
    return interpolateCoordinate(
      chart,
      targetTime,
      points[0],
      points[1],
    );
  }

  if (
    targetTime >=
    points[lastIndex].timestamp
  ) {
    return interpolateCoordinate(
      chart,
      targetTime,
      points[lastIndex - 1],
      points[lastIndex],
    );
  }

  let left = 0;
  let right = lastIndex;

  while (left <= right) {
    const middleIndex =
      Math.floor(
        (left + right) / 2,
      );

    const middlePoint =
      points[middleIndex];

    if (
      middlePoint.timestamp ===
      targetTime
    ) {
      return getCoordinateForTime(
        chart,
        middlePoint.time,
      );
    }

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

  return interpolateCoordinate(
    chart,
    targetTime,
    points[right],
    points[left],
  );
}

function interpolateCoordinate(
  chart: IChartApi,
  targetTime: number,
  startPoint: SeriesTimePoint,
  endPoint: SeriesTimePoint,
): Coordinate | null {
  const startCoordinate =
    getCoordinateForTime(
      chart,
      startPoint.time,
    );

  const endCoordinate =
    getCoordinateForTime(
      chart,
      endPoint.time,
    );

  if (
    startCoordinate === null ||
    endCoordinate === null
  ) {
    return null;
  }

  const timeRange =
    endPoint.timestamp -
    startPoint.timestamp;

  if (timeRange === 0) {
    return startCoordinate;
  }

  const ratio =
    (targetTime -
      startPoint.timestamp) /
    timeRange;

  const coordinate =
    Number(startCoordinate) +
    (Number(endCoordinate) -
      Number(startCoordinate)) *
      ratio;

  if (!Number.isFinite(coordinate)) {
    return null;
  }

  return coordinate as Coordinate;
}

function getCoordinateForTime(
  chart: IChartApi,
  time: Time,
): Coordinate | null {
  const coordinate =
    chart.timeScale().timeToCoordinate(time);

  return isValidCoordinate(coordinate)
    ? coordinate
    : null;
}

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

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

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

      points.push({
        time: item.time,
        timestamp,
      });

      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 {
  getAnchorFromPoint,
  getPriceDelta,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { DrawingBase } from '@core/Drawings/DrawingBase';

import type {
  Anchor,
  Bounds,
  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 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 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 anchor =
      this.getAnchor(kind);

    if (!anchor) {
      return null;
    }

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

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

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

    if (!anchor) {
      return null;
    }

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

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

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

    const offsetX =
      point.x -
      dragStartPoint.x;

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

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

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

















import { TwoPointDrawingBase } from '@core/Drawings/TwoPointDrawingBase';
import { getDistanceToSegment } from '@core/Drawings/utils';

import type { DrawingHandle } from '@core/Drawings/handles';
import type {
  AxisSegment,
  Point,
} from '@core/Drawings/types';
import type { SettingsValues } from '@src/types';

export abstract class LinearDrawingBase<
  TSettings extends SettingsValues = SettingsValues,
  THandleId extends string = string,
> extends TwoPointDrawingBase<
  TSettings,
  THandleId
> {
  protected abstract getStartHandleId(): THandleId;

  protected abstract getEndHandleId(): THandleId;

  protected getLineHandleStrokeColor():
    string | undefined {
    return undefined;
  }

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

    if (!geometry) {
      return [];
    }

    const strokeColor =
      this.getLineHandleStrokeColor();

    const handleStyle =
      strokeColor
        ? {
            strokeColor,
          }
        : {};

    return [
      {
        id: this.getStartHandleId(),
        ...geometry.startPoint,
        shape: 'circle',
        ...handleStyle,
      },
      {
        id: this.getEndHandleId(),
        ...geometry.endPoint,
        shape: 'circle',
        ...handleStyle,
      },
    ];
  }

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

    const geometry =
      this.getTwoPointGeometry();

    if (!geometry) {
      return [];
    }

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

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

    const geometry =
      this.getTwoPointGeometry();

    if (!geometry) {
      return [];
    }

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

  protected isPointNearLine(
    point: Point,
    tolerance: number,
  ): boolean {
    const geometry =
      this.getTwoPointGeometry();

    if (!geometry) {
      return false;
    }

    return (
      getDistanceToSegment(
        point,
        geometry.startPoint,
        geometry.endPoint,
      ) <= tolerance
    );
  }
}

















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

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import { LinearDrawingBase } from '@core/Drawings/LinearDrawingBase';
import { updateViews } from '@core/Drawings/utils';

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

import { LineDrawingPaneView } from './paneView';
import {
  createDefaultSettings,
  getLineDrawingSettingsTabs,
} from './settings';

import type {
  LineDrawingMarkers,
  LineDrawingSettings,
  LineDrawingStyle,
  LineDrawingTextStyle,
} from './settings';
import type {
  Anchor,
  AxisLabel,
  Point,
} from '@core/Drawings/types';
import type {
  TwoPointGeometry,
} from '@core/Drawings/TwoPointDrawingBase';
import type {
  BaseDrawingParams,
  ISeriesDrawing,
} from '@core/Drawings/DrawingBase';

type LineDrawingMode =
  | 'idle'
  | 'drawing'
  | 'ready'
  | 'dragging-start'
  | 'dragging-end'
  | 'dragging-body';

type LineDrawingHandleKey =
  | 'start'
  | 'end';

type TimeLabelKind =
  | 'start'
  | 'end';

type PriceLabelKind =
  | 'start'
  | 'end';

interface LineDrawingParams
  extends BaseDrawingParams
{
  defaultMarkers?: Partial<LineDrawingMarkers>;
}

interface LineDrawingState {
  hidden: boolean;
  mode: LineDrawingMode;
  startAnchor: Anchor | null;
  endAnchor: Anchor | null;
  settings: LineDrawingSettings;
}

export interface LineDrawingRenderData
  extends TwoPointGeometry,
    LineDrawingStyle,
    LineDrawingTextStyle {}

const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;

export class LineDrawing
  extends LinearDrawingBase<
    LineDrawingSettings,
    LineDrawingHandleKey
  >
  implements ISeriesDrawing
{
  private removeSelf?: () => void;
  private openSettings?: () => void;

  private readonly defaultMarkers:
    LineDrawingMarkers;

  protected settings: LineDrawingSettings;
  protected mode: LineDrawingMode = 'idle';

  private dragPointerId: number | null = null;
  private dragStartPoint: Point | null = null;

  private dragStateSnapshot:
    LineDrawingState | null = null;

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

  private readonly paneView:
    LineDrawingPaneView;

  private readonly timeAxisPaneView:
    CustomTimeAxisPaneView;

  private readonly priceAxisPaneView:
    CustomPriceAxisPaneView;

  private readonly startTimeAxisView:
    CustomTimeAxisView;

  private readonly endTimeAxisView:
    CustomTimeAxisView;

  private readonly startPriceAxisView:
    CustomPriceAxisView;

  private readonly endPriceAxisView:
    CustomPriceAxisView;

  constructor({
    chart,
    series,
    container,
    interaction,
    formatObservable,
    removeSelf,
    openSettings,
    initialEvent,
    defaultMarkers = {},
  }: LineDrawingParams) {
    super({
      chart,
      series,
      container,
      interaction,
    });

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

    this.defaultMarkers = {
      startMarker:
        LineMarker.normal,
      endMarker:
        LineMarker.normal,
      ...defaultMarkers,
    };

    this.settings =
      createDefaultSettings(
        this.defaultMarkers,
      );

    this.paneView =
      new LineDrawingPaneView(this);

    this.timeAxisPaneView =
      this.createTimeAxisPaneView();

    this.priceAxisPaneView =
      this.createPriceAxisPaneView();

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

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

    this.startPriceAxisView =
      this.createPriceAxisView('start');

    this.endPriceAxisView =
      this.createPriceAxisView('end');

    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' ||
      this.mode === 'drawing'
    );
  }

  public getState(): LineDrawingState {
    return {
      hidden: this.hidden,
      mode: this.mode,
      startAnchor: this.startAnchor,
      endAnchor: this.endAnchor,
      settings: {
        ...this.settings,
      },
    };
  }

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

    const nextState =
      state as Partial<LineDrawingState>;

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

    if (
      'mode' in nextState &&
      nextState.mode
    ) {
      this.mode =
        nextState.mode;
    }

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

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

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

    this.render();
  }

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

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

  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.startPriceAxisView,
      this.endPriceAxisView,
    ];
  }

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

    const geometry =
      this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      ...geometry,
      ...this.settings,
    };
  }

  protected getGeometry():
    TwoPointGeometry | null {
    return this.getTwoPointGeometry();
  }

  protected getStartHandleId():
    LineDrawingHandleKey {
    return 'start';
  }

  protected getEndHandleId():
    LineDrawingHandleKey {
    return 'end';
  }

  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.getDrawingHandleAtPoint(
        point,
      )
    ) {
      return {
        cursorStyle: 'move',
        externalId:
          'line-drawing',
        zOrder: 'top',
      };
    }

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

    return {
      cursorStyle: 'grab',
      externalId:
        'line-drawing',
      zOrder: 'top',
    };
  }

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

    const labelKind =
      kind as TimeLabelKind;

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

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

    const labelKind =
      kind as PriceLabelKind;

    return this.createAxisLabel(
      this.getAnchorPriceCoordinate(
        labelKind,
      ),
      this.getPriceText(
        labelKind,
      ),
    );
  }

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

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

    if (
      !this.getDrawingHandleAtPoint(
        point,
      ) &&
      !this.isPointNearLine(
        point,
        LINE_HIT_TOLERANCE,
      )
    ) {
      return;
    }

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

    this.openSettings?.();
  };

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

    const point =
      this.getEventPoint(event);

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

      if (this.mode === 'idle') {
        this.startDrawing(point);
      } else {
        this.updateDrawing(point);
        this.finishDrawing();
      }

      return;
    }

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

    const pointTarget =
      this.getDrawingHandleAtPoint(
        point,
      )?.id ?? null;

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

    const isDrawingHit =
      pointTarget !== null ||
      isNearLine;

    const isSelected =
      this.isSelected();

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

      return;
    }

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

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

    const dragMode:
      LineDrawingMode =
        `dragging-${pointTarget ?? 'body'}`;

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

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

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

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

    if (
      this.mode ===
        'dragging-start' ||
      this.mode ===
        'dragging-end'
    ) {
      event.preventDefault();
      event.stopPropagation();

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

      return;
    }

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

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

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

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

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

    if (!anchor) {
      return;
    }

    this.setAnchors(
      anchor,
      anchor,
    );

    this.mode = 'drawing';

    this.render();
  }

  private updateDrawing(
    point: Point,
  ): void {
    if (
      !this.setAnchorFromPoint(
        'end',
        point,
      )
    ) {
      return;
    }

    this.render();
  }

  private finishDrawing(): void {
    const geometry =
      this.getGeometry();

    if (!geometry) {
      return;
    }

    const lineSize =
      Math.hypot(
        geometry.endPoint.x -
          geometry.startPoint.x,
        geometry.endPoint.y -
          geometry.startPoint.y,
      );

    if (
      lineSize <
      MIN_LINE_SIZE
    ) {
      this.removeSelf?.();
      return;
    }

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

    this.render();
  }

  private startDragging(
    mode: LineDrawingMode,
    point: Point,
    pointerId: number,
  ): void {
    this.mode = mode;

    this.dragPointerId =
      pointerId;

    this.dragStartPoint =
      point;

    this.dragStateSnapshot =
      this.getState();

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

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

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

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

  private movePoint(
    point: Point,
  ): void {
    if (
      this.mode ===
      'dragging-start'
    ) {
      this.setAnchorFromPoint(
        'start',
        point,
      );

      return;
    }

    if (
      this.mode ===
      'dragging-end'
    ) {
      this.setAnchorFromPoint(
        'end',
        point,
      );
    }
  }

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

    if (!snapshot) {
      return;
    }

    this.moveAnchorsByPixels(
      snapshot.startAnchor,
      snapshot.endAnchor,
      this.dragStartPoint,
      point,
    );
  }

  private getTimeText(
    kind: TimeLabelKind,
  ): string {
    const anchor =
      this.getAnchor(kind);

    if (
      !anchor ||
      typeof anchor.time !== 'number'
    ) {
      return '';
    }

    return formatDate(
      anchor.time as UTCTimestamp,
      this.displayFormat.dateFormat,
      this.displayFormat.timeFormat,
      this.displayFormat.showTime,
    );
  }

  private getPriceText(
    kind: PriceLabelKind,
  ): string {
    const anchor =
      this.getAnchor(kind);

    if (!anchor) {
      return '';
    }

    return (
      formatPrice(
        anchor.price,
      ) ?? ''
    );
  }
}

















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

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import { LinearDrawingBase } from '@core/Drawings/LinearDrawingBase';
import {
  getDistanceToSegment,
  updateViews,
} from '@core/Drawings/utils';

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

import { RayPaneView } from './paneView';
import {
  createDefaultSettings,
  getRaySettingTabs,
} from './settings';

import type {
  RaySettings,
  RayStyle,
  RayTextStyle,
} from './settings';
import type {
  Anchor,
  AxisLabel,
  Point,
} from '@core/Drawings/types';
import type {
  BaseDrawingParams,
  ISeriesDrawing,
} from '@core/Drawings/DrawingBase';
import type {
  ChartOptionsModel,
  SettingsTab,
} from '@src/types';

type RayMode =
  | 'idle'
  | 'drawing'
  | 'ready'
  | 'dragging-start'
  | 'dragging-direction'
  | 'dragging-body';

type RayHandleKey =
  | 'start'
  | 'direction';

type TimeLabelKind =
  | 'start'
  | 'direction';

type PriceLabelKind =
  | 'start'
  | 'direction';

type RayParams =
  BaseDrawingParams;

interface RayState {
  hidden: boolean;
  mode: RayMode;
  startAnchor: Anchor | null;
  directionAnchor: Anchor | null;
  settings: RaySettings;
}

interface RayGeometry {
  startPoint: Point;
  directionPoint: Point;
  rayEndPoint: Point;
  left: number;
  right: number;
  top: number;
  bottom: number;
}

interface RayIntersection {
  point: Point;
  t: number;
}

export interface RayRenderData
  extends RayGeometry,
    RayStyle,
    RayTextStyle {}

const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;

export class Ray
  extends LinearDrawingBase<
    RaySettings,
    RayHandleKey
  >
  implements ISeriesDrawing
{
  private removeSelf?: () => void;
  private openSettings?: () => void;

  protected settings:
    RaySettings =
      createDefaultSettings();

  protected mode:
    RayMode = 'idle';

  private dragPointerId:
    number | null = null;

  private dragStartPoint:
    Point | null = null;

  private dragStateSnapshot:
    RayState | null = null;

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

  private readonly paneView:
    RayPaneView;

  private readonly timeAxisPaneView:
    CustomTimeAxisPaneView;

  private readonly priceAxisPaneView:
    CustomPriceAxisPaneView;

  private readonly startTimeAxisView:
    CustomTimeAxisView;

  private readonly directionTimeAxisView:
    CustomTimeAxisView;

  private readonly startPriceAxisView:
    CustomPriceAxisView;

  private readonly directionPriceAxisView:
    CustomPriceAxisView;

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

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

    this.paneView =
      new RayPaneView(this);

    this.timeAxisPaneView =
      this.createTimeAxisPaneView();

    this.priceAxisPaneView =
      this.createPriceAxisPaneView();

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

    this.directionTimeAxisView =
      this.createTimeAxisView(
        'direction',
      );

    this.startPriceAxisView =
      this.createPriceAxisView(
        'start',
      );

    this.directionPriceAxisView =
      this.createPriceAxisView(
        'direction',
      );

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

    this.series.attachPrimitive(this);

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

  private get directionAnchor():
    Anchor | null {
    return this.endAnchor;
  }

  private set directionAnchor(
    anchor: Anchor | null,
  ) {
    this.endAnchor = anchor;
  }

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

  public getState(): RayState {
    return {
      hidden: this.hidden,
      mode: this.mode,
      startAnchor:
        this.startAnchor,
      directionAnchor:
        this.directionAnchor,
      settings: {
        ...this.settings,
      },
    };
  }

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

    const nextState =
      state as Partial<RayState>;

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

    if (
      'mode' in nextState &&
      nextState.mode
    ) {
      this.mode =
        nextState.mode;
    }

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

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

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

    this.render();
  }

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

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

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

  public priceAxisViews() {
    return [
      this.startPriceAxisView,
      this.directionPriceAxisView,
    ];
  }

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

    const geometry =
      this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      ...geometry,
      ...this.settings,
    };
  }

  protected getStartHandleId():
    RayHandleKey {
    return 'start';
  }

  protected getEndHandleId():
    RayHandleKey {
    return 'direction';
  }

  protected getLineHandleStrokeColor():
    string {
    return this.settings.lineColor;
  }

  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.getDrawingHandleAtPoint(
        point,
      )
    ) {
      return {
        cursorStyle: 'move',
        externalId: 'ray',
        zOrder: 'top',
      };
    }

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

    return {
      cursorStyle: 'grab',
      externalId: 'ray',
      zOrder: 'top',
    };
  }

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

    const labelKind =
      kind as TimeLabelKind;

    const anchorKind =
      labelKind === 'start'
        ? 'start'
        : 'end';

    return this.createAxisLabel(
      this.getAnchorTimeCoordinate(
        anchorKind,
      ),
      this.getTimeText(
        labelKind,
      ),
    );
  }

  protected getPriceAxisLabel(
    kind: string,
  ): AxisLabel | null {
    if (
      !this.shouldShowInteractiveAxis() ||
      (kind !== 'start' &&
        kind !== 'direction')
    ) {
      return null;
    }

    const labelKind =
      kind as PriceLabelKind;

    const anchorKind =
      labelKind === 'start'
        ? 'start'
        : 'end';

    return this.createAxisLabel(
      this.getAnchorPriceCoordinate(
        anchorKind,
      ),
      this.getPriceText(
        labelKind,
      ),
    );
  }

  protected getGeometry():
    RayGeometry | null {
    const geometry =
      this.getTwoPointGeometry();

    if (!geometry) {
      return null;
    }

    const startPoint =
      geometry.startPoint;

    const directionPoint =
      geometry.endPoint;

    const rayEndPoint =
      this.getRayEndPoint(
        startPoint,
        directionPoint,
      );

    if (!rayEndPoint) {
      return null;
    }

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

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

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

    if (
      !this.getDrawingHandleAtPoint(
        point,
      ) &&
      !this.isPointNearRay(
        point,
      )
    ) {
      return;
    }

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

    this.openSettings?.();
  };

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

    const point =
      this.getEventPoint(event);

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

      this.startDrawing(point);
      return;
    }

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

      this.updateDrawing(point);
      this.finishDrawing();

      return;
    }

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

    const pointTarget =
      this.getDrawingHandleAtPoint(
        point,
      )?.id ?? null;

    const isNearRay =
      this.isPointNearRay(point);

    const isDrawingHit =
      pointTarget !== null ||
      isNearRay;

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

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

      this.select();
      return;
    }

    if (
      pointTarget === 'start'
    ) {
      event.preventDefault();
      event.stopPropagation();

      this.startDragging(
        'dragging-start',
        point,
        event.pointerId,
      );

      return;
    }

    if (
      pointTarget ===
      'direction'
    ) {
      event.preventDefault();
      event.stopPropagation();

      this.startDragging(
        'dragging-direction',
        point,
        event.pointerId,
      );

      return;
    }

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

      this.startDragging(
        'dragging-body',
        point,
        event.pointerId,
      );

      return;
    }

    this.deselect();
  };

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

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

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

    if (
      this.mode ===
        'dragging-start' ||
      this.mode ===
        'dragging-direction'
    ) {
      event.preventDefault();
      event.stopPropagation();

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

      return;
    }

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

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

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

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

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

    if (!anchor) {
      return;
    }

    this.setAnchors(
      anchor,
      anchor,
    );

    this.mode = 'drawing';

    this.render();
  }

  private updateDrawing(
    point: Point,
  ): void {
    if (
      !this.setAnchorFromPoint(
        'end',
        point,
      )
    ) {
      return;
    }

    this.render();
  }

  private finishDrawing(): void {
    const geometry =
      this.getTwoPointGeometry();

    if (!geometry) {
      return;
    }

    const lineSize =
      Math.hypot(
        geometry.endPoint.x -
          geometry.startPoint.x,
        geometry.endPoint.y -
          geometry.startPoint.y,
      );

    if (
      lineSize <
      MIN_LINE_SIZE
    ) {
      this.removeSelf?.();
      return;
    }

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

    this.render();
  }

  private startDragging(
    mode: RayMode,
    point: Point,
    pointerId: number,
  ): void {
    this.mode = mode;

    this.dragPointerId =
      pointerId;

    this.dragStartPoint =
      point;

    this.dragStateSnapshot =
      this.getState();

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

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

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

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

  private movePoint(
    point: Point,
  ): void {
    if (
      this.mode ===
      'dragging-start'
    ) {
      this.setAnchorFromPoint(
        'start',
        point,
      );

      return;
    }

    if (
      this.mode ===
      'dragging-direction'
    ) {
      this.setAnchorFromPoint(
        'end',
        point,
      );
    }
  }

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

    if (!snapshot) {
      return;
    }

    this.moveAnchorsByPixels(
      snapshot.startAnchor,
      snapshot.directionAnchor,
      this.dragStartPoint,
      point,
    );
  }

  private getRayEndPoint(
    startPoint: Point,
    directionPoint: Point,
  ): Point | null {
    const dx =
      directionPoint.x -
      startPoint.x;

    const dy =
      directionPoint.y -
      startPoint.y;

    if (
      dx === 0 &&
      dy === 0
    ) {
      return null;
    }

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

    const height =
      this.series
        .getPane()
        .getHeight();

    if (
      width <= 0 ||
      height <= 0
    ) {
      return null;
    }

    const candidates:
      RayIntersection[] = [];

    if (dx !== 0) {
      const leftT =
        -startPoint.x / dx;

      const rightT =
        (width - startPoint.x) /
        dx;

      const leftY =
        startPoint.y +
        leftT * dy;

      const rightY =
        startPoint.y +
        rightT * dy;

      if (
        leftT >= 0 &&
        leftY >= 0 &&
        leftY <= height
      ) {
        candidates.push({
          point: {
            x: 0,
            y: leftY,
          },
          t: leftT,
        });
      }

      if (
        rightT >= 0 &&
        rightY >= 0 &&
        rightY <= height
      ) {
        candidates.push({
          point: {
            x: width,
            y: rightY,
          },
          t: rightT,
        });
      }
    }

    if (dy !== 0) {
      const topT =
        -startPoint.y / dy;

      const bottomT =
        (height - startPoint.y) /
        dy;

      const topX =
        startPoint.x +
        topT * dx;

      const bottomX =
        startPoint.x +
        bottomT * dx;

      if (
        topT >= 0 &&
        topX >= 0 &&
        topX <= width
      ) {
        candidates.push({
          point: {
            x: topX,
            y: 0,
          },
          t: topT,
        });
      }

      if (
        bottomT >= 0 &&
        bottomX >= 0 &&
        bottomX <= width
      ) {
        candidates.push({
          point: {
            x: bottomX,
            y: height,
          },
          t: bottomT,
        });
      }
    }

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

    return candidates.reduce(
      (farthest, candidate) => {
        return (
          candidate.t >
          farthest.t
            ? candidate
            : farthest
        );
      },
    ).point;
  }

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

    if (!geometry) {
      return false;
    }

    return (
      getDistanceToSegment(
        point,
        geometry.startPoint,
        geometry.rayEndPoint,
      ) <= LINE_HIT_TOLERANCE
    );
  }

  private getTimeText(
    kind: TimeLabelKind,
  ): string {
    const anchor =
      kind === 'start'
        ? this.startAnchor
        : this.directionAnchor;

    if (
      !anchor ||
      typeof anchor.time !== 'number'
    ) {
      return '';
    }

    return formatDate(
      anchor.time as UTCTimestamp,
      this.displayFormat.dateFormat,
      this.displayFormat.timeFormat,
      this.displayFormat.showTime,
    );
  }

  private getPriceText(
    kind: PriceLabelKind,
  ): string {
    const anchor =
      kind === 'start'
        ? this.startAnchor
        : this.directionAnchor;

    if (!anchor) {
      return '';
    }

    return (
      formatPrice(
        anchor.price,
      ) ?? ''
    );
  }
}