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


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

import {
  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 { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { ChartOptionsModel, SettingsTab, SettingsValues } from '@src/types';
import { Defaults } from '@src/types/defaults';

import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, AxisSegment, Point, SeriesApi, UpdatableView } from '@core/Drawings/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 {
  chart: IChartApi;
  series: SeriesApi;
  container: HTMLElement;
  interaction: DrawingInteraction;
  removeSelf?: () => void;
  openSettings?: () => void;
}

export interface BaseDrawingParams extends DrawingBaseParams {
  formatObservable?: Observable<ChartOptionsModel>;
  initialEvent?: MouseEventParams;
}

export interface AxisBoundsGeometry {
  left: number;
  right: number;
  top: number;
  bottom: number;
}

export abstract class DrawingBase<
  TSettings extends SettingsValues = SettingsValues,
  THandleId extends string = string,
  TMode extends string = string,
  TGeometry = unknown,
> implements ISeriesDrawing {
  protected hidden = false;
  protected chart: IChartApi;
  protected series: SeriesApi;
  protected subscriptions = new Subscription();
  protected abstract mode: TMode;
  protected abstract settings: TSettings;
  protected readonly container: HTMLElement;
  protected readonly removeSelf?: () => void;
  protected readonly openSettings?: () => void;
  protected displayFormat: ChartOptionsModel = {
    dateFormat: Defaults.dateFormat,
    timeFormat: Defaults.timeFormat,
    showTime: Defaults.showTime,
  };
  protected readyPromise: Promise<void> | null = null;
  protected resolveReady: (() => void) | null = null;
  protected requestUpdate: (() => void) | null = null;

  private readonly interaction: DrawingInteraction;
  private readonly settingsSubject = new Subject<SettingsValues>();
  private readonly handlesPrimitive: DrawingHandlesPrimitive<THandleId>;
  private paneViewsList: readonly (IPrimitivePaneView & UpdatableView)[] = [];
  private timeAxisPaneViewsList: readonly CustomTimeAxisPaneView[] = [];
  private priceAxisPaneViewsList: readonly CustomPriceAxisPaneView[] = [];
  private timeAxisViewsList: readonly CustomTimeAxisView[] = [];
  private priceAxisViewsList: readonly CustomPriceAxisView[] = [];
  private isInteractionBound = false;
  private isBound = false;

  private readonly onPointerMove = (event: PointerEvent): void => {
    this.handlePointerMove(event);
  };

  private readonly onPointerUp = (event: PointerEvent): void => {
    this.handlePointerUp(event);
  };

  constructor({ chart, series, container, interaction, removeSelf, openSettings }: DrawingBaseParams) {
    this.chart = chart;
    this.series = series;
    this.container = container;
    this.interaction = interaction;
    this.removeSelf = removeSelf;
    this.openSettings = openSettings;
    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;
    }
    this.handleLockedPointerDown(event);
  }

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

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

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

  public abstract getRenderData(): unknown;
  public abstract getState(): unknown;
  public abstract getSettingsTabs(): SettingsTab[];
  public abstract isCreationPending(): boolean;
  public abstract setState(state: unknown): void;

  public updateAllViews(): void {
    updateViews([
      ...this.paneViewsList,
      ...this.timeAxisPaneViewsList,
      ...this.priceAxisPaneViewsList,
      ...this.timeAxisViewsList,
      ...this.priceAxisViewsList,
    ]);
  }

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

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

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

  public timeAxisViews(): readonly ISeriesPrimitiveAxisView[] {
    return this.timeAxisViewsList;
  }

  public priceAxisViews(): readonly ISeriesPrimitiveAxisView[] {
    return this.priceAxisViewsList;
  }

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

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

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

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

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

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

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

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

  protected render(): void {
    this.updateAllViews();
    this.requestUpdate?.();
  }

  protected hideCrosshair(): void {
    this.chart.applyOptions({ crosshair: { mode: CrosshairMode.Hidden } });
  }

  protected showCrosshair(): void {
    this.chart.applyOptions({ crosshair: { mode: CrosshairMode.Normal } });
  }

  protected getEventPoint(event: PointerEvent | TouchMouseEventData): Point {
    return getPointerPointFromEvent(this.container, event);
  }

  protected createDrawingAnchor(point: Point): Anchor | null {
    return getAnchorFromPoint(this.chart, this.series, point);
  }

  protected getPointFromAnchor(anchor: Anchor): Point | 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: Math.round(Number(x)), y: Math.round(Number(y)) };
  }

  protected createDefaultAxisLabel(coordinate: number | null, text: string): AxisLabel | null {
    if (coordinate === null || !text) {
      return null;
    }
    const { colors } = getThemeStore();
    return {
      coordinate,
      text,
      textColor: colors.chartPriceLineText,
      backgroundColor: colors.axisMarkerLabelFill,
    };
  }

  protected consumeEvent(event: Event): void {
    event.preventDefault();
    event.stopPropagation();
  }

  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(): TGeometry | null;

  protected getTimeAxisSegments(): AxisSegment[] {
    const geometry = this.getAxisBoundsGeometry();
    return this.createAxisSegments(geometry?.left ?? null, geometry?.right ?? null);
  }

  protected getPriceAxisSegments(): AxisSegment[] {
    const geometry = this.getAxisBoundsGeometry();
    return this.createAxisSegments(geometry?.top ?? null, geometry?.bottom ?? null);
  }

  protected abstract getTimeAxisLabel(kind: string): AxisLabel | null;
  protected abstract getPriceAxisLabel(kind: string): AxisLabel | null;

  protected initializeDrawingViews(
    paneView: IPrimitivePaneView & UpdatableView,
    timeAxisLabelKinds: readonly string[],
    priceAxisLabelKinds: readonly string[],
  ): void {
    this.paneViewsList = [paneView];
    this.timeAxisPaneViewsList = [
      new CustomTimeAxisPaneView({ getAxisSegments: () => this.getTimeAxisSegments() }),
    ];
    this.priceAxisPaneViewsList = [
      new CustomPriceAxisPaneView({ getAxisSegments: () => this.getPriceAxisSegments() }),
    ];
    this.timeAxisViewsList = timeAxisLabelKinds.map(
      (labelKind) => new CustomTimeAxisView({ getAxisLabel: (kind) => this.getTimeAxisLabel(kind), labelKind }),
    );
    this.priceAxisViewsList = priceAxisLabelKinds.map(
      (labelKind) => new CustomPriceAxisView({ getAxisLabel: (kind) => this.getPriceAxisLabel(kind), labelKind }),
    );
  }

  protected initializeDrawing(
    formatObservable?: Observable<ChartOptionsModel>,
    initialEvent?: MouseEventParams,
    startDrawing?: (point: Point) => void,
  ): void {
    if (formatObservable) {
      this.subscriptions.add(
        formatObservable.subscribe((format) => {
          this.displayFormat = format;
          this.render();
        }),
      );
    }
    this.series.attachPrimitive(this as ISeriesPrimitive<Time>);
    if (initialEvent?.sourceEvent && startDrawing) {
      startDrawing(this.getEventPoint(initialEvent.sourceEvent));
    }
  }

  protected createAxisSegments(from: number | null, to: number | null): AxisSegment[] {
    if (!this.shouldShowAxisElements() || from === null || to === null) {
      return [];
    }
    return [{ from, to, color: getThemeStore().colors.axisMarkerAreaFill }];
  }

  private handleLockedPointerDown(event: PointerEvent): void {
    const point = this.getEventPoint(event);
    if (this.getHoveredItem(point.x, point.y)) {
      this.select();
      return;
    }
    if (this.isSelected()) {
      this.deselect();
    }
  }

  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 bindEvents(): void {
    if (this.isBound) {
      return;
    }
    this.isBound = true;
    window.addEventListener('pointermove', this.onPointerMove);
    window.addEventListener('pointerup', this.onPointerUp);
    window.addEventListener('pointercancel', this.onPointerUp);
  }

  private unbindEvents(): void {
    if (!this.isBound) {
      return;
    }
    this.isBound = false;
    window.removeEventListener('pointermove', this.onPointerMove);
    window.removeEventListener('pointerup', this.onPointerUp);
    window.removeEventListener('pointercancel', this.onPointerUp);
  }

  private getAxisBoundsGeometry(): AxisBoundsGeometry | null {
    const geometry = this.getGeometry() as Partial<AxisBoundsGeometry> | null;
    if (!geometry) {
      return null;
    }
    const { left, right, top, bottom } = geometry;
    if (![left, right, top, bottom].every((value) => typeof value === 'number')) {
      return null;
    }
    return { left: left!, right: right!, top: top!, bottom: bottom! };
  }

  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,
  getContainerSize,
  getPriceDelta,
  shiftTimeByPixels,
} from '@core/Drawings/helpers';

import type { AxisBoundsGeometry, BaseDrawingParams } from '@core/Drawings/DrawingBase';
import type { Anchor, Point } from '@core/Drawings/types';
import type { SettingsValues } from '@src/types';
import type { MouseEventParams } from 'lightweight-charts';

export type TwoPointDrawingMode = 'idle' | 'drawing' | 'ready' | 'dragging';

export interface TwoPointGeometry {
  startPoint: Point;
  endPoint: Point;
  left: number;
  right: number;
  top: number;
  bottom: number;
}

interface AnchorSnapshot {
  startAnchor: Anchor;
  endAnchor: Anchor;
}

export abstract class TwoPointDrawingBase<
  TSettings extends SettingsValues,
  THandleId extends string,
  TDragTarget extends string,
  TGeometry extends AxisBoundsGeometry,
> extends DrawingBase<TSettings, THandleId, TwoPointDrawingMode, TGeometry> {
  protected mode: TwoPointDrawingMode = 'idle';
  protected startAnchor: Anchor | null = null;
  protected endAnchor: Anchor | null = null;
  protected activeDragTarget: TDragTarget | null = null;

  private dragPointerId: number | null = null;
  private dragStartPoint: Point | null = null;
  private dragAnchorSnapshot: AnchorSnapshot | null = null;
  private dragGeometrySnapshot: TGeometry | null = null;
  private crosshairHiddenByDrag = false;

  constructor(params: BaseDrawingParams) {
    super(params);
  }

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

  protected initializeTwoPointDrawing(formatObservable?: BaseDrawingParams['formatObservable'], initialEvent?: MouseEventParams): void {
    this.initializeDrawing(formatObservable, initialEvent, (point) => this.startDrawing(point));
  }

  protected getTwoPointGeometry(): TwoPointGeometry | null {
    if (!this.startAnchor || !this.endAnchor) {
      return 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),
    };
  }

  protected getDragStartPoint(): Point | null {
    return this.dragStartPoint;
  }

  protected getDragGeometrySnapshot(): TGeometry | null {
    return this.dragGeometrySnapshot;
  }

  protected getDragAnchorSnapshot(): AnchorSnapshot | null {
    return this.dragAnchorSnapshot;
  }

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

  protected setAnchorFromPoint(kind: 'start' | 'end', point: Point): boolean {
    const anchor = this.createDrawingAnchor(this.normalizeAnchorPoint(point));
    if (!anchor) {
      return false;
    }
    if (kind === 'start') {
      this.startAnchor = anchor;
    } else {
      this.endAnchor = anchor;
    }
    return true;
  }

  protected moveWholeFromSnapshot(point: Point, constrainToContainer = true): void {
    const snapshot = this.dragAnchorSnapshot;
    const dragStartPoint = this.dragStartPoint;
    if (!snapshot || !dragStartPoint) {
      return;
    }
    const offset = this.getDragOffset(point, constrainToContainer);
    const startTime = shiftTimeByPixels(this.chart, snapshot.startAnchor.time, offset.x, this.series);
    const endTime = shiftTimeByPixels(this.chart, snapshot.endAnchor.time, offset.x, this.series);
    if (startTime === null || endTime === null) {
      return;
    }
    const priceOffset = getPriceDelta(this.series, dragStartPoint.y, dragStartPoint.y + offset.y);
    this.startAnchor = { time: startTime, price: snapshot.startAnchor.price + priceOffset };
    this.endAnchor = { time: endTime, price: snapshot.endAnchor.price + priceOffset };
  }

  protected resetToIdle(): void {
    this.hidden = false;
    this.beforeResetToIdle();
    this.mode = 'idle';
    this.startAnchor = null;
    this.endAnchor = null;
    this.clearDragState();
    this.render();
  }

  protected normalizeRestoredMode(mode: unknown): TwoPointDrawingMode | null {
    if (mode === 'idle' || mode === 'drawing' || mode === 'ready') {
      return mode;
    }
    return mode === 'dragging' ? 'ready' : null;
  }

  protected normalizeAnchorPoint(point: Point): Point {
    return point;
  }

  protected shouldHideCrosshairWhileDragging(): boolean {
    return false;
  }

  protected beforeResetToIdle(): void {}

  protected handleDoubleClick(event: MouseEvent): void {
    if (this.hidden || this.mode !== 'ready') {
      return;
    }
    const point = this.getEventPoint(event as PointerEvent);
    if (!this.isDrawingHit(point)) {
      return;
    }
    this.consumeEvent(event);
    this.openSettings?.();
  }

  protected handlePointerDown(event: PointerEvent): void {
    if (this.hidden || event.button !== 0) {
      return;
    }
    const point = this.getEventPoint(event);
    if (this.handleCreationPointerDown(event, point)) {
      return;
    }
    this.handleReadyPointerDown(event, point);
  }

  protected handlePointerMove(event: PointerEvent): void {
    const point = this.getEventPoint(event);
    if (this.mode === 'drawing') {
      this.updateDrawing(point);
      return;
    }
    if (!this.isActiveDragEvent(event)) {
      return;
    }
    event.preventDefault();
    this.applyDrag(point, this.activeDragTarget!);
    this.render();
  }

  protected handlePointerUp(event: PointerEvent): void {
    if (!this.isActiveDragEvent(event)) {
      return;
    }
    this.finishDragging();
  }

  protected abstract isDrawingHit(point: Point): boolean;
  protected abstract getDragTarget(point: Point): TDragTarget | null;
  protected abstract applyDrag(point: Point, dragTarget: TDragTarget): void;
  protected abstract isValidGeometry(geometry: TGeometry): boolean;

  private handleCreationPointerDown(event: PointerEvent, point: Point): boolean {
    if (this.mode === 'idle') {
      this.consumeEvent(event);
      this.startDrawing(point);
      return true;
    }
    if (this.mode !== 'drawing') {
      return false;
    }
    this.consumeEvent(event);
    this.updateDrawing(point);
    this.finishDrawing();
    return true;
  }

  private handleReadyPointerDown(event: PointerEvent, point: Point): void {
    if (this.mode !== 'ready') {
      return;
    }
    if (!this.isSelected()) {
      this.selectIfHit(event, point);
      return;
    }
    const dragTarget = this.getDragTarget(point);
    if (!dragTarget) {
      this.deselect();
      return;
    }
    this.consumeEvent(event);
    this.startDragging(point, event.pointerId, dragTarget);
  }

  private selectIfHit(event: PointerEvent, point: Point): void {
    if (!this.isDrawingHit(point)) {
      return;
    }
    this.consumeEvent(event);
    this.select();
  }

  private startDrawing(point: Point): void {
    const anchor = this.createDrawingAnchor(this.normalizeAnchorPoint(point));
    if (!anchor) {
      return;
    }
    this.startAnchor = anchor;
    this.endAnchor = anchor;
    this.mode = 'drawing';
    this.render();
  }

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

  private finishDrawing(): void {
    const geometry = this.getGeometry();
    if (!geometry || !this.isValidGeometry(geometry)) {
      this.handleInvalidDrawing();
      return;
    }
    this.mode = 'ready';
    this.resolveReady?.();
    this.render();
  }

  private handleInvalidDrawing(): void {
    if (this.removeSelf) {
      this.removeSelf();
      return;
    }
    this.resetToIdle();
  }

  private startDragging(point: Point, pointerId: number, dragTarget: TDragTarget): void {
    if (!this.startAnchor || !this.endAnchor) {
      return;
    }
    this.mode = 'dragging';
    this.activeDragTarget = dragTarget;
    this.dragPointerId = pointerId;
    this.dragStartPoint = point;
    this.dragAnchorSnapshot = {
      startAnchor: { ...this.startAnchor },
      endAnchor: { ...this.endAnchor },
    };
    this.dragGeometrySnapshot = this.getGeometry();
    this.crosshairHiddenByDrag = this.shouldHideCrosshairWhileDragging();
    if (this.crosshairHiddenByDrag) {
      this.hideCrosshair();
    }
    this.render();
  }

  private finishDragging(): void {
    this.mode = 'ready';
    this.resolveReady?.();
    this.clearDragState();
    if (this.crosshairHiddenByDrag) {
      this.showCrosshair();
    }
    this.crosshairHiddenByDrag = false;
    this.render();
  }

  private clearDragState(): void {
    this.activeDragTarget = null;
    this.dragPointerId = null;
    this.dragStartPoint = null;
    this.dragAnchorSnapshot = null;
    this.dragGeometrySnapshot = null;
  }

  private isActiveDragEvent(event: PointerEvent): boolean {
    return this.mode === 'dragging' && this.dragPointerId === event.pointerId && this.activeDragTarget !== null;
  }

  private getDragOffset(point: Point, constrainToContainer: boolean): Point {
    const dragStartPoint = this.dragStartPoint;
    if (!dragStartPoint) {
      return { x: 0, y: 0 };
    }
    const rawOffset = { x: point.x - dragStartPoint.x, y: point.y - dragStartPoint.y };
    if (!constrainToContainer) {
      return rawOffset;
    }
    return this.clampDragOffset(rawOffset);
  }

  private clampDragOffset(offset: Point): Point {
    const geometry = this.dragGeometrySnapshot;
    if (!geometry) {
      return offset;
    }
    const { width, height } = getContainerSize(this.container);
    return {
      x: clamp(offset.x, -geometry.left, width - geometry.right),
      y: clamp(offset.y, -geometry.top, height - geometry.bottom),
    };
  }
}

export function clampTwoPointDrawingPoint(point: Point, container: HTMLElement): Point {
  return clampPointToContainer(point, container);
}











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





import { normalizeBounds, isPointInBounds } from '@core/Drawings/helpers';
import {
  clampTwoPointDrawingPoint,
  TwoPointDrawingBase,
  TwoPointDrawingMode,
  TwoPointGeometry,
} from '@core/Drawings/TwoPointDrawingBase';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';

import { RectanglePaneView } from './paneView';
import {
  createDefaultSettings,
  getRectangleSettingsTabs,
  RectangleSettings,
  RectangleStyle,
  RectangleTextStyle,
} from './settings';

import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type { SettingsTab } from '@src/types';
import type { PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';

type RectangleHandle = 'body' | 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
type RectangleHandleKey = Exclude<RectangleHandle, 'body'>;
type TimeLabelKind = 'left' | 'right';
type PriceLabelKind = 'top' | 'bottom';
type RectangleParams = BaseDrawingParams;

interface RectangleState {
  hidden: boolean;
  mode: TwoPointDrawingMode;
  startTime: Time | null;
  endTime: Time | null;
  startPrice: number | null;
  endPrice: number | null;
  settings: RectangleSettings;
}

interface RectangleGeometry extends TwoPointGeometry {
  width: number;
  height: number;
}

export type RectangleRenderData = RectangleGeometry & RectangleStyle & RectangleTextStyle;

const BODY_HIT_TOLERANCE = 6;
const MIN_RECTANGLE_SIZE = 6;
const EXTERNAL_ID = 'rectangle-position';

export class Rectangle
  extends TwoPointDrawingBase<RectangleSettings, RectangleHandleKey, RectangleHandle, RectangleGeometry>
  implements ISeriesDrawing {
  protected settings: RectangleSettings = createDefaultSettings();

  constructor(params: RectangleParams) {
    super(params);
    this.initializeDrawingViews(new RectanglePaneView(this), ['left', 'right'], ['top', 'bottom']);
    this.initializeTwoPointDrawing(params.formatObservable, params.initialEvent);
  }

  public getState(): RectangleState {
    return {
      hidden: this.hidden,
      mode: this.mode,
      startTime: this.startAnchor?.time ?? null,
      endTime: this.endAnchor?.time ?? null,
      startPrice: this.startAnchor?.price ?? null,
      endPrice: this.endAnchor?.price ?? null,
      settings: { ...this.settings },
    };
  }

  public setState(state: unknown): void {
    if (!state || typeof state !== 'object') {
      return;
    }
    const nextState = state as Partial<RectangleState>;
    if (typeof nextState.hidden === 'boolean') {
      this.hidden = nextState.hidden;
    }
    const mode = this.normalizeRestoredMode(nextState.mode);
    if (mode) {
      this.mode = mode;
    }
    this.restoreAnchor('start', nextState);
    this.restoreAnchor('end', nextState);
    if (nextState.settings) {
      this.settings = { ...createDefaultSettings(), ...nextState.settings };
    }
    this.render();
  }

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

  public getRenderData(): RectangleRenderData | null {
    if (this.hidden) {
      return null;
    }
    const geometry = this.getGeometry();
    return geometry ? { ...geometry, ...this.settings } : null;
  }

  protected getDrawingHandles(): readonly DrawingHandle<RectangleHandleKey>[] {
    const geometry = this.getGeometry();
    if (!geometry) {
      return [];
    }
    const { left, right, top, bottom } = geometry;
    const centerX = (left + right) / 2;
    const centerY = (top + bottom) / 2;
    return [
      { id: 'w', x: left, y: centerY, shape: 'rounded' },
      { id: 'sw', x: left, y: bottom, shape: 'circle' },
      { id: 's', x: centerX, y: bottom, shape: 'rounded' },
      { id: 'se', x: right, y: bottom, shape: 'circle' },
      { id: 'e', x: right, y: centerY, shape: 'rounded' },
      { id: 'ne', x: right, y: top, shape: 'circle' },
      { id: 'n', x: centerX, y: top, shape: 'rounded' },
      { id: 'nw', x: left, y: top, shape: 'circle' },
    ];
  }

  protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
    if (this.hidden || this.isCreationPending()) {
      return null;
    }
    const point = { x, y };
    if (!this.isSelected()) {
      return this.containsPoint(point) ? this.createHoveredItem('pointer') : null;
    }
    const handle = this.getDrawingHandleAtPoint(point)?.id;
    if (handle) {
      return this.createHoveredItem(this.getCursorStyle(handle));
    }
    return this.containsPoint(point) ? this.createHoveredItem('grab') : null;
  }

  protected getTimeAxisLabel(kind: string): AxisLabel | null {
    if (!this.shouldShowAxisElements() || !isTimeLabelKind(kind)) {
      return null;
    }
    return this.createDefaultAxisLabel(this.getTimeCoordinate(kind), this.getTimeText(kind));
  }

  protected getPriceAxisLabel(kind: string): AxisLabel | null {
    if (!this.shouldShowAxisElements() || !isPriceLabelKind(kind)) {
      return null;
    }
    return this.createDefaultAxisLabel(this.getPriceCoordinate(kind), this.getPriceText(kind));
  }

  protected getGeometry(): RectangleGeometry | null {
    const geometry = this.getTwoPointGeometry();
    if (!geometry) {
      return null;
    }
    return {
      ...geometry,
      width: geometry.right - geometry.left,
      height: geometry.bottom - geometry.top,
    };
  }

  protected normalizeAnchorPoint(point: Point): Point {
    return clampTwoPointDrawingPoint(point, this.container);
  }

  protected isDrawingHit(point: Point): boolean {
    return Boolean(this.getDrawingHandleAtPoint(point)) || this.containsPoint(point);
  }

  protected getDragTarget(point: Point): RectangleHandle | null {
    return this.getDrawingHandleAtPoint(point)?.id ?? (this.containsPoint(point) ? 'body' : null);
  }

  protected applyDrag(point: Point, dragTarget: RectangleHandle): void {
    if (dragTarget === 'body') {
      this.moveWholeFromSnapshot(point);
      return;
    }
    this.resizeRectangle(point, dragTarget);
  }

  protected isValidGeometry(geometry: RectangleGeometry): boolean {
    return geometry.width >= MIN_RECTANGLE_SIZE && geometry.height >= MIN_RECTANGLE_SIZE;
  }

  private restoreAnchor(kind: 'start' | 'end', state: Partial<RectangleState>): void {
    const current = kind === 'start' ? this.startAnchor : this.endAnchor;
    const timeKey = kind === 'start' ? 'startTime' : 'endTime';
    const priceKey = kind === 'start' ? 'startPrice' : 'endPrice';
    const time = timeKey in state ? state[timeKey] ?? null : current?.time ?? null;
    const price = priceKey in state ? state[priceKey] ?? null : current?.price ?? null;
    const anchor = time === null || price === null ? null : { time, price };
    if (kind === 'start') {
      this.startAnchor = anchor;
    } else {
      this.endAnchor = anchor;
    }
  }

  private resizeRectangle(point: Point, dragTarget: RectangleHandleKey): void {
    const geometry = this.getDragGeometrySnapshot();
    if (!geometry) {
      return;
    }
    const clampedPoint = clampTwoPointDrawingPoint(point, this.container);
    const bounds = getResizedBounds(geometry, clampedPoint, dragTarget);
    this.setRectangleBounds(bounds.left, bounds.right, bounds.top, bounds.bottom);
  }

  private setRectangleBounds(left: number, right: number, top: number, bottom: number): void {
    const bounds = normalizeBounds(left, right, top, bottom, this.container);
    const startAnchor = this.createDrawingAnchor({ x: bounds.left, y: bounds.top });
    const endAnchor = this.createDrawingAnchor({ x: bounds.right, y: bounds.bottom });
    if (startAnchor && endAnchor) {
      this.setAnchors(startAnchor, endAnchor);
    }
  }

  private getTimeCoordinate(kind: TimeLabelKind): number | null {
    const geometry = this.getGeometry();
    if (!geometry) {
      return null;
    }
    return kind === 'left' ? geometry.left : geometry.right;
  }

  private getPriceCoordinate(kind: PriceLabelKind): number | null {
    const geometry = this.getGeometry();
    if (!geometry) {
      return null;
    }
    return kind === 'top' ? geometry.top : geometry.bottom;
  }

  private getTimeText(kind: TimeLabelKind): string {
    const time = this.getTimeValueForLabel(kind);
    if (typeof time !== 'number') {
      return '';
    }
    return formatDate(
      time as UTCTimestamp,
      this.displayFormat.dateFormat,
      this.displayFormat.timeFormat,
      this.displayFormat.showTime,
    );
  }

  private getPriceText(kind: PriceLabelKind): string {
    const price = this.getPriceValueForLabel(kind);
    return price === null ? '' : (formatPrice(price) ?? '');
  }

  private getTimeValueForLabel(kind: TimeLabelKind): Time | null {
    if (!this.startAnchor || !this.endAnchor) {
      return null;
    }
    const geometry = this.getTwoPointGeometry();
    if (!geometry) {
      return kind === 'left' ? this.startAnchor.time : this.endAnchor.time;
    }
    const startIsLeft = geometry.startPoint.x <= geometry.endPoint.x;
    if (kind === 'left') {
      return startIsLeft ? this.startAnchor.time : this.endAnchor.time;
    }
    return startIsLeft ? this.endAnchor.time : this.startAnchor.time;
  }

  private getPriceValueForLabel(kind: PriceLabelKind): number | null {
    if (!this.startAnchor || !this.endAnchor) {
      return null;
    }
    const geometry = this.getTwoPointGeometry();
    if (!geometry) {
      return kind === 'top'
        ? Math.max(this.startAnchor.price, this.endAnchor.price)
        : Math.min(this.startAnchor.price, this.endAnchor.price);
    }
    const startIsTop = geometry.startPoint.y <= geometry.endPoint.y;
    if (kind === 'top') {
      return startIsTop ? this.startAnchor.price : this.endAnchor.price;
    }
    return startIsTop ? this.endAnchor.price : this.startAnchor.price;
  }

  private containsPoint(point: Point): boolean {
    const geometry = this.getGeometry();
    return geometry ? isPointInBounds(point, geometry, BODY_HIT_TOLERANCE) : false;
  }

  private createHoveredItem(cursorStyle: PrimitiveHoveredItem['cursorStyle']): PrimitiveHoveredItem {
    return { cursorStyle, externalId: EXTERNAL_ID, zOrder: 'top' };
  }

  private getCursorStyle(handle: RectangleHandle): PrimitiveHoveredItem['cursorStyle'] {
    if (handle === 'nw' || handle === 'se') {
      return 'nwse-resize';
    }
    if (handle === 'ne' || handle === 'sw') {
      return 'nesw-resize';
    }
    if (handle === 'n' || handle === 's') {
      return 'ns-resize';
    }
    if (handle === 'e' || handle === 'w') {
      return 'ew-resize';
    }
    return handle === 'body' ? 'grab' : 'default';
  }
}

function getResizedBounds(
  geometry: RectangleGeometry,
  point: Point,
  handle: RectangleHandleKey,
): Pick<RectangleGeometry, 'left' | 'right' | 'top' | 'bottom'> {
  let { left, right, top, bottom } = geometry;
  if (handle.includes('w')) {
    left = point.x;
  }
  if (handle.includes('e')) {
    right = point.x;
  }
  if (handle.includes('n')) {
    top = point.y;
  }
  if (handle.includes('s')) {
    bottom = point.y;
  }
  return { left, right, top, bottom };
}

function isTimeLabelKind(kind: string): kind is TimeLabelKind {
  return kind === 'left' || kind === 'right';
}

function isPriceLabelKind(kind: string): kind is PriceLabelKind {
  return kind === 'top' || kind === 'bottom';
}







import { isPointInBounds } from '@core/Drawings/helpers';
import {
  clampTwoPointDrawingPoint,
  TwoPointDrawingBase,
  TwoPointDrawingMode,
  TwoPointGeometry,
} from '@core/Drawings/TwoPointDrawingBase';
import { t } from '@src/translations';
import { formatPercent, formatPrice, formatSignedNumber, formatVolume } from '@src/utils';
import { formatDate } from '@src/utils/formatter';

import { DiapsonPaneView } from './paneView';
import {
  createDefaultSettings,
  DiapsonSettings,
  DiapsonStyle,
  DiapsonTextStyle,
  getDiapsonSettingsTabs,
} from './settings';

import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type { SettingsTab } from '@src/types';
import type { PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';

export type DiapsonRangeMode = 'date' | 'price';

type DiapsonDragTarget = 'body' | 'start' | 'end';
type DiapsonHandleKey = Exclude<DiapsonDragTarget, 'body'>;
type TimeLabelKind = 'left' | 'right';
type PriceLabelKind = 'top' | 'bottom';

interface DiapsonParams extends BaseDrawingParams {
  rangeMode: DiapsonRangeMode;
  stepSize?: number;
  stepLabel?: string;
}

export interface DiapsonState {
  hidden: boolean;
  mode: TwoPointDrawingMode;
  rangeMode: DiapsonRangeMode;
  startTime: Time | null;
  endTime: Time | null;
  startPrice: number | null;
  endPrice: number | null;
  settings: DiapsonSettings;
}

interface DiapsonGeometry extends TwoPointGeometry {
  width: number;
  height: number;
}

export interface DiapsonRenderData extends DiapsonGeometry, DiapsonStyle, DiapsonTextStyle {
  rangeMode: DiapsonRangeMode;
  showFill: boolean;
  labelLines: string[];
}

interface DateMetrics {
  barsCount: number;
  elapsedText: string;
  volumeText: string;
}

interface PriceMetrics {
  delta: number;
  percent: number;
  steps: number;
}

const BODY_HIT_TOLERANCE = 6;
const MIN_RECTANGLE_WIDTH = 6;
const MIN_RECTANGLE_HEIGHT = 6;

export class Diapson
  extends TwoPointDrawingBase<DiapsonSettings, DiapsonHandleKey, DiapsonDragTarget, DiapsonGeometry>
  implements ISeriesDrawing {
  protected settings: DiapsonSettings = createDefaultSettings();
  private rangeMode: DiapsonRangeMode;
  private readonly stepSize: number;
  private readonly stepLabel: string;

  constructor(params: DiapsonParams) {
    super(params);
    this.rangeMode = params.rangeMode;
    this.stepSize = params.stepSize && params.stepSize > 0 ? params.stepSize : 1;
    this.stepLabel = params.stepLabel ?? '';
    this.initializeDrawingViews(new DiapsonPaneView(this), ['left', 'right'], ['top', 'bottom']);
    this.initializeTwoPointDrawing(params.formatObservable, params.initialEvent);
  }

  public setRangeMode(nextMode: DiapsonRangeMode): void {
    if (this.rangeMode === nextMode) {
      return;
    }
    this.rangeMode = nextMode;
    this.resetToIdle();
  }

  public getState(): DiapsonState {
    return {
      hidden: this.hidden,
      mode: this.mode,
      rangeMode: this.rangeMode,
      startTime: this.startAnchor?.time ?? null,
      endTime: this.endAnchor?.time ?? null,
      startPrice: this.startAnchor?.price ?? null,
      endPrice: this.endAnchor?.price ?? null,
      settings: { ...this.settings },
    };
  }

  public setState(state: unknown): void {
    if (!state || typeof state !== 'object') {
      return;
    }
    const nextState = state as Partial<DiapsonState>;
    if (typeof nextState.hidden === 'boolean') {
      this.hidden = nextState.hidden;
    }
    const mode = this.normalizeRestoredMode(nextState.mode);
    if (mode) {
      this.mode = mode;
    }
    if (nextState.rangeMode) {
      this.rangeMode = nextState.rangeMode;
    }
    this.restoreAnchor('start', nextState);
    this.restoreAnchor('end', nextState);
    if (nextState.settings) {
      this.settings = { ...createDefaultSettings(), ...nextState.settings };
    }
    this.render();
  }

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

  public getRenderData(): DiapsonRenderData | null {
    if (this.hidden) {
      return null;
    }
    const geometry = this.getGeometry();
    if (!geometry) {
      return null;
    }
    return {
      ...geometry,
      rangeMode: this.rangeMode,
      showFill: true,
      labelLines: this.getLabelLines(),
      ...this.settings,
    };
  }

  protected getDrawingHandles(): readonly DrawingHandle<DiapsonHandleKey>[] {
    const geometry = this.getGeometry();
    if (!geometry) {
      return [];
    }
    return [
      { id: 'end', ...geometry.endPoint, shape: 'circle', size: 12, borderWidth: 2 },
      { id: 'start', ...geometry.startPoint, shape: 'circle', size: 12, borderWidth: 2 },
    ];
  }

  protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
    if (this.hidden || this.isCreationPending()) {
      return null;
    }
    const point = { x, y };
    const externalId = `diapson-${this.rangeMode}`;
    if (!this.isSelected()) {
      return this.containsPoint(point) ? { cursorStyle: 'move', externalId, zOrder: 'top' } : null;
    }
    const handle = this.getDrawingHandleAtPoint(point)?.id;
    if (handle) {
      return { cursorStyle: this.getCursorStyle(handle), externalId, zOrder: 'top' };
    }
    return this.containsPoint(point) ? { cursorStyle: 'move', externalId, zOrder: 'top' } : null;
  }

  protected getTimeAxisLabel(kind: string): AxisLabel | null {
    if (!this.shouldShowAxisElements() || !isTimeLabelKind(kind)) {
      return null;
    }
    return this.createDefaultAxisLabel(this.getTimeCoordinate(kind), this.getTimeText(kind));
  }

  protected getPriceAxisLabel(kind: string): AxisLabel | null {
    if (!this.shouldShowAxisElements() || !isPriceLabelKind(kind)) {
      return null;
    }
    return this.createDefaultAxisLabel(this.getPriceCoordinate(kind), this.getPriceText(kind));
  }

  protected getGeometry(): DiapsonGeometry | null {
    const geometry = this.getTwoPointGeometry();
    if (!geometry) {
      return null;
    }
    const startPoint = clampTwoPointDrawingPoint(geometry.startPoint, this.container);
    const endPoint = clampTwoPointDrawingPoint(geometry.endPoint, this.container);
    const left = Math.min(startPoint.x, endPoint.x);
    const right = Math.max(startPoint.x, endPoint.x);
    const top = Math.min(startPoint.y, endPoint.y);
    const bottom = Math.max(startPoint.y, endPoint.y);
    return { startPoint, endPoint, left, right, top, bottom, width: right - left, height: bottom - top };
  }

  protected normalizeAnchorPoint(point: Point): Point {
    return clampTwoPointDrawingPoint(point, this.container);
  }

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

  protected isDrawingHit(point: Point): boolean {
    return Boolean(this.getDrawingHandleAtPoint(point)) || this.containsPoint(point);
  }

  protected getDragTarget(point: Point): DiapsonDragTarget | null {
    return this.getDrawingHandleAtPoint(point)?.id ?? (this.containsPoint(point) ? 'body' : null);
  }

  protected applyDrag(point: Point, dragTarget: DiapsonDragTarget): void {
    if (dragTarget === 'body') {
      this.moveWholeFromSnapshot(point);
      return;
    }
    this.setAnchorFromPoint(dragTarget, point);
  }

  protected isValidGeometry(geometry: DiapsonGeometry): boolean {
    return geometry.width >= MIN_RECTANGLE_WIDTH && geometry.height >= MIN_RECTANGLE_HEIGHT;
  }

  private restoreAnchor(kind: 'start' | 'end', state: Partial<DiapsonState>): void {
    const current = kind === 'start' ? this.startAnchor : this.endAnchor;
    const timeKey = kind === 'start' ? 'startTime' : 'endTime';
    const priceKey = kind === 'start' ? 'startPrice' : 'endPrice';
    const time = timeKey in state ? state[timeKey] ?? null : current?.time ?? null;
    const price = priceKey in state ? state[priceKey] ?? null : current?.price ?? null;
    const anchor = time === null || price === null ? null : { time, price };
    if (kind === 'start') {
      this.startAnchor = anchor;
    } else {
      this.endAnchor = anchor;
    }
  }

  private getLabelLines(): string[] {
    return this.rangeMode === 'date' ? this.getDateLabelLines() : this.getPriceLabelLines();
  }

  private getDateLabelLines(): string[] {
    const metrics = this.getDateMetrics();
    if (!metrics) {
      return [];
    }
    const firstLine = metrics.elapsedText
      ? `${metrics.barsCount} ${t('bars')}, ${metrics.elapsedText}`
      : `${metrics.barsCount} ${t('bars')}`;
    return metrics.volumeText ? [firstLine, `${t('Vol')} ${metrics.volumeText}`] : [firstLine];
  }

  private getPriceLabelLines(): string[] {
    const metrics = this.getPriceMetrics();
    if (!metrics) {
      return [];
    }
    const percentText = metrics.percent < 0
      ? `-${formatPercent(Math.abs(metrics.percent))}`
      : formatPercent(Math.abs(metrics.percent));
    const stepsText = formatSteps(metrics.steps);
    const stepSuffix = this.stepLabel ? ` ${this.stepLabel}` : '';
    return [`${formatSignedNumber(metrics.delta)} (${percentText}) ${stepsText}${stepSuffix}`];
  }

  private getDateMetrics(): DateMetrics | null {
    const leftTime = this.getLeftTimeValue();
    const rightTime = this.getRightTimeValue();
    if (leftTime === null || rightTime === null) {
      return null;
    }
    const durationSeconds = Math.floor(Math.abs(Number(rightTime) - Number(leftTime)));
    const volume = this.getVolumeInRange();
    return {
      barsCount: this.getBarsCount(),
      elapsedText: formatDuration(durationSeconds),
      volumeText: volume > 0 ? formatVolume(volume) : '',
    };
  }

  private getPriceMetrics(): PriceMetrics | null {
    if (!this.startAnchor || !this.endAnchor) {
      return null;
    }
    const delta = this.endAnchor.price - this.startAnchor.price;
    return {
      delta,
      percent: this.startAnchor.price !== 0 ? (delta / Math.abs(this.startAnchor.price)) * 100 : 0,
      steps: delta / this.stepSize,
    };
  }

  private getBarsCount(): number {
    const range = this.getDataRange();
    return range ? Math.abs(range.to - range.from) : 0;
  }

  private getVolumeInRange(): number {
    const range = this.getDataRange();
    if (!range) {
      return 0;
    }
    const data = this.series.data() ?? [];
    let volume = 0;
    for (let index = range.from; index <= range.to; index += 1) {
      volume += getItemVolume(data[index] as unknown as Record<string, unknown> | undefined);
    }
    return volume;
  }

  private getDataRange(): { from: number; to: number } | null {
    if (!this.startAnchor || !this.endAnchor) {
      return null;
    }
    const data = this.series.data() ?? [];
    if (!data.length) {
      return null;
    }
    const startIndex = findNearestIndexByTime(data, this.startAnchor.time);
    const endIndex = findNearestIndexByTime(data, this.endAnchor.time);
    if (startIndex < 0 || endIndex < 0) {
      return null;
    }
    return { from: Math.min(startIndex, endIndex), to: Math.max(startIndex, endIndex) };
  }

  private getTimeCoordinate(kind: TimeLabelKind): number | null {
    const geometry = this.getGeometry();
    if (!geometry) {
      return null;
    }
    return kind === 'left' ? geometry.left : geometry.right;
  }

  private getPriceCoordinate(kind: PriceLabelKind): number | null {
    const geometry = this.getGeometry();
    if (!geometry) {
      return null;
    }
    return kind === 'top' ? geometry.top : geometry.bottom;
  }

  private getTimeText(kind: TimeLabelKind): string {
    const time = kind === 'left' ? this.getLeftTimeValue() : this.getRightTimeValue();
    if (typeof time !== 'number') {
      return '';
    }
    return formatDate(
      time as UTCTimestamp,
      this.displayFormat.dateFormat,
      this.displayFormat.timeFormat,
      this.displayFormat.showTime,
    );
  }

  private getPriceText(kind: PriceLabelKind): string {
    const price = kind === 'top' ? this.getTopPriceValue() : this.getBottomPriceValue();
    return price === null ? '' : (formatPrice(price) ?? '');
  }

  private getLeftTimeValue(): Time | null {
    return this.getTimeValueBySide('left');
  }

  private getRightTimeValue(): Time | null {
    return this.getTimeValueBySide('right');
  }

  private getTimeValueBySide(side: TimeLabelKind): Time | null {
    if (!this.startAnchor || !this.endAnchor) {
      return null;
    }
    const geometry = this.getTwoPointGeometry();
    if (!geometry) {
      return side === 'left' ? this.startAnchor.time : this.endAnchor.time;
    }
    const startIsLeft = geometry.startPoint.x <= geometry.endPoint.x;
    if (side === 'left') {
      return startIsLeft ? this.startAnchor.time : this.endAnchor.time;
    }
    return startIsLeft ? this.endAnchor.time : this.startAnchor.time;
  }

  private getTopPriceValue(): number | null {
    return this.getPriceValueBySide('top');
  }

  private getBottomPriceValue(): number | null {
    return this.getPriceValueBySide('bottom');
  }

  private getPriceValueBySide(side: PriceLabelKind): number | null {
    if (!this.startAnchor || !this.endAnchor) {
      return null;
    }
    const geometry = this.getTwoPointGeometry();
    if (!geometry) {
      return side === 'top'
        ? Math.max(this.startAnchor.price, this.endAnchor.price)
        : Math.min(this.startAnchor.price, this.endAnchor.price);
    }
    const startIsTop = geometry.startPoint.y <= geometry.endPoint.y;
    if (side === 'top') {
      return startIsTop ? this.startAnchor.price : this.endAnchor.price;
    }
    return startIsTop ? this.endAnchor.price : this.startAnchor.price;
  }

  private containsPoint(point: Point): boolean {
    const geometry = this.getGeometry();
    return geometry ? isPointInBounds(point, geometry, BODY_HIT_TOLERANCE) : false;
  }

  private getCursorStyle(handle: DiapsonHandleKey): PrimitiveHoveredItem['cursorStyle'] {
    const geometry = this.getGeometry();
    if (!geometry) {
      return 'default';
    }
    const dx = geometry.endPoint.x - geometry.startPoint.x;
    const dy = geometry.endPoint.y - geometry.startPoint.y;
    return dx * dy >= 0 ? 'nwse-resize' : 'nesw-resize';
  }
}

function formatSteps(steps: number): string {
  const absoluteSteps = Math.abs(steps);
  let text: string;
  if (Number.isInteger(absoluteSteps)) {
    text = absoluteSteps.toString();
  } else if (absoluteSteps >= 1000) {
    text = absoluteSteps.toFixed(0);
  } else if (absoluteSteps >= 100) {
    text = absoluteSteps.toFixed(1);
  } else {
    text = absoluteSteps.toFixed(2);
  }
  return steps < 0 ? `-${text}` : text;
}

function formatDuration(durationSeconds: number): string {
  const days = Math.floor(durationSeconds / 86400);
  const hours = Math.floor((durationSeconds % 86400) / 3600);
  const minutes = Math.floor((durationSeconds % 3600) / 60);
  const seconds = durationSeconds % 60;
  const parts: string[] = [];
  if (days > 0) {
    parts.push(`${days}d`);
  }
  if (hours > 0) {
    parts.push(`${hours}h`);
  }
  if (minutes > 0) {
    parts.push(`${minutes}m`);
  }
  return (parts.length ? parts : [`${seconds}s`]).slice(0, 2).join(' ');
}

function getItemVolume(item: Record<string, unknown> | undefined): number {
  if (!item) {
    return 0;
  }
  if (typeof item.volume === 'number') {
    return item.volume;
  }
  const customValues = item.customValues as Record<string, unknown> | undefined;
  return customValues && typeof customValues.volume === 'number' ? customValues.volume : 0;
}

function findNearestIndexByTime(data: readonly { time: Time }[], time: Time): number {
  const target = Number(time);
  if (!Number.isFinite(target)) {
    return -1;
  }
  let nearestIndex = -1;
  let nearestDistance = Number.POSITIVE_INFINITY;
  data.forEach((item, index) => {
    const itemTime = Number(item.time);
    const distance = Math.abs(itemTime - target);
    if (Number.isFinite(itemTime) && distance < nearestDistance) {
      nearestIndex = index;
      nearestDistance = distance;
    }
  });
  return nearestIndex;
}

function isTimeLabelKind(kind: string): kind is TimeLabelKind {
  return kind === 'left' || kind === 'right';
}

function isPriceLabelKind(kind: string): kind is PriceLabelKind {
  return kind === 'top' || kind === 'bottom';
}





import { getYCoordinateFromPrice, isPointInBounds } from '@core/Drawings/helpers';
import {
  clampTwoPointDrawingPoint,
  TwoPointDrawingBase,
  TwoPointDrawingMode,
  TwoPointGeometry,
} from '@core/Drawings/TwoPointDrawingBase';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';

import { FibonacciRetracementPaneView } from './paneView';
import {
  cloneFibonacciRetracementSettings,
  createDefaultSettings,
  FibonacciRetracementSettings,
  formatLevelLabel,
  getFibonacciRetracementSettingsTabs,
  getFibonacciRetracementSettingsValues,
  getVisibleFibonacciLevels,
  mergeFibonacciRetracementSettings,
} from './settings';

import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, Bounds, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type { SettingsTab, SettingsValues } from '@src/types';
import type { PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';

type FibonacciRetracementDragTarget = 'body' | 'start' | 'end';
type FibonacciRetracementHandleKey = Exclude<FibonacciRetracementDragTarget, 'body'>;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'top' | 'bottom';
type FibonacciRetracementParams = BaseDrawingParams;

interface FibonacciRetracementState {
  hidden: boolean;
  mode: TwoPointDrawingMode;
  startTime: Time | null;
  endTime: Time | null;
  startPrice: number | null;
  endPrice: number | null;
  settings: FibonacciRetracementSettings;
}

export interface FibonacciRetracementLevelRenderData {
  id: string;
  value: number;
  price: number;
  y: number;
  color: string;
  text: string;
}

export interface FibonacciRetracementAreaRenderData {
  top: number;
  bottom: number;
  color: string;
}

interface FibonacciRetracementGeometry extends TwoPointGeometry {
  width: number;
  height: number;
  levels: FibonacciRetracementLevelRenderData[];
  areas: FibonacciRetracementAreaRenderData[];
}

type FibonacciRetracementRenderSettings = Omit<FibonacciRetracementSettings, 'levels' | 'backgroundOpacity'>;

export interface FibonacciRetracementRenderData
  extends FibonacciRetracementGeometry,
    FibonacciRetracementRenderSettings {
  backgroundOpacity: number;
}

const BODY_HIT_TOLERANCE = 6;
const LINE_HIT_TOLERANCE = 6;
const MIN_DISTANCE = 6;
const PERCENT_DIVIDER = 100;
const EXTERNAL_ID = 'fibonacci-retracement-position';

export class FibonacciRetracement
  extends TwoPointDrawingBase<
    FibonacciRetracementSettings,
    FibonacciRetracementHandleKey,
    FibonacciRetracementDragTarget,
    FibonacciRetracementGeometry
  >
  implements ISeriesDrawing {
  protected settings: FibonacciRetracementSettings = createDefaultSettings();

  constructor(params: FibonacciRetracementParams) {
    super(params);
    this.initializeDrawingViews(new FibonacciRetracementPaneView(this), ['start', 'end'], ['top', 'bottom']);
    this.initializeTwoPointDrawing(params.formatObservable, params.initialEvent);
  }

  public getState(): FibonacciRetracementState {
    return {
      hidden: this.hidden,
      mode: this.mode,
      startTime: this.startAnchor?.time ?? null,
      endTime: this.endAnchor?.time ?? null,
      startPrice: this.startAnchor?.price ?? null,
      endPrice: this.endAnchor?.price ?? null,
      settings: cloneFibonacciRetracementSettings(this.settings),
    };
  }

  public setState(state: unknown): void {
    if (!state || typeof state !== 'object') {
      return;
    }
    const nextState = state as Partial<FibonacciRetracementState>;
    if (typeof nextState.hidden === 'boolean') {
      this.hidden = nextState.hidden;
    }
    const mode = this.normalizeRestoredMode(nextState.mode);
    if (mode) {
      this.mode = mode;
    }
    this.restoreAnchor('start', nextState);
    this.restoreAnchor('end', nextState);
    if (nextState.settings) {
      this.settings = mergeFibonacciRetracementSettings(createDefaultSettings(), nextState.settings);
    }
    this.render();
  }

  public getSettings(): SettingsValues {
    return getFibonacciRetracementSettingsValues(this.settings);
  }

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

  public updateSettings(settings: SettingsValues): void {
    this.settings = mergeFibonacciRetracementSettings(this.settings, settings);
    this.render();
  }

  public getRenderData(): FibonacciRetracementRenderData | null {
    const geometry = this.hidden ? null : this.getGeometry();
    if (!geometry) {
      return null;
    }
    return {
      ...geometry,
      showBackground: this.settings.showBackground,
      backgroundOpacity: this.settings.backgroundOpacity / PERCENT_DIVIDER,
      reverse: this.settings.reverse,
      labelsPosition: this.settings.labelsPosition,
      showPrices: this.settings.showPrices,
      showLevelValues: this.settings.showLevelValues,
      fontSize: this.settings.fontSize,
      isBold: this.settings.isBold,
      isItalic: this.settings.isItalic,
    };
  }

  protected getDrawingHandles(): readonly DrawingHandle<FibonacciRetracementHandleKey>[] {
    const geometry = this.getGeometry();
    if (!geometry) {
      return [];
    }
    return [
      { id: 'end', ...geometry.endPoint },
      { id: 'start', ...geometry.startPoint },
    ];
  }

  protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
    if (this.hidden || this.isCreationPending()) {
      return null;
    }
    const point = { x, y };
    const containsPoint = this.containsPoint(point);
    if (!this.isSelected()) {
      return containsPoint ? this.createHoveredItem('pointer') : null;
    }
    if (this.getDrawingHandleAtPoint(point)) {
      return this.createHoveredItem('pointer');
    }
    return containsPoint ? this.createHoveredItem('grab') : null;
  }

  protected getTimeAxisLabel(kind: string): AxisLabel | null {
    if (!this.shouldShowAxisElements() || !isTimeLabelKind(kind)) {
      return null;
    }
    return this.createDefaultAxisLabel(this.getTimeCoordinate(kind), this.getTimeText(kind));
  }

  protected getPriceAxisLabel(kind: string): AxisLabel | null {
    if (!this.shouldShowAxisElements() || !isPriceLabelKind(kind)) {
      return null;
    }
    return this.createDefaultAxisLabel(this.getPriceCoordinate(kind), this.getPriceText(kind));
  }

  protected getGeometry(): FibonacciRetracementGeometry | null {
    const baseGeometry = this.getTwoPointGeometry();
    if (!baseGeometry) {
      return null;
    }
    const levels = this.getLevels();
    const top = Math.min(baseGeometry.top, ...levels.map((level) => level.y));
    const bottom = Math.max(baseGeometry.bottom, ...levels.map((level) => level.y));
    return {
      ...baseGeometry,
      top,
      bottom,
      width: baseGeometry.right - baseGeometry.left,
      height: bottom - top,
      levels,
      areas: this.getAreas(levels),
    };
  }

  protected normalizeAnchorPoint(point: Point): Point {
    return clampTwoPointDrawingPoint(point, this.container);
  }

  protected isDrawingHit(point: Point): boolean {
    return Boolean(this.getDrawingHandleAtPoint(point)) || this.containsPoint(point);
  }

  protected getDragTarget(point: Point): FibonacciRetracementDragTarget | null {
    return this.getDrawingHandleAtPoint(point)?.id ?? (this.containsPoint(point) ? 'body' : null);
  }

  protected applyDrag(point: Point, dragTarget: FibonacciRetracementDragTarget): void {
    if (dragTarget === 'body') {
      this.moveWholeFromSnapshot(point);
      return;
    }
    this.setAnchorFromPoint(dragTarget, point);
  }

  protected isValidGeometry(geometry: FibonacciRetracementGeometry): boolean {
    const priceDistance = Math.abs(geometry.startPoint.y - geometry.endPoint.y);
    return geometry.width >= MIN_DISTANCE && priceDistance >= MIN_DISTANCE;
  }

  private restoreAnchor(kind: 'start' | 'end', state: Partial<FibonacciRetracementState>): void {
    const current = kind === 'start' ? this.startAnchor : this.endAnchor;
    const timeKey = kind === 'start' ? 'startTime' : 'endTime';
    const priceKey = kind === 'start' ? 'startPrice' : 'endPrice';
    const time = timeKey in state ? state[timeKey] ?? null : current?.time ?? null;
    const price = priceKey in state ? state[priceKey] ?? null : current?.price ?? null;
    const anchor = time === null || price === null ? null : { time, price };
    if (kind === 'start') {
      this.startAnchor = anchor;
    } else {
      this.endAnchor = anchor;
    }
  }

  private getLevels(): FibonacciRetracementLevelRenderData[] {
    if (!this.startAnchor || !this.endAnchor) {
      return [];
    }
    return getVisibleFibonacciLevels(this.settings).reduce<FibonacciRetracementLevelRenderData[]>((levels, level) => {
      const price = this.getLevelPrice(level.value);
      const y = getYCoordinateFromPrice(this.series, price);
      if (y === null) {
        return levels;
      }
      levels.push({
        id: level.id,
        value: level.value,
        price,
        y: Math.round(Number(y)),
        color: level.color,
        text: this.getLevelText(level.value, price),
      });
      return levels;
    }, []);
  }

  private getLevelPrice(value: number): number {
    const startPrice = this.startAnchor?.price ?? 0;
    const endPrice = this.endAnchor?.price ?? 0;
    return this.settings.reverse
      ? startPrice + (endPrice - startPrice) * value
      : endPrice + (startPrice - endPrice) * value;
  }

  private getAreas(levels: FibonacciRetracementLevelRenderData[]): FibonacciRetracementAreaRenderData[] {
    if (!this.settings.showBackground || levels.length < 2) {
      return [];
    }
    const orderedLevels = [...levels].sort((first, second) => first.value - second.value);
    return orderedLevels.slice(0, -1).map((level, index) => {
      const nextLevel = orderedLevels[index + 1];
      return {
        top: Math.min(level.y, nextLevel.y),
        bottom: Math.max(level.y, nextLevel.y),
        color: nextLevel.color,
      };
    });
  }

  private getLevelText(value: number, price: number): string {
    const parts: string[] = [];
    if (this.settings.showLevelValues) {
      parts.push(formatLevelLabel(value));
    }
    if (this.settings.showPrices) {
      parts.push(`(${formatPrice(price) ?? String(price)})`);
    }
    return parts.join(' ');
  }

  private getTimeCoordinate(kind: TimeLabelKind): number | null {
    const geometry = this.getGeometry();
    if (!geometry) {
      return null;
    }
    return kind === 'start' ? geometry.startPoint.x : geometry.endPoint.x;
  }

  private getPriceCoordinate(kind: PriceLabelKind): number | null {
    const geometry = this.getGeometry();
    if (!geometry) {
      return null;
    }
    return kind === 'top' ? geometry.top : geometry.bottom;
  }

  private getTimeText(kind: TimeLabelKind): string {
    const time = kind === 'start' ? this.startAnchor?.time : this.endAnchor?.time;
    if (typeof time !== 'number') {
      return '';
    }
    return formatDate(
      time as UTCTimestamp,
      this.displayFormat.dateFormat,
      this.displayFormat.timeFormat,
      this.displayFormat.showTime,
    );
  }

  private getPriceText(kind: PriceLabelKind): string {
    const price = this.getPriceValueForLabel(kind);
    return price === null ? '' : (formatPrice(price) ?? '');
  }

  private getPriceValueForLabel(kind: PriceLabelKind): number | null {
    const geometry = this.getGeometry();
    if (!geometry || !this.startAnchor || !this.endAnchor) {
      return null;
    }
    const targetY = kind === 'top' ? geometry.top : geometry.bottom;
    const edgeLevel = geometry.levels.find((level) => level.y === targetY);
    if (edgeLevel) {
      return edgeLevel.price;
    }
    return kind === 'top'
      ? Math.max(this.startAnchor.price, this.endAnchor.price)
      : Math.min(this.startAnchor.price, this.endAnchor.price);
  }

  private containsPoint(point: Point): boolean {
    const geometry = this.getGeometry();
    if (!geometry) {
      return false;
    }
    if (this.isInsideBackground(point, geometry)) {
      return true;
    }
    const xInRange = point.x >= geometry.left - LINE_HIT_TOLERANCE && point.x <= geometry.right + LINE_HIT_TOLERANCE;
    return xInRange && geometry.levels.some((level) => Math.abs(point.y - level.y) <= LINE_HIT_TOLERANCE);
  }

  private isInsideBackground(point: Point, geometry: FibonacciRetracementGeometry): boolean {
    if (!this.settings.showBackground) {
      return false;
    }
    const bounds: Bounds = {
      left: geometry.left,
      right: geometry.right,
      top: geometry.top,
      bottom: geometry.bottom,
    };
    return isPointInBounds(point, bounds, BODY_HIT_TOLERANCE);
  }

  private createHoveredItem(cursorStyle: PrimitiveHoveredItem['cursorStyle']): PrimitiveHoveredItem {
    return { cursorStyle, externalId: EXTERNAL_ID, zOrder: 'top' };
  }
}

function isTimeLabelKind(kind: string): kind is TimeLabelKind {
  return kind === 'start' || kind === 'end';
}

function isPriceLabelKind(kind: string): kind is PriceLabelKind {
  return kind === 'top' || kind === 'bottom';
}






import { getDistanceToSegment } from '@core/Drawings/utils';
import {
  TwoPointDrawingBase,
  TwoPointDrawingMode,
  TwoPointGeometry,
} from '@core/Drawings/TwoPointDrawingBase';
import { LineMarker, type SettingsTab } from '@src/types';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';

import { LineDrawingPaneView } from './paneView';
import {
  createDefaultSettings,
  getLineDrawingSettingsTabs,
  LineDrawingMarkers,
  LineDrawingSettings,
  LineDrawingStyle,
  LineDrawingTextStyle,
} from './settings';

import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type { PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';

type LineDrawingDragTarget = 'body' | 'start' | 'end';
type LineDrawingHandleKey = Exclude<LineDrawingDragTarget, 'body'>;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'start' | 'end';

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

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

interface LineDrawingGeometry extends TwoPointGeometry {}

export interface LineDrawingRenderData extends LineDrawingGeometry, LineDrawingStyle, LineDrawingTextStyle {}

const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;
const EXTERNAL_ID = 'line-drawing';

export class LineDrawing
  extends TwoPointDrawingBase<LineDrawingSettings, LineDrawingHandleKey, LineDrawingDragTarget, LineDrawingGeometry>
  implements ISeriesDrawing {
  protected settings: LineDrawingSettings;
  private readonly defaultMarkers: LineDrawingMarkers;

  constructor(params: LineDrawingParams) {
    super(params);
    this.defaultMarkers = {
      startMarker: LineMarker.normal,
      endMarker: LineMarker.normal,
      ...params.defaultMarkers,
    };
    this.settings = createDefaultSettings(this.defaultMarkers);
    this.initializeDrawingViews(new LineDrawingPaneView(this), ['start', 'end'], ['start', 'end']);
    this.initializeTwoPointDrawing(params.formatObservable, params.initialEvent);
  }

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

  public setState(state: unknown): void {
    if (!state || typeof state !== 'object') {
      return;
    }
    const nextState = state as Partial<LineDrawingState> & { mode?: unknown };
    if (typeof nextState.hidden === 'boolean') {
      this.hidden = nextState.hidden;
    }
    const mode = normalizeLineDrawingMode(nextState.mode);
    if (mode) {
      this.mode = mode;
    }
    if ('startAnchor' in nextState) {
      this.startAnchor = nextState.startAnchor ? { ...nextState.startAnchor } : null;
    }
    if ('endAnchor' in nextState) {
      this.endAnchor = nextState.endAnchor ? { ...nextState.endAnchor } : null;
    }
    if (nextState.settings) {
      this.settings = { ...createDefaultSettings(this.defaultMarkers), ...nextState.settings };
    }
    this.render();
  }

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

  public getRenderData(): LineDrawingRenderData | null {
    if (this.hidden) {
      return null;
    }
    const geometry = this.getGeometry();
    return geometry ? { ...geometry, ...this.settings } : null;
  }

  protected getDrawingHandles(): readonly DrawingHandle<LineDrawingHandleKey>[] {
    const geometry = this.getGeometry();
    if (!geometry) {
      return [];
    }
    return [
      { id: 'start', ...geometry.startPoint, shape: 'circle', borderWidth: 2 },
      { id: 'end', ...geometry.endPoint, shape: 'circle', borderWidth: 2 },
    ];
  }

  protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
    if (this.hidden || this.isCreationPending()) {
      return null;
    }
    const point = { x, y };
    if (this.getDrawingHandleAtPoint(point)) {
      return this.createHoveredItem('move');
    }
    return this.isPointNearLine(point) ? this.createHoveredItem('grab') : null;
  }

  protected getTimeAxisLabel(kind: string): AxisLabel | null {
    if (!this.shouldShowAxisElements() || !isLineLabelKind(kind)) {
      return null;
    }
    const anchor = this.getAnchor(kind);
    if (!anchor || typeof anchor.time !== 'number') {
      return null;
    }
    const point = this.getPointFromAnchor(anchor);
    const text = formatDate(
      anchor.time as UTCTimestamp,
      this.displayFormat.dateFormat,
      this.displayFormat.timeFormat,
      this.displayFormat.showTime,
    );
    return this.createDefaultAxisLabel(point?.x ?? null, text);
  }

  protected getPriceAxisLabel(kind: string): AxisLabel | null {
    if (!this.shouldShowAxisElements() || !isLineLabelKind(kind)) {
      return null;
    }
    const anchor = this.getAnchor(kind);
    if (!anchor) {
      return null;
    }
    const point = this.getPointFromAnchor(anchor);
    return this.createDefaultAxisLabel(point?.y ?? null, formatPrice(anchor.price) ?? '');
  }

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

  protected shouldHideCrosshairWhileDragging(): boolean {
    return true;
  }

  protected isDrawingHit(point: Point): boolean {
    return Boolean(this.getDrawingHandleAtPoint(point)) || this.isPointNearLine(point);
  }

  protected getDragTarget(point: Point): LineDrawingDragTarget | null {
    return this.getDrawingHandleAtPoint(point)?.id ?? (this.isPointNearLine(point) ? 'body' : null);
  }

  protected applyDrag(point: Point, dragTarget: LineDrawingDragTarget): void {
    if (dragTarget === 'body') {
      this.moveWholeFromSnapshot(point, false);
      return;
    }
    this.setAnchorFromPoint(dragTarget, point);
  }

  protected isValidGeometry(geometry: LineDrawingGeometry): boolean {
    return Math.hypot(
      geometry.endPoint.x - geometry.startPoint.x,
      geometry.endPoint.y - geometry.startPoint.y,
    ) >= MIN_LINE_SIZE;
  }

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

  private isPointNearLine(point: Point): boolean {
    const geometry = this.getGeometry();
    return geometry
      ? getDistanceToSegment(point, geometry.startPoint, geometry.endPoint) <= LINE_HIT_TOLERANCE
      : false;
  }

  private createHoveredItem(cursorStyle: PrimitiveHoveredItem['cursorStyle']): PrimitiveHoveredItem {
    return { cursorStyle, externalId: EXTERNAL_ID, zOrder: 'top' };
  }
}

function normalizeLineDrawingMode(mode: unknown): TwoPointDrawingMode | null {
  if (mode === 'idle' || mode === 'drawing' || mode === 'ready') {
    return mode;
  }
  if (mode === 'dragging' || mode === 'dragging-start' || mode === 'dragging-end' || mode === 'dragging-body') {
    return 'ready';
  }
  return null;
}

function isLineLabelKind(kind: string): kind is TimeLabelKind {
  return kind === 'start' || kind === 'end';
}





import { getDistanceToSegment } from '@core/Drawings/utils';
import { TwoPointDrawingBase, TwoPointDrawingMode } from '@core/Drawings/TwoPointDrawingBase';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';

import { RayPaneView } from './paneView';
import { createDefaultSettings, getRaySettingTabs, RaySettings, RayStyle, RayTextStyle } 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';
import type { PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';

type RayDragTarget = 'body' | 'start' | 'direction';
type RayHandleKey = Exclude<RayDragTarget, 'body'>;
type RayLabelKind = 'start' | 'direction';
type RayParams = BaseDrawingParams;

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

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

export interface RayRenderData extends RayGeometry, RayStyle, RayTextStyle {}

const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;
const EXTERNAL_ID = 'ray';

export class Ray
  extends TwoPointDrawingBase<RaySettings, RayHandleKey, RayDragTarget, RayGeometry>
  implements ISeriesDrawing {
  protected settings: RaySettings = createDefaultSettings();

  constructor(params: RayParams) {
    super(params);
    this.initializeDrawingViews(new RayPaneView(this), ['start', 'direction'], ['start', 'direction']);
    this.initializeTwoPointDrawing(params.formatObservable, params.initialEvent);
  }

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

  public setState(state: unknown): void {
    if (!state || typeof state !== 'object') {
      return;
    }
    const nextState = state as Partial<RayState> & { mode?: unknown };
    if (typeof nextState.hidden === 'boolean') {
      this.hidden = nextState.hidden;
    }
    const mode = normalizeRayMode(nextState.mode);
    if (mode) {
      this.mode = mode;
    }
    if ('startAnchor' in nextState) {
      this.startAnchor = nextState.startAnchor ? { ...nextState.startAnchor } : null;
    }
    if ('directionAnchor' in nextState) {
      this.endAnchor = nextState.directionAnchor ? { ...nextState.directionAnchor } : null;
    }
    if (nextState.settings) {
      this.settings = { ...createDefaultSettings(), ...nextState.settings };
    }
    this.render();
  }

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

  public getRenderData(): RayRenderData | null {
    if (this.hidden) {
      return null;
    }
    const geometry = this.getGeometry();
    return geometry ? { ...geometry, ...this.settings } : null;
  }

  protected getDrawingHandles(): readonly DrawingHandle<RayHandleKey>[] {
    const geometry = this.getGeometry();
    if (!geometry) {
      return [];
    }
    return [
      {
        id: 'start',
        ...geometry.startPoint,
        shape: 'circle',
        borderWidth: 2,
        strokeColor: this.settings.lineColor,
      },
      {
        id: 'direction',
        ...geometry.directionPoint,
        shape: 'circle',
        borderWidth: 2,
        strokeColor: this.settings.lineColor,
      },
    ];
  }

  protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
    if (this.hidden || this.isCreationPending()) {
      return null;
    }
    const point = { x, y };
    if (this.getDrawingHandleAtPoint(point)) {
      return this.createHoveredItem('move');
    }
    return this.isPointNearRay(point) ? this.createHoveredItem('grab') : null;
  }

  protected getTimeAxisSegments(): AxisSegment[] {
    const points = this.getDirectionPoints();
    return points ? this.createAxisSegments(points.start.x, points.direction.x) : [];
  }

  protected getPriceAxisSegments(): AxisSegment[] {
    const points = this.getDirectionPoints();
    return points ? this.createAxisSegments(points.start.y, points.direction.y) : [];
  }

  protected getTimeAxisLabel(kind: string): AxisLabel | null {
    if (!this.shouldShowAxisElements() || !isRayLabelKind(kind)) {
      return null;
    }
    const anchor = this.getAnchor(kind);
    if (!anchor || typeof anchor.time !== 'number') {
      return null;
    }
    const point = this.getPointFromAnchor(anchor);
    const text = formatDate(
      anchor.time as UTCTimestamp,
      this.displayFormat.dateFormat,
      this.displayFormat.timeFormat,
      this.displayFormat.showTime,
    );
    return this.createDefaultAxisLabel(point?.x ?? null, text);
  }

  protected getPriceAxisLabel(kind: string): AxisLabel | null {
    if (!this.shouldShowAxisElements() || !isRayLabelKind(kind)) {
      return null;
    }
    const anchor = this.getAnchor(kind);
    if (!anchor) {
      return null;
    }
    const point = this.getPointFromAnchor(anchor);
    return this.createDefaultAxisLabel(point?.y ?? null, formatPrice(anchor.price) ?? '');
  }

  protected getGeometry(): RayGeometry | null {
    const points = this.getDirectionPoints();
    if (!points) {
      return null;
    }
    const rayEndPoint = this.getRayEndPoint(points.start, points.direction);
    if (!rayEndPoint) {
      return null;
    }
    return {
      startPoint: points.start,
      directionPoint: points.direction,
      rayEndPoint,
      left: Math.min(points.start.x, rayEndPoint.x),
      right: Math.max(points.start.x, rayEndPoint.x),
      top: Math.min(points.start.y, rayEndPoint.y),
      bottom: Math.max(points.start.y, rayEndPoint.y),
    };
  }

  protected shouldHideCrosshairWhileDragging(): boolean {
    return true;
  }

  protected isDrawingHit(point: Point): boolean {
    return Boolean(this.getDrawingHandleAtPoint(point)) || this.isPointNearRay(point);
  }

  protected getDragTarget(point: Point): RayDragTarget | null {
    return this.getDrawingHandleAtPoint(point)?.id ?? (this.isPointNearRay(point) ? 'body' : null);
  }

  protected applyDrag(point: Point, dragTarget: RayDragTarget): void {
    if (dragTarget === 'body') {
      this.moveWholeFromSnapshot(point, false);
      return;
    }
    this.setAnchorFromPoint(dragTarget === 'start' ? 'start' : 'end', point);
  }

  protected isValidGeometry(geometry: RayGeometry): boolean {
    return Math.hypot(
      geometry.directionPoint.x - geometry.startPoint.x,
      geometry.directionPoint.y - geometry.startPoint.y,
    ) >= MIN_LINE_SIZE;
  }

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

  private getDirectionPoints(): { start: Point; direction: Point } | null {
    if (!this.startAnchor || !this.endAnchor) {
      return null;
    }
    const start = this.getPointFromAnchor(this.startAnchor);
    const direction = this.getPointFromAnchor(this.endAnchor);
    return start && direction ? { start, direction } : null;
  }

  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, height } = this.container.getBoundingClientRect();
    const candidates = [
      getVerticalIntersection(startPoint, dx, dy, 0, width, height),
      getVerticalIntersection(startPoint, dx, dy, width, width, height),
      getHorizontalIntersection(startPoint, dx, dy, 0, width, height),
      getHorizontalIntersection(startPoint, dx, dy, height, width, height),
    ].filter((candidate): candidate is Point => candidate !== null);
    return candidates[0] ?? directionPoint;
  }

  private isPointNearRay(point: Point): boolean {
    const geometry = this.getGeometry();
    return geometry
      ? getDistanceToSegment(point, geometry.startPoint, geometry.rayEndPoint) <= LINE_HIT_TOLERANCE
      : false;
  }

  private createHoveredItem(cursorStyle: PrimitiveHoveredItem['cursorStyle']): PrimitiveHoveredItem {
    return { cursorStyle, externalId: EXTERNAL_ID, zOrder: 'top' };
  }
}

function getVerticalIntersection(
  start: Point,
  dx: number,
  dy: number,
  x: number,
  width: number,
  height: number,
): Point | null {
  if (dx === 0) {
    return null;
  }
  const factor = (x - start.x) / dx;
  const y = start.y + factor * dy;
  return factor >= 1 && x >= 0 && x <= width && y >= 0 && y <= height ? { x, y } : null;
}

function getHorizontalIntersection(
  start: Point,
  dx: number,
  dy: number,
  y: number,
  width: number,
  height: number,
): Point | null {
  if (dy === 0) {
    return null;
  }
  const factor = (y - start.y) / dy;
  const x = start.x + factor * dx;
  return factor >= 1 && x >= 0 && x <= width && y >= 0 && y <= height ? { x, y } : null;
}

function normalizeRayMode(mode: unknown): TwoPointDrawingMode | null {
  if (mode === 'idle' || mode === 'drawing' || mode === 'ready') {
    return mode;
  }
  if (mode === 'dragging' || mode === 'dragging-start' || mode === 'dragging-direction' || mode === 'dragging-body') {
    return 'ready';
  }
  return null;
}

function isRayLabelKind(kind: string): kind is RayLabelKind {
  return kind === 'start' || kind === 'direction';
}