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


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

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import { DrawingHandlesPrimitive, type DrawingHandle } from '@core/Drawings/handles';
import {
  getAnchorFromPoint,
  getPointerPoint as getPointerPointFromEvent,
  getRawPointerPoint,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
} from '@core/Drawings/helpers';
import type { Anchor, AxisLabel, AxisSegment, Point, SeriesApi, UpdatableView } from '@core/Drawings/types';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import type { ChartOptionsModel, SettingsTab, SettingsValues } from '@src/types';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';

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

interface DrawingViewsOptions {
  timeAxisPane?: boolean;
  priceAxisPane?: boolean;
  timeAxisPaneZOrder?: PrimitivePaneViewZOrder;
  priceAxisPaneZOrder?: PrimitivePaneViewZOrder;
}

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 isBound = false;
  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 removeSelfCallback?: () => void;
  private readonly openSettingsCallback?: () => void;
  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 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.removeSelfCallback = removeSelf;
    this.openSettingsCallback = 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.notifySettingsChanged();
    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: Number(x), y: Number(y) };
  }

  protected getRoundedPointFromAnchor(anchor: Anchor): Point | null {
    const point = this.getPointFromAnchor(anchor);

    if (!point) {
      return null;
    }

    return { x: Math.round(point.x), y: Math.round(point.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 createTimeAxisLabelForValue(time: Time | null, coordinate?: number | null): AxisLabel | null {
    if (typeof time !== 'number') {
      return null;
    }

    const resolvedCoordinate =
      coordinate === undefined ? getXCoordinateFromTime(this.chart, time, this.series) : coordinate;
    const numericCoordinate = resolvedCoordinate === null ? null : Number(resolvedCoordinate);
    const text = formatDate(
      time as UTCTimestamp,
      this.displayFormat.dateFormat,
      this.displayFormat.timeFormat,
      this.displayFormat.showTime,
    );

    return this.createDefaultAxisLabel(numericCoordinate, text);
  }

  protected createPriceAxisLabelForValue(price: number | null, coordinate?: number | null): AxisLabel | null {
    if (price === null) {
      return null;
    }

    const resolvedCoordinate = coordinate === undefined ? getYCoordinateFromPrice(this.series, price) : coordinate;
    const numericCoordinate = resolvedCoordinate === null ? null : Number(resolvedCoordinate);

    return this.createDefaultAxisLabel(numericCoordinate, formatPrice(price) ?? '');
  }

  protected getPrimaryPointerDownPoint(event: PointerEvent): Point | null {
    if (this.hidden || event.button !== 0) {
      return null;
    }

    return this.getEventPoint(event);
  }

  protected handleCreationAndReadyPointerDown(
    event: PointerEvent,
    handleCreation: (event: PointerEvent, point: Point) => boolean,
    handleReady: (event: PointerEvent, point: Point) => void,
  ): void {
    const point = this.getPrimaryPointerDownPoint(event);

    if (!point) {
      return;
    }

    if (handleCreation(event, point)) {
      return;
    }

    handleReady(event, point);
  }

  protected openSettingsOnDoubleClick(
    event: MouseEvent,
    isReady: boolean,
    isHit: (point: Point) => boolean,
    requireSelected = false,
  ): void {
    if (this.hidden || !isReady || (requireSelected && !this.isSelected())) {
      return;
    }

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

    if (!isHit(point)) {
      return;
    }

    this.consumeEvent(event);
    this.openDrawingSettings();
  }

  protected removeDrawing(): boolean {
    if (!this.removeSelfCallback) {
      return false;
    }

    this.removeSelfCallback();
    return true;
  }

  protected openDrawingSettings(): void {
    this.openSettingsCallback?.();
  }

  protected notifySettingsChanged(): void {
    this.settingsSubject.next(this.getSettings());
  }

  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[] = [],
    options: DrawingViewsOptions = {},
  ): void {
    const {
      timeAxisPane = true,
      priceAxisPane = true,
      timeAxisPaneZOrder = 'bottom',
      priceAxisPaneZOrder = 'bottom',
    } = options;

    this.paneViewsList = [paneView];
    this.timeAxisPaneViewsList = timeAxisPane
      ? [
          new CustomTimeAxisPaneView({
            getAxisSegments: () => this.getTimeAxisSegments(),
            zOrder: timeAxisPaneZOrder,
          }),
        ]
      : [];
    this.priceAxisPaneViewsList = priceAxisPane
      ? [
          new CustomPriceAxisPaneView({
            getAxisSegments: () => this.getPriceAxisSegments(),
            zOrder: priceAxisPaneZOrder,
          }),
        ]
      : [];
    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 unknown 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 }];
  }

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

    this.isBound = true;
    window.addEventListener('pointermove', this.onPointerMove);
    window.addEventListener('pointerup', this.onPointerUp);
    window.addEventListener('pointercancel', this.onPointerUp);
  }

  protected 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 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 getAxisBoundsGeometry(): AxisBoundsGeometry | null {
    const geometry = this.getGeometry() as Partial<AxisBoundsGeometry> | null;

    if (!geometry) {
      return null;
    }

    const { left, right, top, bottom } = geometry;
    const values = [left, right, top, bottom];

    if (!values.every((value) => typeof value === 'number' && Number.isFinite(value))) {
      return null;
    }

    return {
      left: left as number,
      right: right as number,
      top: top as number,
      bottom: bottom as number,
    };
  }

  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, type AxisBoundsGeometry, type BaseDrawingParams } from '@core/Drawings/DrawingBase';
import { clampPointToContainer, getContainerSize, getPriceDelta, shiftTimeByPixels } from '@core/Drawings/helpers';
import type { Anchor, Point } from '@core/Drawings/types';
import type { SettingsValues } from '@src/types';
import type { MouseEventParams, Time } 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;
}

export interface LegacyTwoPointAnchorState {
  startTime: Time | null;
  endTime: Time | null;
  startPrice: number | null;
  endPrice: number | null;
}

export interface LegacyTwoPointDrawingState extends LegacyTwoPointAnchorState {
  hidden: boolean;
  mode: TwoPointDrawingMode;
}

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.getRoundedPointFromAnchor(this.startAnchor);
    const endPoint = this.getRoundedPointFromAnchor(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 getLegacyTwoPointState(): LegacyTwoPointDrawingState {
    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,
    };
  }

  protected restoreLegacyTwoPointState(
    state: Partial<LegacyTwoPointDrawingState>,
    preserveNullAnchors = false,
  ): void {
    if (typeof state.hidden === 'boolean') {
      this.hidden = state.hidden;
    }

    const mode = this.normalizeRestoredMode(state.mode);

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

    this.restoreLegacyAnchors(state, preserveNullAnchors);
  }

  protected restoreLegacyAnchors(
    state: Partial<LegacyTwoPointAnchorState>,
    preserveNullValues = false,
  ): void {
    this.startAnchor = this.restoreLegacyAnchor('start', state, this.startAnchor, preserveNullValues);
    this.endAnchor = this.restoreLegacyAnchor('end', state, this.endAnchor, preserveNullValues);
  }

  protected createRenderDataWithSettings<TRenderData>(geometry: TGeometry | null): TRenderData | null {
    if (this.hidden || !geometry) {
      return null;
    }

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

  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 shouldStopPropagationWhileDragging(): boolean {
    return false;
  }

  protected handleMissingGeometry(): void {
    this.removeOrResetInvalidDrawing();
  }

  protected handleInvalidGeometry(): void {
    this.removeOrResetInvalidDrawing();
  }

  protected beforeResetToIdle(): void {}

  protected handleDoubleClick(event: MouseEvent): void {
    this.openSettingsOnDoubleClick(event, this.mode === 'ready', (point) => this.isDrawingHit(point));
  }

  protected handlePointerDown(event: PointerEvent): void {
    this.handleCreationAndReadyPointerDown(
      event,
      (pointerEvent, point) => this.handleCreationPointerDown(pointerEvent, point),
      (pointerEvent, point) => this.handleReadyPointerDown(pointerEvent, point),
    );
  }

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

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

    if (!this.isActiveDragEvent(event) || !this.activeDragTarget) {
      return;
    }

    if (this.shouldStopPropagationWhileDragging()) {
      this.consumeEvent(event);
    } else {
      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.handleMissingGeometry();
      return;
    }

    if (!this.isValidGeometry(geometry)) {
      this.handleInvalidGeometry();
      return;
    }

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

  private removeOrResetInvalidDrawing(): void {
    if (this.removeDrawing()) {
      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 {
    const shouldRestoreCrosshair = this.crosshairHiddenByDrag;

    this.mode = 'ready';
    this.resolveReady?.();
    this.clearDragState();
    this.crosshairHiddenByDrag = false;

    if (shouldRestoreCrosshair) {
      this.showCrosshair();
    }

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

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

    const offset = {
      x: point.x - this.dragStartPoint.x,
      y: point.y - this.dragStartPoint.y,
    };

    return constrainToContainer ? this.clampDragOffset(offset) : offset;
  }

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

  private restoreLegacyAnchor(
    kind: 'start' | 'end',
    state: Partial<LegacyTwoPointAnchorState>,
    current: Anchor | null,
    preserveNullValues: boolean,
  ): Anchor | null {
    const timeKey = kind === 'start' ? 'startTime' : 'endTime';
    const priceKey = kind === 'start' ? 'startPrice' : 'endPrice';
    const currentTime = current?.time ?? null;
    const currentPrice = current?.price ?? null;
    const time = this.getRestoredValue(state, timeKey, currentTime, preserveNullValues);
    const price = this.getRestoredValue(state, priceKey, currentPrice, preserveNullValues);

    return time === null || price === null ? null : { time, price };
  }

  private getRestoredValue<TKey extends keyof LegacyTwoPointAnchorState>(
    state: Partial<LegacyTwoPointAnchorState>,
    key: TKey,
    currentValue: LegacyTwoPointAnchorState[TKey],
    preserveNullValues: boolean,
  ): LegacyTwoPointAnchorState[TKey] {
    if (!(key in state)) {
      return currentValue;
    }

    const nextValue = state[key];

    if (nextValue == null && preserveNullValues) {
      return currentValue;
    }

    return (nextValue ?? null) as LegacyTwoPointAnchorState[TKey];
  }
}

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,
  type TwoPointDrawingMode,
} from '@core/Drawings/TwoPointDrawingBase';
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 { AxisBoundsGeometry, BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type { SettingsTab } from '@src/types';
import type { PrimitiveHoveredItem, Time } 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 AxisBoundsGeometry {
  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 {
      ...this.getLegacyTwoPointState(),
      settings: { ...this.settings },
    };
  }

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

    const nextState = state as Partial<RectangleState>;

    this.restoreLegacyTwoPointState(nextState);

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

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

  public getRenderData(): RectangleRenderData | null {
    return this.createRenderDataWithSettings<RectangleRenderData>(this.getGeometry());
  }

  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.createTimeAxisLabelForValue(this.getTimeValueForLabel(kind), this.getTimeCoordinate(kind));
  }

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

    return this.createPriceAxisLabelForValue(this.getPriceValueForLabel(kind), this.getPriceCoordinate(kind));
  }

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

    if (!geometry) {
      return null;
    }
    return {
      left: geometry.left,
      right: geometry.right,
      top: geometry.top,
      bottom: geometry.bottom,
      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 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 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 { getYCoordinateFromPrice, isPointInBounds } from '@core/Drawings/helpers';
import {
  clampTwoPointDrawingPoint,
  TwoPointDrawingBase,
  type TwoPointDrawingMode,
  type TwoPointGeometry,
} from '@core/Drawings/TwoPointDrawingBase';
import { formatPrice } from '@src/utils';

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

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

    const nextState = state as Partial<FibonacciRetracementState>;

    this.restoreLegacyTwoPointState(nextState, true);

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

    const time = kind === 'start' ? this.startAnchor?.time ?? null : this.endAnchor?.time ?? null;

    return this.createTimeAxisLabelForValue(time, this.getTimeCoordinate(kind));
  }

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

    return this.createPriceAxisLabelForValue(this.getPriceValueForLabel(kind), this.getPriceCoordinate(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 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 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 { findNearestTimeIndex, isPointInBounds } from '@core/Drawings/helpers';
import {
  clampTwoPointDrawingPoint,
  TwoPointDrawingBase,
  type TwoPointDrawingMode,
  type 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 {
      ...this.getLegacyTwoPointState(),
      rangeMode: this.rangeMode,
      settings: { ...this.settings },
    };
  }

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

    const nextState = state as Partial<DiapsonState>;

    this.restoreLegacyTwoPointState(nextState);

    if (nextState.rangeMode) {
      this.rangeMode = nextState.rangeMode;
    }
    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 handleMissingGeometry(): void {
    this.resetToIdle();
  }

  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 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 = findNearestTimeIndex(data, this.startAnchor.time);
    const endIndex = findNearestTimeIndex(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 isTimeLabelKind(kind: string): kind is TimeLabelKind {
  return kind === 'left' || kind === 'right';
}

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








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

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 } from 'lightweight-charts';

type LineDrawingDragTarget = 'body' | 'start' | 'end';
type LineDrawingStateMode = 'idle' | 'drawing' | 'ready' | 'dragging-start' | 'dragging-end' | 'dragging-body';
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: LineDrawingStateMode;
  startAnchor: Anchor | null;
  endAnchor: Anchor | null;
  settings: LineDrawingSettings;
}

type LineDrawingGeometry = 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: getLineDrawingStateMode(this.mode, this.activeDragTarget),
      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 {
    return this.createRenderDataWithSettings<LineDrawingRenderData>(this.getGeometry());
  }

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

    return this.createTimeAxisLabelForValue(this.getAnchor(kind)?.time ?? null);
  }

  protected getPriceAxisLabel(kind: string): AxisLabel | null {
    if (!this.shouldShowAxisElements() || !isLineLabelKind(kind)) {
      return null;
    }

    return this.createPriceAxisLabelForValue(this.getAnchor(kind)?.price ?? null);
  }

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

  protected shouldHideCrosshairWhileDragging(): boolean {
    return true;
  }

  protected shouldStopPropagationWhileDragging(): boolean {
    return true;
  }

  protected handleMissingGeometry(): void {
    return;
  }

  protected handleInvalidGeometry(): void {
    this.removeDrawing();
  }

  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 getLineDrawingStateMode(
  mode: TwoPointDrawingMode,
  dragTarget: LineDrawingDragTarget | null,
): LineDrawingStateMode {
  if (mode !== 'dragging') {
    return mode;
  }

  if (dragTarget === 'start') {
    return 'dragging-start';
  }

  if (dragTarget === 'end') {
    return 'dragging-end';
  }

  return 'dragging-body';
}

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, type TwoPointDrawingMode } from '@core/Drawings/TwoPointDrawingBase';

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 } from 'lightweight-charts';

type RayDragTarget = 'body' | 'start' | 'direction';
type RayStateMode = 'idle' | 'drawing' | 'ready' | 'dragging-start' | 'dragging-direction' | 'dragging-body';
type RayHandleKey = Exclude<RayDragTarget, 'body'>;
type RayLabelKind = 'start' | 'direction';
type RayParams = BaseDrawingParams;

interface RayState {
  hidden: boolean;
  mode: RayStateMode;
  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: getRayStateMode(this.mode, this.activeDragTarget),
      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 {
    return this.createRenderDataWithSettings<RayRenderData>(this.getGeometry());
  }

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

    return this.createTimeAxisLabelForValue(this.getAnchor(kind)?.time ?? null);
  }

  protected getPriceAxisLabel(kind: string): AxisLabel | null {
    if (!this.shouldShowAxisElements() || !isRayLabelKind(kind)) {
      return null;
    }

    return this.createPriceAxisLabelForValue(this.getAnchor(kind)?.price ?? null);
  }

  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 shouldStopPropagationWhileDragging(): boolean {
    return true;
  }

  protected handleMissingGeometry(): void {
    return;
  }

  protected handleInvalidGeometry(): void {
    this.removeDrawing();
  }

  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.getRoundedPointFromAnchor(this.startAnchor);
    const direction = this.getRoundedPointFromAnchor(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 getRayStateMode(mode: TwoPointDrawingMode, dragTarget: RayDragTarget | null): RayStateMode {
  if (mode !== 'dragging') {
    return mode;
  }

  if (dragTarget === 'start') {
    return 'dragging-start';
  }

  if (dragTarget === 'direction') {
    return 'dragging-direction';
  }

  return 'dragging-body';
}

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











import { getTimeFromXCoordinate, getXCoordinateFromTime, getYCoordinateFromPrice } from '@core/Drawings/helpers';
import { getDistanceToSegment } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';

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

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

import { RegressionTrendPaneView } from './paneView';
import {
  createDefaultSettings,
  getRegressionTrendSettingsTabs,
  RegressionTrendSettings,
  RegressionTrendStyle,
} from './settings';

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

type RegressionTrendMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type DragTarget = 'start' | 'end' | 'body' | null;
type RegressionTrendHandleKey = Exclude<DragTarget, 'body' | null>;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'start' | 'end';

type RegressionTrendParams = BaseDrawingParams;

interface RegressionTrendState {
  hidden: boolean;
  mode: RegressionTrendMode;
  startTime: Time | null;
  endTime: Time | null;
  settings: RegressionTrendSettings;
}

interface RegressionSeriesData {
  time: Time;
  close?: number;
  value?: number;
}

interface RegressionResult {
  baseStartPrice: number;
  baseEndPrice: number;
  upperStartPrice: number;
  upperEndPrice: number;
  lowerStartPrice: number;
  lowerEndPrice: number;
  correlation: number;
}

interface RegressionTrendGeometry {
  baseStartPoint: Point;
  baseEndPoint: Point;
  upperStartPoint: Point;
  upperEndPoint: Point;
  lowerStartPoint: Point;
  lowerEndPoint: Point;
  baseStartPrice: number;
  baseEndPrice: number;
  correlation: number;
  left: number;
  right: number;
  top: number;
  bottom: number;
}

export interface RegressionTrendRenderData extends RegressionTrendGeometry, RegressionTrendStyle {
  showChannel: boolean;
}

const LINE_HIT_TOLERANCE = 8;
const MIN_BAR_DISTANCE = 1;
const REGRESSION_DEVIATION = 2;

export class RegressionTrend
  extends SeriesDrawingBase<RegressionTrendSettings, RegressionTrendHandleKey>
  implements ISeriesDrawing
{
  private openSettings?: () => void;

  protected settings: RegressionTrendSettings = createDefaultSettings();
  protected mode: RegressionTrendMode = 'idle';

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

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

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

    this.openSettings = openSettings;

    this.initializeDrawingViews(new RegressionTrendPaneView(this), ['start', 'end'], ['start', 'end']);

    this.initializeDrawing(formatObservable, initialEvent, (point) => this.startDrawing(point));
  }

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

  public getState(): RegressionTrendState {
    return {
      hidden: this.hidden,
      mode: this.mode,
      startTime: this.startTime,
      endTime: this.endTime,
      settings: { ...this.settings },
    };
  }

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

    const next = state as Partial<RegressionTrendState>;

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

    if (next.mode) {
      this.mode = next.mode === 'dragging' ? 'ready' : next.mode;
    }

    if ('startTime' in next) {
      this.startTime = next.startTime ?? null;
    }

    if ('endTime' in next) {
      this.endTime = next.endTime ?? null;
    }

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

    this.render();
  }

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      ...geometry,
      ...this.settings,
      showChannel: this.mode === 'ready' || this.mode === 'dragging',
    };
  }

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

    if (!geometry) {
      return [];
    }

    return [
      {
        id: 'start',
        ...geometry.baseStartPoint,
        shape: 'circle',
        borderWidth: 2,
      },
      {
        id: 'end',
        ...geometry.baseEndPoint,
        shape: 'circle',
        borderWidth: 2,
      },
    ];
  }

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

    const point = { x, y };

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

      return {
        cursorStyle: 'pointer',
        externalId: 'regression-trend',
        zOrder: 'top',
      };
    }

    const dragTarget = this.getDragTarget(point);

    if (!dragTarget) {
      return null;
    }

    return {
      cursorStyle: dragTarget === 'body' ? 'grab' : 'ew-resize',
      externalId: 'regression-trend',
      zOrder: 'top',
    };
  }

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();
    const from = Math.min(geometry.baseStartPoint.y, geometry.baseEndPoint.y);
    const to = Math.max(geometry.baseStartPoint.y, geometry.baseEndPoint.y);

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

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

    const time = kind === 'start' ? this.startTime : this.endTime;

    if (typeof time !== 'number') {
      return null;
    }

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

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

    const { colors } = getThemeStore();

    return {
      coordinate,
      text: formatDate(
        time as UTCTimestamp,
        this.displayFormat.dateFormat,
        this.displayFormat.timeFormat,
        this.displayFormat.showTime,
      ),
      textColor: colors.chartPriceLineText,
      backgroundColor: colors.axisMarkerLabelFill,
    };
  }

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    const price = kind === 'start' ? geometry.baseStartPrice : geometry.baseEndPrice;
    const coordinate = getYCoordinateFromPrice(this.series, price);

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

    const { colors } = getThemeStore();

    return {
      coordinate,
      text: formatPrice(price) ?? '',
      textColor: colors.chartPriceLineText,
      backgroundColor: colors.axisMarkerLabelFill,
    };
  }

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

    event.preventDefault();
    event.stopPropagation();
    this.openSettings?.();
  };

  private canOpenSettings(event: MouseEvent): boolean {
    if (this.hidden || this.mode !== 'ready' || !this.isSelected()) {
      return false;
    }

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

    return this.containsPoint(point) || Boolean(this.getDrawingHandleAtPoint(point));
  }

  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') {
      const time = this.getBarTime(point.x);

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

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

      this.endTime = time;

      if (!this.hasValidRange()) {
        this.render();
        return;
      }

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

      this.render();
      return;
    }

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

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

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

      this.select();
      return;
    }

    const dragTarget = this.getDragTarget(point);

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

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

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

    this.mode = 'dragging';

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

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

    if (this.mode === 'drawing') {
      const time = this.getBarTime(point.x);

      if (time !== null) {
        this.endTime = time;
        this.render();
      }

      return;
    }

    if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId || !this.dragStateSnapshot) {
      return;
    }

    event.preventDefault();

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

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

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

    this.mode = 'ready';

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

  protected getGeometry(): RegressionTrendGeometry | null {
    if (this.startTime === null || this.endTime === null) {
      return null;
    }

    const regression = this.getRegression();

    if (!regression) {
      return null;
    }

    const baseStartPoint = this.getPoint(this.startTime, regression.baseStartPrice);
    const baseEndPoint = this.getPoint(this.endTime, regression.baseEndPrice);
    const upperStartPoint = this.getPoint(this.startTime, regression.upperStartPrice);
    const upperEndPoint = this.getPoint(this.endTime, regression.upperEndPrice);
    const lowerStartPoint = this.getPoint(this.startTime, regression.lowerStartPrice);
    const lowerEndPoint = this.getPoint(this.endTime, regression.lowerEndPrice);

    if (!baseStartPoint || !baseEndPoint || !upperStartPoint || !upperEndPoint || !lowerStartPoint || !lowerEndPoint) {
      return null;
    }

    const points = [upperStartPoint, upperEndPoint, lowerStartPoint, lowerEndPoint];

    return {
      baseStartPoint,
      baseEndPoint,
      upperStartPoint,
      upperEndPoint,
      lowerStartPoint,
      lowerEndPoint,
      baseStartPrice: regression.baseStartPrice,
      baseEndPrice: regression.baseEndPrice,
      correlation: regression.correlation,
      left: Math.min(...points.map((item) => item.x)),
      right: Math.max(...points.map((item) => item.x)),
      top: Math.min(...points.map((item) => item.y)),
      bottom: Math.max(...points.map((item) => item.y)),
    };
  }

  private startDrawing(point: Point): void {
    const time = this.getBarTime(point.x);

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

    this.startTime = time;
    this.endTime = time;
    this.mode = 'drawing';

    this.render();
  }

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

    if (!snapshot || snapshot.startTime === null || snapshot.endTime === null) {
      return;
    }

    switch (this.activeDragTarget) {
      case 'start':
        this.moveEdge('start', point);
        break;
      case 'end':
        this.moveEdge('end', point);
        break;
      case 'body':
        this.moveWhole(snapshot, point);
        break;
      default:
        break;
    }
  }

  private moveEdge(kind: TimeLabelKind, point: Point): void {
    const time = this.getBarTime(point.x);

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

    const previousTime = kind === 'start' ? this.startTime : this.endTime;

    if (kind === 'start') {
      this.startTime = time;
    } else {
      this.endTime = time;
    }

    if (this.hasValidRange()) {
      return;
    }

    if (kind === 'start') {
      this.startTime = previousTime;
    } else {
      this.endTime = previousTime;
    }
  }

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

    const data = this.getSeriesData();
    const dragStartIndex = this.getBarIndexByX(this.dragStartPoint.x, data);
    const currentIndex = this.getBarIndexByX(point.x, data);
    const startIndex = this.getBarIndex(snapshot.startTime, data);
    const endIndex = this.getBarIndex(snapshot.endTime, data);

    if (dragStartIndex === null || currentIndex === null || startIndex === null || endIndex === null) {
      return;
    }

    const rawOffset = currentIndex - dragStartIndex;
    const minIndex = Math.min(startIndex, endIndex);
    const maxIndex = Math.max(startIndex, endIndex);
    const minOffset = -minIndex;
    const maxOffset = data.length - 1 - maxIndex;
    const offset = Math.max(minOffset, Math.min(rawOffset, maxOffset));

    this.startTime = data[startIndex + offset].time;
    this.endTime = data[endIndex + offset].time;
  }

  private getRegression(): RegressionResult | null {
    const data = this.getSeriesData();
    const range = this.getBarRange(data);

    if (!range) {
      return null;
    }

    const values: number[] = [];

    for (let index = range.left; index <= range.right; index += 1) {
      const value = getSeriesValue(data[index]);

      if (value !== null) {
        values.push(value);
      }
    }

    if (values.length < 2) {
      return null;
    }

    const regression = calculateRegression(values);
    const offset = regression.deviation * REGRESSION_DEVIATION;
    const baseStartPrice = range.reversed ? regression.endValue : regression.startValue;
    const baseEndPrice = range.reversed ? regression.startValue : regression.endValue;

    return {
      baseStartPrice,
      baseEndPrice,
      upperStartPrice: baseStartPrice + offset,
      upperEndPrice: baseEndPrice + offset,
      lowerStartPrice: baseStartPrice - offset,
      lowerEndPrice: baseEndPrice - offset,
      correlation: regression.correlation,
    };
  }

  private getBarRange(data = this.getSeriesData()): {
    left: number;
    right: number;
    reversed: boolean;
  } | null {
    if (this.startTime === null || this.endTime === null) {
      return null;
    }

    const startIndex = this.getBarIndex(this.startTime, data);
    const endIndex = this.getBarIndex(this.endTime, data);

    if (startIndex === null || endIndex === null) {
      return null;
    }

    return {
      left: Math.min(startIndex, endIndex),
      right: Math.max(startIndex, endIndex),
      reversed: startIndex > endIndex,
    };
  }

  private hasValidRange(): boolean {
    const range = this.getBarRange();

    if (!range) {
      return false;
    }

    return range.right - range.left >= MIN_BAR_DISTANCE;
  }

  private getBarTime(x: number): Time | null {
    const time = getTimeFromXCoordinate(this.chart, x);

    if (typeof time !== 'number') {
      return null;
    }

    const data = this.getSeriesData();
    const index = findNearestBarIndex(data, time);

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

    return data[index].time;
  }

  private getBarIndexByX(x: number, data: readonly RegressionSeriesData[]): number | null {
    const time = getTimeFromXCoordinate(this.chart, x);

    if (typeof time !== 'number') {
      return null;
    }

    return findNearestBarIndex(data, time);
  }

  private getBarIndex(time: Time, data: readonly RegressionSeriesData[]): number | null {
    if (typeof time !== 'number') {
      return null;
    }

    return findNearestBarIndex(data, time);
  }

  private getSeriesData(): readonly RegressionSeriesData[] {
    return this.series.data() as readonly RegressionSeriesData[];
  }

  private getPoint(time: Time, price: number): Point | null {
    const x = getXCoordinateFromTime(this.chart, time, this.series);
    const y = getYCoordinateFromPrice(this.series, price);

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

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

  private getDragTarget(point: Point): DragTarget {
    const handle = this.getDrawingHandleAtPoint(point);

    if (handle) {
      return handle.id;
    }

    if (this.containsPoint(point)) {
      return 'body';
    }

    return null;
  }

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

    if (!geometry) {
      return false;
    }

    if (getDistanceToSegment(point, geometry.upperStartPoint, geometry.upperEndPoint) <= LINE_HIT_TOLERANCE) {
      return true;
    }

    if (getDistanceToSegment(point, geometry.lowerStartPoint, geometry.lowerEndPoint) <= LINE_HIT_TOLERANCE) {
      return true;
    }

    if (getDistanceToSegment(point, geometry.baseStartPoint, geometry.baseEndPoint) <= LINE_HIT_TOLERANCE) {
      return true;
    }

    return isPointInPolygon(point, [
      geometry.upperStartPoint,
      geometry.upperEndPoint,
      geometry.lowerEndPoint,
      geometry.lowerStartPoint,
    ]);
  }
}

function calculateRegression(values: readonly number[]): {
  startValue: number;
  endValue: number;
  deviation: number;
  correlation: number;
} {
  const count = values.length;
  const meanX = (count - 1) / 2;
  const meanY = values.reduce((sum, value) => sum + value, 0) / count;

  let sumXX = 0;
  let sumXY = 0;
  let sumYY = 0;

  for (let index = 0; index < count; index += 1) {
    const x = index - meanX;
    const y = values[index] - meanY;

    sumXX += x * x;
    sumXY += x * y;
    sumYY += y * y;
  }

  const slope = sumXX === 0 ? 0 : sumXY / sumXX;
  const intercept = meanY - slope * meanX;

  let residualSum = 0;

  for (let index = 0; index < count; index += 1) {
    const expected = intercept + slope * index;
    const residual = values[index] - expected;

    residualSum += residual * residual;
  }

  const denominator = Math.sqrt(sumXX * sumYY);

  return {
    startValue: intercept,
    endValue: intercept + slope * (count - 1),
    deviation: Math.sqrt(residualSum / count),
    correlation: denominator === 0 ? 0 : sumXY / denominator,
  };
}

function getSeriesValue(data: RegressionSeriesData): number | null {
  if (typeof data.close === 'number' && Number.isFinite(data.close)) {
    return data.close;
  }

  if (typeof data.value === 'number' && Number.isFinite(data.value)) {
    return data.value;
  }

  return null;
}

function findNearestBarIndex(data: readonly RegressionSeriesData[], targetTime: number): number | null {
  if (!data.length) {
    return null;
  }

  let left = 0;
  let right = data.length - 1;

  while (left <= right) {
    const middle = Math.floor((left + right) / 2);
    const { time } = data[middle];

    if (typeof time !== 'number') {
      return findNearestBarIndexLinear(data, targetTime);
    }

    if (time === targetTime) {
      return middle;
    }

    if (time < targetTime) {
      left = middle + 1;
    } else {
      right = middle - 1;
    }
  }

  const nextIndex = Math.min(left, data.length - 1);
  const previousIndex = Math.max(0, nextIndex - 1);

  const nextTime = data[nextIndex].time;
  const previousTime = data[previousIndex].time;

  if (typeof nextTime !== 'number' || typeof previousTime !== 'number') {
    return findNearestBarIndexLinear(data, targetTime);
  }

  return Math.abs(previousTime - targetTime) <= Math.abs(nextTime - targetTime) ? previousIndex : nextIndex;
}

function findNearestBarIndexLinear(data: readonly RegressionSeriesData[], targetTime: number): number | null {
  let result: number | null = null;
  let minDistance = Number.POSITIVE_INFINITY;

  data.forEach((item, index) => {
    if (typeof item.time !== 'number') {
      return;
    }

    const distance = Math.abs(item.time - targetTime);

    if (distance >= minDistance) {
      return;
    }

    minDistance = distance;
    result = index;
  });

  return result;
}

function isPointInPolygon(point: Point, polygon: Point[]): boolean {
  let inside = false;

  for (let index = 0, previousIndex = polygon.length - 1; index < polygon.length; previousIndex = index, index += 1) {
    const current = polygon[index];
    const previous = polygon[previousIndex];

    const intersects =
      current.y > point.y !== previous.y > point.y &&
      point.x < ((previous.x - current.x) * (point.y - current.y)) / (previous.y - current.y) + current.x;

    if (intersects) {
      inside = !inside;
    }
  }

  return inside;
}













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

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
  clampPointToContainer,
  getAnchorFromPoint,
  getContainerSize,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  isPointInBounds,
} from '@core/Drawings/helpers';
import { createAxisLabel, updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';

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

import { VolumeProfilePaneView } from './paneView';

import {
  createDefaultSettings,
  getVolumeProfileSettingsTabs,
  VolumeProfileSettings,
  VolumeProfileStyle,
} from './settings';

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

export type VolumeProfileKind = 'fixedRange' | 'visibleRange';

type VolumeProfileMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type DragTarget = 'body' | 'poc' | 'start' | 'end' | null;
type VolumeProfileHandleId = Exclude<DragTarget, 'body' | null>;

interface VolumeProfileParams extends BaseDrawingParams {
  profileKind?: VolumeProfileKind;
}

interface SeriesCandleData {
  time: Time;
  open?: number;
  high?: number;
  low?: number;
  close?: number;
  value?: number;
  volume?: number;
  customValues?: {
    open?: number;
    high?: number;
    low?: number;
    close?: number;
    value?: number;
    volume?: number;
  };
}

export interface VolumeProfileState {
  hidden: boolean;
  mode: VolumeProfileMode;
  startAnchor: Anchor | null;
  endAnchor: Anchor | null;
  visibleRangeStartRatio: number;
  settings: VolumeProfileSettings;
}

interface VolumeProfileDataRow {
  priceLow: number;
  priceHigh: number;
  buyVolume: number;
  sellVolume: number;
  totalVolume: number;
}

interface VolumeProfileGeometry extends Bounds {
  width: number;
  height: number;
  startPoint: Point;
  endPoint: Point;
}

interface VolumeProfileRenderRow {
  top: number;
  height: number;
  buyWidth: number;
  sellWidth: number;
}

export interface VolumeProfileRenderData extends VolumeProfileGeometry, VolumeProfileStyle {
  profileKind: VolumeProfileKind;
  rows: VolumeProfileRenderRow[];
  pocY: number | null;
}

const PROFILE_ROW_COUNT = 24;
const BODY_HIT_TOLERANCE = 4;
const MIN_PROFILE_SIZE = 8;
const MAX_VISIBLE_RANGE_START_RATIO = 0.95;

export class VolumeProfile
  extends SeriesDrawingBase<VolumeProfileSettings, VolumeProfileHandleId>
  implements ISeriesDrawing
{
  private removeSelf?: () => void;
  private openSettings?: () => void;

  private readonly profileKind: VolumeProfileKind;
  protected settings: VolumeProfileSettings = createDefaultSettings();
  protected mode: VolumeProfileMode = 'idle';

  private startAnchor: Anchor | null = null;
  private endAnchor: Anchor | null = null;

  private profileRows: VolumeProfileDataRow[] = [];
  private profileMinPrice: number | null = null;
  private profileMaxPrice: number | null = null;

  private visibleRangeStartRatio = 0;

  private activeDragTarget: DragTarget = null;
  private dragPointerId: number | null = null;
  private dragStartPoint: Point | null = null;
  private dragGeometrySnapshot: VolumeProfileGeometry | null = null;

  private readonly paneView: VolumeProfilePaneView;
  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,
    profileKind = 'fixedRange',
    formatObservable,
    removeSelf,
    openSettings,
    initialEvent,
  }: VolumeProfileParams) {
    super({ chart, series, container, interaction });

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

    if (this.profileKind === 'visibleRange') {
      this.mode = 'ready';
    }

    this.paneView = new VolumeProfilePaneView(this);

    this.timeAxisPaneView = new CustomTimeAxisPaneView({
      getAxisSegments: () => this.getTimeAxisSegments(),
    });

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

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

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

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

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

    if (this.profileKind === 'visibleRange') {
      this.chart.timeScale().subscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);
      this.calculateProfile();
    }

    this.initializeDrawing(formatObservable, this.profileKind === 'fixedRange' ? initialEvent : undefined, (point) =>
      this.startDrawing(point),
    );
  }

  public destroy(): void {
    if (this.profileKind === 'visibleRange') {
      this.chart.timeScale().unsubscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);
    }

    super.destroy();
  }

  public isCreationPending(): boolean {
    if (this.profileKind === 'visibleRange') {
      return false;
    }

    return this.mode === 'idle' || this.mode === 'drawing';
  }

  public shouldShowInObjectTree(): boolean {
    if (this.profileKind === 'visibleRange') {
      return true;
    }

    return super.shouldShowInObjectTree();
  }

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

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

    const nextState = state as Partial<VolumeProfileState>;

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

    if (this.profileKind === 'fixedRange') {
      this.mode = nextState.mode ?? this.mode;
      this.startAnchor = nextState.startAnchor ?? this.startAnchor;
      this.endAnchor = nextState.endAnchor ?? this.endAnchor;
    }

    if (this.profileKind === 'visibleRange') {
      this.mode = 'ready';
      this.resolveReady?.();

      if (typeof nextState.visibleRangeStartRatio === 'number') {
        this.visibleRangeStartRatio = clamp(nextState.visibleRangeStartRatio, 0, MAX_VISIBLE_RANGE_START_RATIO);
      }
    }

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

    this.calculateProfile();
    this.render();
  }

  public getSettingsTabs(): SettingsTab[] {
    return getVolumeProfileSettingsTabs(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[] {
    if (this.profileKind === 'visibleRange') {
      return [];
    }

    return [this.timeAxisPaneView];
  }

  public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
    if (this.profileKind === 'visibleRange') {
      return [];
    }

    return [this.priceAxisPaneView];
  }

  public timeAxisViews() {
    if (this.profileKind === 'visibleRange') {
      return [];
    }

    return [this.startTimeAxisView, this.endTimeAxisView];
  }

  public priceAxisViews() {
    if (this.profileKind === 'visibleRange') {
      return [];
    }

    return [this.startPriceAxisView, this.endPriceAxisView];
  }

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    const { rows, pocY } = this.getProfileRenderRows(geometry);

    return {
      ...geometry,
      profileKind: this.profileKind,
      rows,
      pocY,
      ...this.settings,
    };
  }

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

    if (!geometry) {
      return [];
    }

    if (this.profileKind === 'visibleRange') {
      const pocY = this.getPocY(geometry);

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

      return [{ id: 'poc', x: geometry.left, y: pocY, shape: 'circle', size: 10, borderWidth: 2 }];
    }

    return [
      { id: 'end', ...geometry.endPoint, shape: 'circle', size: 10, borderWidth: 2 },
      { id: 'start', ...geometry.startPoint, shape: 'circle', size: 10, borderWidth: 2 },
    ];
  }

  protected getTimeAxisSegments(): AxisSegment[] {
    if (this.profileKind === 'visibleRange') {
      return [];
    }

    const geometry = this.getGeometry();

    return this.createAxisSegments(geometry?.left ?? null, geometry?.right ?? null);
  }

  protected getPriceAxisSegments(): AxisSegment[] {
    if (this.profileKind === 'visibleRange') {
      return [];
    }

    const geometry = this.getGeometry();

    return this.createAxisSegments(geometry?.top ?? null, geometry?.bottom ?? null);
  }

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

    const dragTarget = this.getDragTarget({ x, y });

    if (!dragTarget) {
      return null;
    }

    if (dragTarget === 'poc') {
      return {
        cursorStyle: 'ew-resize',
        externalId: 'volume-profile',
        zOrder: 'top',
      };
    }

    return {
      cursorStyle: this.isSelected() ? 'grab' : 'pointer',
      externalId: 'volume-profile',
      zOrder: 'top',
    };
  }

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

    const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;

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

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

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

    const price = kind === 'start' ? this.profileMinPrice : this.profileMaxPrice;

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

    return createAxisLabel(getYCoordinateFromPrice(this.series, price), formatPrice(price));
  }

  private handleVisibleLogicalRangeChange = (): void => {
    if (this.profileKind !== 'visibleRange') {
      return;
    }

    this.calculateProfile();
    this.render();
  };

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

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

    if (!this.getDragTarget(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.profileKind === 'visibleRange') {
      this.handleVisibleRangePointerDown(event, point);
      return;
    }

    this.handleFixedRangePointerDown(event, point);
  };

  protected handleVisibleRangePointerDown(event: PointerEvent, point: Point): void {
    let dragTarget = this.getVisibleRangeDragTarget(point);

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

      return;
    }

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

    if (!this.isSelected()) {
      this.select();
      dragTarget = this.getVisibleRangeDragTarget(point);
    }

    if (dragTarget !== 'poc') {
      return;
    }

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

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

  private handleFixedRangePointerDown(event: PointerEvent, point: Point): void {
    if (this.handleFixedRangeCreation(event, point)) {
      return;
    }

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

    const dragTarget = this.getFixedRangeDragTarget(point);

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

      event.preventDefault();
      event.stopPropagation();
      this.select();
      return;
    }

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

    event.preventDefault();
    event.stopPropagation();
    this.startDragging(point, event.pointerId, dragTarget);
  }

  private handleFixedRangeCreation(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;
  }

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

    if (this.profileKind === 'visibleRange') {
      this.handleVisibleRangePointerMove(event, point);
      return;
    }

    this.handleFixedRangePointerMove(event, point);
  };

  private handleVisibleRangePointerMove(event: PointerEvent, point: Point): void {
    if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId || this.activeDragTarget !== 'poc') {
      return;
    }

    event.preventDefault();

    this.moveVisibleRangeStart(point);
    this.calculateProfile();
    this.render();
  }

  private handleFixedRangePointerMove(event: PointerEvent, point: Point): void {
    if (this.mode === 'drawing') {
      this.updateDrawing(point);
      return;
    }

    if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
      return;
    }

    event.preventDefault();

    if (this.activeDragTarget === 'body') {
      this.moveBody(point);
      this.render();
      return;
    }

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

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

    this.finishDragging();
  };

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

    if (!anchor) {
      return;
    }

    this.startAnchor = anchor;
    this.endAnchor = anchor;
    this.mode = 'drawing';

    this.calculateProfile();
    this.render();
  }

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

    if (!anchor) {
      return;
    }

    this.endAnchor = anchor;

    this.calculateProfile();
    this.render();
  }

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

    if (
      this.profileKind === 'fixedRange' &&
      (!geometry || geometry.width < MIN_PROFILE_SIZE || geometry.height < MIN_PROFILE_SIZE)
    ) {
      if (this.removeSelf) {
        this.removeSelf();
        return;
      }

      this.resetToIdle();
      return;
    }

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

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

  private startDragging(point: Point, pointerId: number, dragTarget: Exclude<DragTarget, null>): void {
    this.mode = 'dragging';

    this.activeDragTarget = dragTarget;
    this.dragPointerId = pointerId;
    this.dragStartPoint = point;
    this.dragGeometrySnapshot = this.getGeometry();

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

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

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

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

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

    this.startAnchor = null;
    this.endAnchor = null;
    this.profileRows = [];
    this.profileMinPrice = null;
    this.profileMaxPrice = null;

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

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

  private moveVisibleRangeStart(point: Point): void {
    const { width } = getContainerSize(this.container);

    if (width <= 0) {
      return;
    }

    this.visibleRangeStartRatio = clamp(point.x / width, 0, MAX_VISIBLE_RANGE_START_RATIO);
  }

  private moveHandle(point: Point): void {
    if (!this.activeDragTarget || this.activeDragTarget === 'body' || this.activeDragTarget === 'poc') {
      return;
    }

    const anchor = this.createAnchor(clampPointToContainer(point, this.container));

    if (!anchor) {
      return;
    }

    if (this.activeDragTarget === 'start') {
      this.startAnchor = anchor;
    }

    if (this.activeDragTarget === 'end') {
      this.endAnchor = anchor;
    }

    this.calculateProfile();
  }

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

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

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

    const rawOffsetX = point.x - dragStartPoint.x;
    const rawOffsetY = point.y - dragStartPoint.y;

    const offsetX = clamp(rawOffsetX, -geometry.left, width - geometry.right);
    const offsetY = clamp(rawOffsetY, -geometry.top, height - geometry.bottom);

    this.setAnchorsFromPoints(
      {
        x: geometry.startPoint.x + offsetX,
        y: geometry.startPoint.y + offsetY,
      },
      {
        x: geometry.endPoint.x + offsetX,
        y: geometry.endPoint.y + offsetY,
      },
    );
  }

  private setAnchorsFromPoints(startPoint: Point, endPoint: Point): void {
    const startAnchor = this.createAnchor(clampPointToContainer(startPoint, this.container));
    const endAnchor = this.createAnchor(clampPointToContainer(endPoint, this.container));

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

    this.startAnchor = startAnchor;
    this.endAnchor = endAnchor;

    this.calculateProfile();
  }

  private getDragTarget(point: Point): Exclude<DragTarget, null> | null {
    if (this.profileKind === 'visibleRange') {
      return this.getVisibleRangeDragTarget(point);
    }

    return this.getFixedRangeDragTarget(point);
  }

  private getVisibleRangeDragTarget(point: Point): Exclude<DragTarget, null> | null {
    const handle = this.getDrawingHandleAtPoint(point);

    if (handle?.id === 'poc') {
      return 'poc';
    }

    if (this.containsPoint(point)) {
      return 'body';
    }

    return null;
  }

  private getFixedRangeDragTarget(point: Point): Exclude<DragTarget, null> | null {
    const handle = this.getDrawingHandleAtPoint(point);

    if (handle) {
      return handle.id;
    }

    if (this.containsPoint(point)) {
      return 'body';
    }

    return null;
  }

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

    if (!geometry) {
      return false;
    }

    return isPointInBounds(point, geometry, BODY_HIT_TOLERANCE);
  }

  private calculateProfile(): void {
    if (this.profileKind === 'visibleRange') {
      this.calculateAnchoredVolumeProfile();
      return;
    }

    this.calculateFixedRangeVolumeProfile();
  }

  private calculateAnchoredVolumeProfile(): void {
    const visibleRange = this.chart.timeScale().getVisibleLogicalRange();

    if (!visibleRange) {
      this.clearProfile();
      return;
    }

    const from = Number(visibleRange.from);
    const to = Number(visibleRange.to);
    const start = from + (to - from) * this.visibleRangeStartRatio;

    this.calculateProfileByLogicalRange(start, to);
  }

  private calculateFixedRangeVolumeProfile(): void {
    if (!this.startAnchor || !this.endAnchor) {
      this.clearProfile();
      return;
    }

    const leftFrameTime = Math.min(Number(this.startAnchor.time), Number(this.endAnchor.time));
    const rightFrameTime = Math.max(Number(this.startAnchor.time), Number(this.endAnchor.time));

    const candles = this.series.data() as SeriesCandleData[];
    const selectedCandles: SeriesCandleData[] = [];

    candles.forEach((candle) => {
      const candleTime = Number(candle.time);

      if (candleTime >= leftFrameTime && candleTime <= rightFrameTime) {
        selectedCandles.push(candle);
      }
    });

    this.calculateProfileByCandles(selectedCandles);
  }

  private calculateProfileByLogicalRange(fromLogical: number, toLogical: number): void {
    const candles = this.series.data() as SeriesCandleData[];

    if (!candles.length) {
      this.clearProfile();
      return;
    }

    const fromIndex = Math.max(0, Math.floor(Math.min(fromLogical, toLogical)));
    const toIndex = Math.min(candles.length - 1, Math.ceil(Math.max(fromLogical, toLogical)));

    if (fromIndex > toIndex) {
      this.clearProfile();
      return;
    }

    this.calculateProfileByCandles(candles.slice(fromIndex, toIndex + 1));
  }

  private calculateProfileByCandles(candles: SeriesCandleData[], fixedMinPrice?: number, fixedMaxPrice?: number): void {
    if (!candles.length && (fixedMinPrice === undefined || fixedMaxPrice === undefined)) {
      this.clearProfile();
      return;
    }

    let minPrice = fixedMinPrice ?? Infinity;
    let maxPrice = fixedMaxPrice ?? -Infinity;

    if (fixedMinPrice === undefined || fixedMaxPrice === undefined) {
      candles.forEach((candle) => {
        const price = this.getCandlePrice(candle);

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

        minPrice = Math.min(minPrice, this.getCandleLow(candle, price));
        maxPrice = Math.max(maxPrice, this.getCandleHigh(candle, price));
      });
    }

    if (!Number.isFinite(minPrice) || !Number.isFinite(maxPrice)) {
      this.clearProfile();
      return;
    }

    if (minPrice === maxPrice) {
      maxPrice = minPrice + Math.max(Math.abs(minPrice) * 0.001, 1);
    }

    this.profileMinPrice = minPrice;
    this.profileMaxPrice = maxPrice;

    const priceStep = (maxPrice - minPrice) / PROFILE_ROW_COUNT;
    const profileRows = this.createEmptyProfileRows(minPrice, priceStep);

    candles.forEach((candle) => {
      const volume = this.getCandleVolume(candle);

      if (volume <= 0) {
        return;
      }

      const price = this.getCandlePrice(candle);

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

      const candleHigh = this.getCandleHigh(candle, price);
      const candleLow = this.getCandleLow(candle, price);

      if (candleHigh < minPrice || candleLow > maxPrice) {
        return;
      }

      const isBuyVolume = this.isBuyVolume(candle);

      if (candleHigh === candleLow) {
        addPointVolume(profileRows, candleHigh, volume, isBuyVolume);
        return;
      }

      const highInRange = Math.min(maxPrice, candleHigh);
      const lowInRange = Math.max(minPrice, candleLow);
      const candleRange = candleHigh - candleLow;

      profileRows.forEach((row) => {
        const overlap = Math.max(0, Math.min(row.priceHigh, highInRange) - Math.max(row.priceLow, lowInRange));

        if (overlap <= 0) {
          return;
        }

        addVolumeToRow(row, volume * (overlap / candleRange), isBuyVolume);
      });
    });

    this.profileRows = profileRows;
  }

  private createEmptyProfileRows(minPrice: number, priceStep: number): VolumeProfileDataRow[] {
    const rows: VolumeProfileDataRow[] = [];

    for (let index = 0; index < PROFILE_ROW_COUNT; index += 1) {
      rows.push({
        priceLow: minPrice + priceStep * index,
        priceHigh: minPrice + priceStep * (index + 1),
        buyVolume: 0,
        sellVolume: 0,
        totalVolume: 0,
      });
    }

    return rows;
  }

  private clearProfile(): void {
    this.profileRows = [];
    this.profileMinPrice = null;
    this.profileMaxPrice = null;
  }

  private getCandleVolume(candle: SeriesCandleData): number {
    return this.getNumber(candle.customValues?.volume) ?? this.getNumber(candle.volume) ?? 0;
  }

  private getCandlePrice(candle: SeriesCandleData): number | null {
    return (
      this.getNumber(candle.close) ??
      this.getNumber(candle.customValues?.close) ??
      this.getNumber(candle.value) ??
      this.getNumber(candle.customValues?.value)
    );
  }

  private getCandleOpen(candle: SeriesCandleData): number | null {
    return this.getNumber(candle.open) ?? this.getNumber(candle.customValues?.open);
  }

  private getCandleClose(candle: SeriesCandleData): number | null {
    return (
      this.getNumber(candle.close) ??
      this.getNumber(candle.customValues?.close) ??
      this.getNumber(candle.value) ??
      this.getNumber(candle.customValues?.value)
    );
  }

  private getCandleHigh(candle: SeriesCandleData, fallbackPrice: number): number {
    return this.getNumber(candle.high) ?? this.getNumber(candle.customValues?.high) ?? fallbackPrice;
  }

  private getCandleLow(candle: SeriesCandleData, fallbackPrice: number): number {
    return this.getNumber(candle.low) ?? this.getNumber(candle.customValues?.low) ?? fallbackPrice;
  }

  private isBuyVolume(candle: SeriesCandleData): boolean {
    const open = this.getCandleOpen(candle);
    const close = this.getCandleClose(candle);

    if (open === null || close === null) {
      return true;
    }

    return close >= open;
  }

  private getNumber(value: unknown): number | null {
    return typeof value === 'number' && Number.isFinite(value) ? value : null;
  }

  protected getGeometry(): VolumeProfileGeometry | null {
    if (this.profileKind === 'visibleRange') {
      return this.getAnchoredVolumeProfileGeometry();
    }

    return this.getFixedRangeVolumeProfileGeometry();
  }

  private getAnchoredVolumeProfileGeometry(): VolumeProfileGeometry | null {
    if (this.profileMinPrice === null || this.profileMaxPrice === null) {
      return null;
    }

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

    const topCoordinate = getYCoordinateFromPrice(this.series, this.profileMaxPrice);
    const bottomCoordinate = getYCoordinateFromPrice(this.series, this.profileMinPrice);

    if (topCoordinate === null || bottomCoordinate === null) {
      return null;
    }

    const left = clamp(Math.round(width * this.visibleRangeStartRatio), 0, width);
    const right = width;
    const top = clamp(Math.round(Math.min(Number(topCoordinate), Number(bottomCoordinate))), 0, height);
    const bottom = clamp(Math.round(Math.max(Number(topCoordinate), Number(bottomCoordinate))), 0, height);

    return {
      startPoint: {
        x: left,
        y: top,
      },
      endPoint: {
        x: right,
        y: bottom,
      },
      left,
      right,
      top,
      bottom,
      width: right - left,
      height: bottom - top,
    };
  }

  private getFixedRangeVolumeProfileGeometry(): VolumeProfileGeometry | null {
    if (!this.startAnchor || !this.endAnchor) {
      return null;
    }

    if (this.profileMinPrice === null || this.profileMaxPrice === null) {
      return null;
    }

    const startX = getXCoordinateFromTime(this.chart, this.startAnchor.time, this.series);
    const endX = getXCoordinateFromTime(this.chart, this.endAnchor.time, this.series);
    const startY = getYCoordinateFromPrice(this.series, this.profileMinPrice);
    const endY = getYCoordinateFromPrice(this.series, this.profileMaxPrice);

    if (startX === null || endX === null || startY === null || endY === null) {
      return null;
    }

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

    const startPoint = {
      x: clamp(Math.round(Number(startX)), 0, width),
      y: clamp(Math.round(Number(startY)), 0, height),
    };

    const endPoint = {
      x: clamp(Math.round(Number(endX)), 0, width),
      y: clamp(Math.round(Number(endY)), 0, height),
    };

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

  private getPocY(geometry: VolumeProfileGeometry): number | null {
    return this.getProfileRenderRows(geometry).pocY;
  }

  private getProfileRenderRows(geometry: VolumeProfileGeometry): {
    rows: VolumeProfileRenderRow[];
    pocY: number | null;
  } {
    if (!this.profileRows.length) {
      return {
        rows: [],
        pocY: null,
      };
    }

    const maxVolume = this.profileRows.reduce((max, row) => Math.max(max, row.totalVolume), 0);

    if (maxVolume <= 0) {
      return {
        rows: [],
        pocY: null,
      };
    }

    let pocY: number | null = null;
    let pocVolume = 0;

    const rows = this.profileRows
      .map((row) => {
        const highY = getYCoordinateFromPrice(this.series, row.priceHigh);
        const lowY = getYCoordinateFromPrice(this.series, row.priceLow);

        if (highY === null || lowY === null) {
          return null;
        }

        const top = clamp(Math.min(Number(highY), Number(lowY)), geometry.top, geometry.bottom);
        const bottom = clamp(Math.max(Number(highY), Number(lowY)), geometry.top, geometry.bottom);

        if (row.totalVolume > pocVolume) {
          pocVolume = row.totalVolume;
          pocY = (top + bottom) / 2;
        }

        return {
          top,
          height: Math.max(1, bottom - top),
          buyWidth: (geometry.width * row.buyVolume) / maxVolume,
          sellWidth: (geometry.width * row.sellVolume) / maxVolume,
        };
      })
      .filter((row): row is VolumeProfileRenderRow => row !== null);

    return {
      rows,
      pocY,
    };
  }

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

function addPointVolume(
  profileRows: VolumeProfileDataRow[],
  price: number,
  volume: number,
  isBuyVolume: boolean,
): void {
  const row = profileRows.find((item, index) => {
    const isLastRow = index === profileRows.length - 1;

    return price >= item.priceLow && (price < item.priceHigh || isLastRow);
  });

  if (!row) {
    return;
  }

  addVolumeToRow(row, volume, isBuyVolume);
}

function addVolumeToRow(row: VolumeProfileDataRow, volume: number, isBuyVolume: boolean): void {
  if (isBuyVolume) {
    row.buyVolume += volume;
  } else {
    row.sellVolume += volume;
  }

  row.totalVolume += volume;
}










import { clamp } from 'lodash-es';

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

interface SeriesTimeItem {
  time: Time;
}

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

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

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

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

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

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

  if (!series) {
    return null;
  }

  const logical = getNearestLogicalFromTime(series, time);

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

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

  return isValidCoordinate(projectedCoordinate) ? projectedCoordinate : null;
}

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

export function findNearestTimeIndex(data: readonly { time: Time }[], time: Time): number {
  const targetTime = getNumericTime(time);

  if (targetTime === null || !data.length) {
    return -1;
  }

  return findNearestIndex(data, targetTime);
}

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

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

  const points = getSeriesTimePoints(series);

  if (!points.length || targetTime < points[0].time || targetTime > points[points.length - 1].time) {
    return null;
  }

  const index = findNearestIndex(points, targetTime);

  return index < 0 ? null : points[index].logical;
}

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

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

    if (time !== null) {
      points.push({ time, logical });
    }

    return points;
  }, []);
}

function findNearestIndex(data: readonly { time: Time }[], targetTime: number): number {
  let left = 0;
  let right = data.length - 1;

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

    if (middleTime === null) {
      return findNearestIndexLinear(data, targetTime);
    }

    if (middleTime === targetTime) {
      return middle;
    }

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

  return getNearestIndexFromBounds(data, targetTime, right, left);
}

function getNearestIndexFromBounds(
  data: readonly { time: Time }[],
  targetTime: number,
  previousIndex: number,
  nextIndex: number,
): number {
  if (previousIndex < 0) {
    return nextIndex < data.length ? nextIndex : -1;
  }

  if (nextIndex >= data.length) {
    return previousIndex;
  }

  const previousTime = getNumericTime(data[previousIndex].time);
  const nextTime = getNumericTime(data[nextIndex].time);

  if (previousTime === null || nextTime === null) {
    return findNearestIndexLinear(data, targetTime);
  }

  return Math.abs(targetTime - previousTime) <= Math.abs(nextTime - targetTime) ? previousIndex : nextIndex;
}

function findNearestIndexLinear(data: readonly { time: Time }[], targetTime: number): number {
  let nearestIndex = -1;
  let nearestDistance = Number.POSITIVE_INFINITY;

  data.forEach((item, index) => {
    const itemTime = getNumericTime(item.time);

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

    const distance = Math.abs(itemTime - targetTime);

    if (distance < nearestDistance) {
      nearestIndex = index;
      nearestDistance = distance;
    }
  });

  return nearestIndex;
}

function getNumericTime(time: Time): number | null {
  return typeof time === 'number' && Number.isFinite(time) ? time : null;
}

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