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


import {
  AutoscaleInfo,
  CrosshairMode,
  IChartApi,
  IPrimitivePaneView,
  ISeriesApi,
  ISeriesPrimitive,
  ISeriesPrimitiveAxisView,
  Logical,
  PrimitiveHoveredItem,
  SeriesAttachedParameter,
  SeriesOptionsMap,
  SeriesType,
  Time,
} from 'lightweight-charts';

import { BehaviorSubject, distinctUntilChanged, Subscription } from 'rxjs';

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

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

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

  shouldShowInObjectTree(): boolean;

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

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

  subscribeIsSelected(callback: (isSelected: boolean) => void): Subscription;
  subscribeIsLocked(callback: (isLocked: boolean) => void): Subscription;

  isSelected(): boolean;
  isLocked(): boolean;
  setLocked(isLocked: boolean): void;

  getRenderData(): unknown;
}

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

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

  private readonly isLockedSubject = new BehaviorSubject(false);

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

  protected requestUpdate: (() => void) | null = null;

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

  public subscribeIsSelected(callback: (isSelected: boolean) => void): Subscription {
    return this.isActive.pipe(distinctUntilChanged()).subscribe(callback);
  }

  public subscribeIsLocked(callback: (isLocked: boolean) => void): Subscription {
    return this.isLockedSubject.pipe(distinctUntilChanged()).subscribe(callback);
  }

  public isSelected(): boolean {
    return this.isActive.value;
  }

  public isLocked(): boolean {
    return this.isLockedSubject.value;
  }

  public setLocked(isLocked: boolean): void {
    if (this.isLockedSubject.value === isLocked) {
      return;
    }

    this.isLockedSubject.next(isLocked);

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

    this.render();
  }

  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.isLockedSubject.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.render();
  }

  public attached(param: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
    this.requestUpdate = param.requestUpdate;
    this.bindEvents();
  }

  public detached(): void {
    this.showCrosshair();
    this.unbindEvents();
    this.requestUpdate = null;
  }

  public autoscaleInfo(_start: Logical, _end: Logical): AutoscaleInfo | null {
    return null;
  }

  public abstract getRenderData(): unknown; // todo: make proper type
  public abstract getState(): unknown;
  public abstract getSettingsTabs(): SettingsTab[];
  public abstract isCreationPending(): boolean;
  public abstract setState(state: unknown): void;

  public abstract hitTest(x: number, y: number): PrimitiveHoveredItem | null;
  public abstract updateAllViews(): void;
  public abstract paneViews(): readonly IPrimitivePaneView[];
  public abstract priceAxisPaneViews(): readonly IPrimitivePaneView[];
  public abstract priceAxisViews(): readonly ISeriesPrimitiveAxisView[];
  public abstract timeAxisPaneViews(): readonly IPrimitivePaneView[];
  public abstract timeAxisViews(): readonly ISeriesPrimitiveAxisView[];

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

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

    this.isBound = true;

    this.container.addEventListener('dblclick', this.handleDoubleClick);
    this.container.addEventListener('pointerdown', this.handlePointerDown);
    this.container.addEventListener('contextmenu', this.handleContextMenu);

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

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

    this.isBound = false;

    this.container.removeEventListener('dblclick', this.handleDoubleClick);
    this.container.removeEventListener('pointerdown', this.handlePointerDown);
    this.container.removeEventListener('contextmenu', this.handleContextMenu);

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

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

  protected abstract getGeometry(): unknown; // todo: make proper type
  protected abstract getTimeAxisSegments(): AxisSegment[];
  protected abstract getPriceAxisSegments(): AxisSegment[];
  protected abstract getTimeAxisLabel(kind: string): AxisLabel | null;
  protected abstract getPriceAxisLabel(kind: string): AxisLabel | null;
}



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

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

interface SeriesTimeItem {
  time: Time;
}

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

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

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

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

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

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

  if (!series) {
    return null;
  }

  const logical = getNearestLogicalFromTime(series, time);

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

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

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

  return projectedCoordinate;
}

export function clamp(value: number, min: number, max: number): number {
  return Math.max(min, Math.min(value, max));
}

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 getPointerPoint(container: HTMLElement, event: PointerEvent): Point {
  const rect = container.getBoundingClientRect();

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

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

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

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

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

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

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

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

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

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

  return toPrice - fromPrice;
}

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

  if (!height) {
    return null;
  }

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

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

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

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

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

  return {
    time,
    price,
  };
}

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

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

  const points = getSeriesTimePoints(series);

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

  const lastIndex = points.length - 1;

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

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

  let left = 0;
  let right = lastIndex;

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

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

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

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

  return getNearestLogicalByTime(targetTime, previousPoint, nextPoint);
}

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

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

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

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

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

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

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

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

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

    return points;
  }, []);
}

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

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

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



import type { ISeriesApi, SeriesOptionsMap, Time } from 'lightweight-charts';

export type SeriesApi = ISeriesApi<keyof SeriesOptionsMap, Time>;

export interface Point {
  x: number;
  y: number;
}

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

export interface ContainerSize {
  width: number;
  height: number;
}

export interface Anchor {
  time: Time;
  price: number;
}

export interface AxisSegment {
  from: number;
  to: number;
  color: string;
}

export interface AxisLabel {
  coordinate: number;
  text: string;
  textColor: string;
  backgroundColor: string;
}

export interface UpdatableView {
  update(): void;
}


import type { UpdatableView } from './types';

export function updateViews(views: readonly UpdatableView[]): void {
  for (const view of views) {
    view.update();
  }
}

export function getOrderedSideValue<T>(
  startValue: T | null,
  endValue: T | null,
  startCoordinate: number | null,
  endCoordinate: number | null,
  side: 'start' | 'end',
): T | null {
  if (startValue === null || endValue === null) {
    return null;
  }

  if (startCoordinate === null || endCoordinate === null) {
    return side === 'start' ? startValue : endValue;
  }

  const startFirst = startCoordinate <= endCoordinate;

  if (side === 'start') {
    return startFirst ? startValue : endValue;
  }

  return startFirst ? endValue : startValue;
}



import { AxisLine } from '@src/core/Drawings/axisLine';
import { Diapson } from '@src/core/Drawings/diapson';
import { FibonacciRetracement } from '@src/core/Drawings/fibonacciRetracement';
import { Ray } from '@src/core/Drawings/ray';
import { Rectangle } from '@src/core/Drawings/rectangle';
import { Ruler } from '@src/core/Drawings/ruler';
import { SliderPosition } from '@src/core/Drawings/sliderPosition';
import { Text } from '@src/core/Drawings/text';
import { Traectory } from '@src/core/Drawings/traectory';
import { TrendLine } from '@src/core/Drawings/trendLine';
import { VolumeProfile } from '@src/core/Drawings/volumeProfile';
import { t } from '@src/translations';
import { DrawingConfig } from '@src/types';

export enum DrawingsNames {
  'trendLine' = 'trendLine',
  'ray' = 'ray',
  'horizontalLine' = 'horizontalLine',
  'horizontalRay' = 'horizontalRay',
  'verticalLine' = 'verticalLine',
  'ruler' = 'ruler',

  'fibonacciRetracement' = 'fibonacciRetracement',

  'sliderLong' = 'sliderLong',
  'sliderShort' = 'sliderShort',
  'diapsonDates' = 'diapsonDates',
  'diapsonPrices' = 'diapsonPrices',
  'fixedRangeProfile' = 'fixedRangeProfile',
  'visibleRangeProfile' = 'visibleRangeProfile',

  'rectangle' = 'rectangle',
  'traectory' = 'traectory',

  'text' = 'text',
}

export const drawingLabelById = (): Record<DrawingsNames, string> => ({
  [DrawingsNames.trendLine]: t('Trend line'),
  [DrawingsNames.ray]: t('Ray'),
  [DrawingsNames.horizontalLine]: t('Horizontal line'),
  [DrawingsNames.horizontalRay]: t('Horizontal ray'),
  [DrawingsNames.verticalLine]: t('Vertical line'),
  [DrawingsNames.fibonacciRetracement]: t('Fibonacci retracement'),
  [DrawingsNames.ruler]: t('Ruler'),
  [DrawingsNames.sliderLong]: t('Long position'),
  [DrawingsNames.sliderShort]: t('Short position'),
  [DrawingsNames.diapsonDates]: t('Dates range'),
  [DrawingsNames.diapsonPrices]: t('Prices range'),
  [DrawingsNames.fixedRangeProfile]: t('Fixed range profile volume'),
  [DrawingsNames.visibleRangeProfile]: t('Anchored volume profile'),
  [DrawingsNames.rectangle]: t('Rectangle'),
  [DrawingsNames.traectory]: t('Traectory'),
  [DrawingsNames.text]: t('Text'),
});

export const drawingsMap: Record<DrawingsNames, DrawingConfig> = {
  [DrawingsNames.trendLine]: {
    construct: ({ chart, series, container, eventManager, removeSelf, openSettings }) => {
      return new TrendLine(chart, series, {
        container,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.ray]: {
    construct: ({ chart, series, container, eventManager, removeSelf, openSettings }) => {
      return new Ray(chart, series, {
        container,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.horizontalLine]: {
    construct: ({ chart, series, eventManager, container, removeSelf, openSettings }) => {
      return new AxisLine(chart, series, {
        direction: 'horizontal',
        container,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.horizontalRay]: {
    construct: ({ chart, series, container, eventManager, removeSelf, openSettings }) => {
      return new TrendLine(chart, series, {
        container,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.verticalLine]: {
    construct: ({ chart, series, eventManager, container, removeSelf, openSettings }) => {
      return new AxisLine(chart, series, {
        direction: 'vertical',
        container,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.sliderLong]: {
    construct: ({ chart, series, eventManager, container, removeSelf, openSettings }) => {
      return new SliderPosition(chart, series, {
        side: 'long',
        container,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.fibonacciRetracement]: {
    construct: ({ chart, series, eventManager, container, removeSelf, openSettings }) => {
      return new FibonacciRetracement(chart, series, {
        container,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.sliderShort]: {
    construct: ({ chart, series, eventManager, container, removeSelf, openSettings }) => {
      return new SliderPosition(chart, series, {
        side: 'short',
        container,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.diapsonDates]: {
    construct: ({ chart, series, container, eventManager, removeSelf, openSettings }) => {
      return new Diapson(chart, series, {
        rangeMode: 'date',
        container,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.diapsonPrices]: {
    construct: ({ chart, series, container, eventManager, removeSelf, openSettings }) => {
      return new Diapson(chart, series, {
        rangeMode: 'price',
        container,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.fixedRangeProfile]: {
    construct: ({ chart, series, container, eventManager, removeSelf, openSettings }) => {
      return new VolumeProfile(chart, series, {
        profileKind: 'fixedRange',
        container,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.visibleRangeProfile]: {
    singleInstance: true,
    construct: ({ chart, series, container, eventManager, removeSelf, openSettings }) => {
      return new VolumeProfile(chart, series, {
        profileKind: 'visibleRange',
        container,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.rectangle]: {
    construct: ({ chart, series, eventManager, container, removeSelf, openSettings }) => {
      return new Rectangle(chart, series, {
        container,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.ruler]: {
    singleInstance: true,
    construct: ({ chart, series, eventManager, container, removeSelf }) => {
      return new Ruler(chart, series, {
        formatObservable: eventManager.getChartOptionsModel(),
        container,
        resetTriggers: [eventManager.getTimeframeObs(), eventManager.getInterval()],
        removeSelf,
      });
    },
  },
  [DrawingsNames.traectory]: {
    construct: ({ chart, series, eventManager, container, removeSelf, openSettings }) => {
      return new Traectory(chart, series, {
        formatObservable: eventManager.getChartOptionsModel(),
        container,
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.text]: {
    construct: ({ chart, series, eventManager, container, removeSelf, openSettings }) => {
      return new Text(chart, series, {
        formatObservable: eventManager.getChartOptionsModel(),
        container,
        removeSelf,
        openSettings,
      });
    },
  },
};



import { IChartApi, IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
import { Observable } from 'rxjs';

import { CustomPriceAxisView, CustomTimeAxisView } from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
  getPriceFromYCoordinate,
  getTimeFromXCoordinate,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  isNearPoint,
} from '@core/Drawings/helpers';
import { AxisSegment } from '@core/Drawings/types';
import { updateViews } from '@core/Drawings/utils';

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

import { AxisLinePaneView } from './paneView';

import {
  AxisLineSettings,
  AxisLineStyle,
  AxisLineTextStyle,
  createDefaultSettings,
  getAxisLineSettingsTabs,
} from './settings';

import type { ISeriesDrawing } from '@core/Drawings/common';
import type { AxisLabel, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab } from '@src/types';

export type AxisLineDirection = 'vertical' | 'horizontal';

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

interface AxisLineParams {
  direction: AxisLineDirection;
  container: HTMLElement;
  formatObservable?: Observable<ChartOptionsModel>;
  removeSelf?: () => void;
  openSettings?: () => void;
}

interface AxisLineState {
  hidden: boolean;
  isActive: boolean;
  mode: AxisLineMode;
  time: Time | null;
  price: number | null;
  settings: AxisLineSettings;
}

export interface AxisLineRenderData extends AxisLineStyle, AxisLineTextStyle {
  direction: AxisLineDirection;
  coordinate: number;
  handle: Point;
  showHandle: boolean;
}

const HANDLE_HIT_TOLERANCE = 8;
const LINE_HIT_TOLERANCE = 6;

export class AxisLine extends SeriesDrawingBase<AxisLineSettings> implements ISeriesDrawing {
  private removeSelf?: () => void;
  private openSettings?: () => void;

  protected settings: AxisLineSettings = createDefaultSettings();

  protected mode: AxisLineMode = 'idle';

  private direction: AxisLineDirection;
  private time: Time | null = null;
  private price: number | null = null;
  private dragPointerId: number | null = null;

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

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

  constructor(
    chart: IChartApi,
    series: SeriesApi,
    { direction, container, formatObservable, removeSelf, openSettings }: AxisLineParams,
  ) {
    super({ chart, series, container });
    this.direction = direction;
    this.removeSelf = removeSelf;
    this.openSettings = openSettings;

    this.paneView = new AxisLinePaneView(this);

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

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

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

    this.series.attachPrimitive(this);
  }

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

  public getState(): AxisLineState {
    return {
      hidden: this.hidden,
      isActive: this.isActive.value,
      mode: this.mode,
      time: this.time,
      price: this.price,
      settings: { ...this.settings },
    };
  }

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

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

  public setState(state: unknown): void {
    const nextState = state as Partial<AxisLineState>;

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

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

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

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

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

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

    this.render();
  }

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

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

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

  public timeAxisViews() {
    return this.direction === 'vertical' ? [this.timeAxisView] : [];
  }

  public priceAxisViews() {
    return this.direction === 'horizontal' ? [this.priceAxisView] : [];
  }

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

    const coordinate = this.getCoordinate();

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

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

    return {
      direction: this.direction,
      coordinate,
      handle: this.direction === 'vertical' ? { x: coordinate, y: height / 2 } : { x: width / 2, y: coordinate },
      showHandle: this.isActive.value,
      ...this.settings,
    };
  }

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

    const point = { x, y };
    const data = this.getRenderData();

    if (!data) {
      return null;
    }

    if (this.isActive.value && isNearPoint(point, data.handle.x, data.handle.y, HANDLE_HIT_TOLERANCE)) {
      return {
        cursorStyle: this.getCursorStyle(),
        externalId: 'axis-line',
        zOrder: 'top',
      };
    }

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

    return {
      cursorStyle: this.getCursorStyle(),
      externalId: 'axis-line',
      zOrder: 'top',
    };
  }

  protected getTimeAxisLabel(kind: string): AxisLabel | null {
    if (kind !== 'main' || this.direction !== 'vertical' || !this.isActive.value || this.time === null) {
      return null;
    }

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

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

    const { colors } = getThemeStore();

    return {
      coordinate,
      text: formatDate(
        this.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 (kind !== 'main' || this.direction !== 'horizontal' || !this.isActive.value || this.price === null) {
      return null;
    }

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

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

    const { colors } = getThemeStore();

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

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

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

  protected getGeometry(): void {
    console.log('stub');
  }

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

    const data = this.getRenderData();

    if (!data) {
      return;
    }

    const rect = this.container.getBoundingClientRect();
    const point = {
      x: event.clientX - rect.left,
      y: event.clientY - rect.top,
    };

    const isNearHandle = this.isActive.value && isNearPoint(point, data.handle.x, data.handle.y, HANDLE_HIT_TOLERANCE);
    const isNearLine = this.isPointNearLine(point, data.coordinate);

    if (!isNearHandle && !isNearLine) {
      return;
    }

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

    this.openSettings?.();
  };

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

    const point = this.getEventPoint(event);

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

      this.updateLine(point);
      this.isActive.next(true);
      this.mode = 'ready';
      this.resolveReady?.();

      this.render();
      return;
    }

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

    const data = this.getRenderData();

    if (!data) {
      return;
    }

    const isNearHandle = this.isActive.value && isNearPoint(point, data.handle.x, data.handle.y, HANDLE_HIT_TOLERANCE);
    const isNearLine = this.isPointNearLine(point, data.coordinate);

    if (!this.isActive.value) {
      if (!isNearLine) {
        return;
      }

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

      this.isActive.next(true);
      this.render();
      return;
    }

    if (!isNearHandle && !isNearLine) {
      this.isActive.next(false);
      this.render();
      return;
    }

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

    this.mode = 'dragging';
    this.dragPointerId = event.pointerId;
    this.hideCrosshair();
    this.render();
  };

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

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

    this.updateLine(this.getEventPoint(event));
    this.render();
  };

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

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

    this.dragPointerId = null;
    this.showCrosshair();
    this.render();
  };

  private updateLine(point: Point): void {
    if (this.direction === 'vertical') {
      this.time = getTimeFromXCoordinate(this.chart, point.x);
      return;
    }

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

  private getCoordinate(): number | null {
    if (this.direction === 'vertical') {
      if (this.time === null) {
        return null;
      }

      const coordinate = getXCoordinateFromTime(this.chart, this.time, this.series);
      return coordinate === null ? null : Number(coordinate);
    }

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

    const coordinate = getYCoordinateFromPrice(this.series, this.price);
    return coordinate === null ? null : Number(coordinate);
  }

  private isPointNearLine(point: Point, coordinate: number): boolean {
    return this.direction === 'vertical'
      ? Math.abs(point.x - coordinate) <= LINE_HIT_TOLERANCE
      : Math.abs(point.y - coordinate) <= LINE_HIT_TOLERANCE;
  }

  private getCursorStyle(): PrimitiveHoveredItem['cursorStyle'] {
    return this.direction === 'vertical' ? 'ew-resize' : 'ns-resize';
  }
}



import { Observable } from 'rxjs';

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
  clamp,
  clampPointToContainer as clampPointToContainerInElement,
  getAnchorFromPoint,
  getContainerSize as getElementContainerSize,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  isNearPoint,
  isPointInBounds,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';

import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { Defaults } from '@src/types/defaults';
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 { ISeriesDrawing } from '@core/Drawings/common';
import type { Anchor, AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
import type { IChartApi, IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';

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

type DiapsonMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type DiapsonHandle = 'body' | 'start' | 'end' | null;
type TimeLabelKind = 'left' | 'right';
type PriceLabelKind = 'top' | 'bottom';

interface DiapsonParams {
  container: HTMLElement;
  rangeMode: DiapsonRangeMode;
  formatObservable?: Observable<ChartOptionsModel>;
  removeSelf?: () => void;
  openSettings?: () => void;
  stepSize?: number;
  stepLabel?: string;
}

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

interface DiapsonGeometry {
  left: number;
  right: number;
  top: number;
  bottom: number;
  width: number;
  height: number;
  startPoint: Point;
  endPoint: Point;
}

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

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

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

const HANDLE_HIT_TOLERANCE = 8;
const BODY_HIT_TOLERANCE = 6;
const MIN_RECTANGLE_WIDTH = 6;
const MIN_RECTANGLE_HEIGHT = 6;

export class Diapson extends SeriesDrawingBase<DiapsonSettings> implements ISeriesDrawing {
  private removeSelf?: () => void;
  private openSettings?: () => void;

  protected settings: DiapsonSettings = createDefaultSettings();

  protected mode: DiapsonMode = 'idle';
  private rangeMode: DiapsonRangeMode;

  private startTime: Time | null = null;
  private endTime: Time | null = null;
  private startPrice: number | null = null;
  private endPrice: number | null = null;

  private activeDragTarget: DiapsonHandle = null;
  private dragPointerId: number | null = null;
  private dragStartPoint: Point | null = null;
  private dragGeometrySnapshot: DiapsonGeometry | null = null;

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

  private readonly stepSize: number;
  private readonly stepLabel: string;

  private readonly paneView: DiapsonPaneView;
  private readonly timeAxisPaneView: CustomTimeAxisPaneView;
  private readonly priceAxisPaneView: CustomPriceAxisPaneView;
  private readonly leftTimeAxisView: CustomTimeAxisView;
  private readonly rightTimeAxisView: CustomTimeAxisView;
  private readonly topPriceAxisView: CustomPriceAxisView;
  private readonly bottomPriceAxisView: CustomPriceAxisView;

  constructor(chart: IChartApi, series: SeriesApi, params: DiapsonParams) {
    super({ chart, series, container: params.container });

    const { rangeMode, formatObservable, removeSelf, openSettings, stepSize = 1, stepLabel = '' } = params;

    this.rangeMode = rangeMode;
    this.removeSelf = removeSelf;
    this.openSettings = openSettings;
    this.stepSize = stepSize > 0 ? stepSize : 1;
    this.stepLabel = stepLabel;

    this.paneView = new DiapsonPaneView(this);

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

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

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

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

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

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

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

    this.series.attachPrimitive(this);
  }

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

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

    this.rangeMode = nextMode;
    this.resetToIdle();
  }

  public getState(): DiapsonState {
    return {
      hidden: this.hidden,
      isActive: this.isActive.value,
      mode: this.mode,
      rangeMode: this.rangeMode,
      startTime: this.startTime,
      endTime: this.endTime,
      startPrice: this.startPrice,
      endPrice: this.endPrice,
      settings: { ...this.settings },
    };
  }

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

    const nextState = state as Partial<DiapsonState>;

    this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
    if (typeof nextState.isActive === 'boolean') {
      this.isActive.next(nextState.isActive);
    }
    this.mode = nextState.mode ?? this.mode;
    this.rangeMode = nextState.rangeMode ?? this.rangeMode;
    this.startTime = 'startTime' in nextState ? (nextState.startTime ?? null) : this.startTime;
    this.endTime = 'endTime' in nextState ? (nextState.endTime ?? null) : this.endTime;
    this.startPrice = 'startPrice' in nextState ? (nextState.startPrice ?? null) : this.startPrice;
    this.endPrice = 'endPrice' in nextState ? (nextState.endPrice ?? null) : this.endPrice;

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

    this.render();
  }

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

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

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

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

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

  public timeAxisViews() {
    return [this.leftTimeAxisView, this.rightTimeAxisView];
  }

  public priceAxisViews() {
    return [this.topPriceAxisView, this.bottomPriceAxisView];
  }

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      ...geometry,
      rangeMode: this.rangeMode,
      showFill: true,
      showHandles: this.isActive.value,
      labelLines: this.getLabelLines(),
      ...this.settings,
    };
  }

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

    const bounds = this.getTimeBounds();

    if (!bounds) {
      return [];
    }

    const { colors } = getThemeStore();

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

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

    const bounds = this.getPriceBounds();

    if (!bounds) {
      return [];
    }

    const { colors } = getThemeStore();

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

  protected getTimeAxisLabel(kind: string): AxisLabel | null {
    if (!this.isActive.value || (kind !== 'left' && kind !== 'right')) {
      return null;
    }

    const labelKind = kind as TimeLabelKind;
    const coordinate = this.getTimeCoordinate(labelKind);
    const text = this.getTimeText(labelKind);

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

    const { colors } = getThemeStore();

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

  protected getPriceAxisLabel(kind: string): AxisLabel | null {
    if (!this.isActive.value || (kind !== 'top' && kind !== 'bottom')) {
      return null;
    }

    const labelKind = kind as PriceLabelKind;
    const coordinate = this.getPriceCoordinate(labelKind);
    const text = this.getPriceText(labelKind);

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

    const { colors } = getThemeStore();

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

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

    const point = { x, y };

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

      return {
        cursorStyle: 'move',
        externalId: `diapson-${this.rangeMode}`,
        zOrder: 'top',
      };
    }

    const handleTarget = this.getHandleTarget(point);

    if (handleTarget) {
      return {
        cursorStyle: this.getCursorStyle(handleTarget),
        externalId: `diapson-${this.rangeMode}`,
        zOrder: 'top',
      };
    }

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

    return {
      cursorStyle: 'move',
      externalId: `diapson-${this.rangeMode}`,
      zOrder: 'top',
    };
  }

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

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

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

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

    this.openSettings?.();
  };

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

    const point = this.getEventPoint(event);

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

      this.startDrawing(point);
      return;
    }

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

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

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

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

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

      this.isActive.next(true);
      this.render();
      return;
    }

    const dragTarget = this.getDragTarget(point);

    if (!dragTarget) {
      this.isActive.next(false);
      this.render();
      return;
    }

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

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

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

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

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

    event.preventDefault();

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

    this.resizeRectangle(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(this.clampPointToContainer(point));

    if (!anchor) {
      return;
    }

    this.startTime = anchor.time;
    this.endTime = anchor.time;
    this.startPrice = anchor.price;
    this.endPrice = anchor.price;

    this.isActive.next(true);
    this.mode = 'drawing';
    this.render();
  }

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

    if (!anchor) {
      return;
    }

    this.endTime = anchor.time;
    this.endPrice = anchor.price;

    this.render();
  }

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

    if (!geometry) {
      this.resetToIdle();
      return;
    }

    if (geometry.width < MIN_RECTANGLE_WIDTH || geometry.height < MIN_RECTANGLE_HEIGHT) {
      if (this.removeSelf) {
        this.removeSelf();
        return;
      }

      this.resetToIdle();
      return;
    }

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

    this.render();
  }

  private startDragging(point: Point, pointerId: number, dragTarget: Exclude<DiapsonHandle, null>): void {
    this.mode = 'dragging';
    this.activeDragTarget = dragTarget;
    this.dragPointerId = pointerId;
    this.dragStartPoint = point;
    this.dragGeometrySnapshot = this.getGeometry();

    this.render();
  }

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

    this.clearInteractionState();
    this.render();
  }

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

  private resetToIdle(): void {
    this.hidden = false;
    this.isActive.next(false);
    this.mode = 'idle';
    this.startTime = null;
    this.endTime = null;
    this.startPrice = null;
    this.endPrice = null;
    this.clearInteractionState();
    this.render();
  }

  private getDragTarget(point: Point): Exclude<DiapsonHandle, null> | null {
    const handleTarget = this.getHandleTarget(point);

    if (handleTarget) {
      return handleTarget;
    }

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

    return null;
  }

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

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

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

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

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

    const nextStartPoint = this.clampPointToContainer({
      x: geometry.startPoint.x + offsetX,
      y: geometry.startPoint.y + offsetY,
    });

    const nextEndPoint = this.clampPointToContainer({
      x: geometry.endPoint.x + offsetX,
      y: geometry.endPoint.y + offsetY,
    });

    this.setAnchorsFromPoints(nextStartPoint, nextEndPoint);
  }

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

    if (!geometry || !this.activeDragTarget || this.activeDragTarget === 'body') {
      return;
    }

    const nextPoint = this.clampPointToContainer(point);

    if (this.activeDragTarget === 'start') {
      this.setAnchorsFromPoints(nextPoint, geometry.endPoint);
      return;
    }

    this.setAnchorsFromPoints(geometry.startPoint, nextPoint);
  }

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

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

    this.startTime = startAnchor.time;
    this.startPrice = startAnchor.price;
    this.endTime = endAnchor.time;
    this.endPrice = endAnchor.price;
  }

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

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

    const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
    const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
    const startY = getYCoordinateFromPrice(this.series, this.startPrice);
    const endY = getYCoordinateFromPrice(this.series, this.endPrice);

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

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

    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.round(Math.min(startPoint.x, endPoint.x));
    const right = Math.round(Math.max(startPoint.x, endPoint.x));
    const top = Math.round(Math.min(startPoint.y, endPoint.y));
    const bottom = Math.round(Math.max(startPoint.y, endPoint.y));

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

  private getLabelLines(): string[] {
    if (this.rangeMode === 'date') {
      const metrics = this.getDateMetrics();

      if (!metrics) {
        return [];
      }

      const firstLine = metrics.elapsedText
        ? `${metrics.barsCount} ${t('bars')}, ${metrics.elapsedText}`
        : `${metrics.barsCount} ${t('bars')}`;

      if (!metrics.volumeText) {
        return [firstLine];
      }

      return [firstLine, `${t('Vol')} ${metrics.volumeText}`];
    }

    const metrics = this.getPriceMetrics();

    if (!metrics) {
      return [];
    }

    const percentText =
      metrics.percent < 0 ? `-${formatPercent(Math.abs(metrics.percent))}` : formatPercent(Math.abs(metrics.percent));

    const absSteps = Math.abs(metrics.steps);
    let stepsText = '';

    if (Number.isInteger(absSteps)) {
      stepsText = absSteps.toString();
    } else if (absSteps >= 1000) {
      stepsText = absSteps.toFixed(0);
    } else if (absSteps >= 100) {
      stepsText = absSteps.toFixed(1);
    } else {
      stepsText = absSteps.toFixed(2);
    }

    if (metrics.steps < 0) {
      stepsText = `-${stepsText}`;
    }

    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 || !rightTime) {
      return null;
    }

    const barsCount = this.getBarsCount();
    const durationSeconds = Math.max(0, Math.floor(Math.abs(Number(rightTime) - Number(leftTime))));
    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 elapsedParts: string[] = [];

    if (days > 0) {
      elapsedParts.push(`${days}d`);
    }

    if (hours > 0) {
      elapsedParts.push(`${hours}h`);
    }

    if (minutes > 0) {
      elapsedParts.push(`${minutes}m`);
    }

    if (elapsedParts.length === 0) {
      elapsedParts.push(`${seconds}s`);
    }

    const volume = this.getVolumeInRange();

    return {
      barsCount,
      elapsedText: elapsedParts.slice(0, 2).join(' '),
      volumeText: volume > 0 ? formatVolume(volume) : '',
    };
  }

  private getPriceMetrics(): PriceMetrics | null {
    if (this.startPrice === null || this.endPrice === null) {
      return null;
    }

    const delta = this.endPrice - this.startPrice;

    return {
      delta,
      percent: this.startPrice !== 0 ? (delta / Math.abs(this.startPrice)) * 100 : 0,
      steps: delta / this.stepSize,
    };
  }

  private getBarsCount(): number {
    if (this.startTime === null || this.endTime === null) {
      return 0;
    }

    const startIndex = this.findIndexByTime(this.startTime);
    const endIndex = this.findIndexByTime(this.endTime);

    if (startIndex < 0 || endIndex < 0) {
      return 0;
    }

    return Math.abs(endIndex - startIndex);
  }

  private getVolumeInRange(): number {
    if (this.startTime === null || this.endTime === null) {
      return 0;
    }

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

    if (!data.length) {
      return 0;
    }

    const startIndex = this.findIndexByTime(this.startTime);
    const endIndex = this.findIndexByTime(this.endTime);

    if (startIndex < 0 || endIndex < 0) {
      return 0;
    }

    const from = Math.min(startIndex, endIndex);
    const to = Math.max(startIndex, endIndex);

    let volume = 0;

    for (let index = from; index <= to; index += 1) {
      const item = data[index] as unknown as Record<string, unknown> | undefined;

      if (!item) {
        continue;
      }

      if (typeof item.volume === 'number') {
        volume += item.volume;
        continue;
      }

      const customValues = item.customValues as Record<string, unknown> | undefined;

      if (customValues && typeof customValues.volume === 'number') {
        volume += customValues.volume;
      }
    }

    return volume;
  }

  private findIndexByTime(time: Time): number {
    const data = this.series.data() ?? [];

    return data.findIndex((item) => Number(item.time) === Number(time));
  }

  private getTimeBounds(): { left: number; right: number } | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      left: geometry.left,
      right: geometry.right,
    };
  }

  private getPriceBounds(): { top: number; bottom: number } | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      top: geometry.top,
      bottom: geometry.bottom,
    };
  }

  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 (!time) {
      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();

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

    return formatPrice(price) ?? '';
  }

  private getLeftTimeValue(): Time | null {
    if (this.startTime === null || this.endTime === null) {
      return null;
    }

    const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
    const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);

    if (startX === null || endX === null) {
      return this.startTime;
    }

    return Number(startX) <= Number(endX) ? this.startTime : this.endTime;
  }

  private getRightTimeValue(): Time | null {
    if (this.startTime === null || this.endTime === null) {
      return null;
    }

    const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
    const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);

    if (startX === null || endX === null) {
      return this.endTime;
    }

    return Number(startX) <= Number(endX) ? this.endTime : this.startTime;
  }

  private getTopPriceValue(): number | null {
    if (this.startPrice === null || this.endPrice === null) {
      return null;
    }

    const startY = getYCoordinateFromPrice(this.series, this.startPrice);
    const endY = getYCoordinateFromPrice(this.series, this.endPrice);

    if (startY === null || endY === null) {
      return Math.max(this.startPrice, this.endPrice);
    }

    return Number(startY) <= Number(endY) ? this.startPrice : this.endPrice;
  }

  private getBottomPriceValue(): number | null {
    if (this.startPrice === null || this.endPrice === null) {
      return null;
    }

    const startY = getYCoordinateFromPrice(this.series, this.startPrice);
    const endY = getYCoordinateFromPrice(this.series, this.endPrice);

    if (startY === null || endY === null) {
      return Math.min(this.startPrice, this.endPrice);
    }

    return Number(startY) <= Number(endY) ? this.endPrice : this.startPrice;
  }

  private getHandleTarget(point: Point): Exclude<DiapsonHandle, 'body' | null> | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    if (isNearPoint(point, geometry.startPoint.x, geometry.startPoint.y, HANDLE_HIT_TOLERANCE)) {
      return 'start';
    }

    if (isNearPoint(point, geometry.endPoint.x, geometry.endPoint.y, HANDLE_HIT_TOLERANCE)) {
      return 'end';
    }

    return null;
  }

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

    if (!geometry) {
      return false;
    }

    return isPointInBounds(point, geometry, BODY_HIT_TOLERANCE);
  }

  private getCursorStyle(handle: Exclude<DiapsonHandle, null>): PrimitiveHoveredItem['cursorStyle'] {
    if (handle === 'body') {
      return 'move';
    }

    const geometry = this.getGeometry();

    if (!geometry) {
      return 'default';
    }

    const sameDirection =
      (geometry.endPoint.x - geometry.startPoint.x >= 0 && geometry.endPoint.y - geometry.startPoint.y >= 0) ||
      (geometry.endPoint.x - geometry.startPoint.x < 0 && geometry.endPoint.y - geometry.startPoint.y < 0);

    return sameDirection ? 'nwse-resize' : 'nesw-resize';
  }

  private getContainerSize(): { width: number; height: number } {
    return getElementContainerSize(this.container);
  }

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



import { Observable } from 'rxjs';

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
  clamp,
  clampPointToContainer as clampPointToContainerInElement,
  getAnchorFromPoint,
  getContainerSize as getElementContainerSize,
  getPriceDelta as getPriceDeltaFromCoordinates,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  isNearPoint,
  isPointInBounds,
  shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';

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

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

import type { ISeriesDrawing } from '@core/Drawings/common';
import type { AxisLabel, AxisSegment, Bounds, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab, SettingsValues } from '@src/types';
import type { IChartApi, IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';

type FibonacciRetracementMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type FibonacciRetracementHandle = 'body' | 'start' | 'end' | null;
type FibonacciRetracementHandleKey = Exclude<FibonacciRetracementHandle, 'body' | null>;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'top' | 'bottom';

interface FibonacciRetracementParams {
  container: HTMLElement;
  formatObservable?: Observable<ChartOptionsModel>;
  removeSelf?: () => void;
  openSettings?: () => void;
}

interface FibonacciRetracementState {
  hidden: boolean;
  isActive: boolean;
  mode: FibonacciRetracementMode;
  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 {
  startPoint: Point;
  endPoint: Point;
  left: number;
  right: number;
  top: number;
  bottom: number;
  width: number;
  height: number;
  levels: FibonacciRetracementLevelRenderData[];
  areas: FibonacciRetracementAreaRenderData[];
  handles: Record<FibonacciRetracementHandleKey, Point>;
}

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

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

const HANDLE_HIT_TOLERANCE = 8;
const BODY_HIT_TOLERANCE = 6;
const LINE_HIT_TOLERANCE = 6;
const MIN_DISTANCE = 6;
const PERCENT_DIVIDER = 100;

export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracementSettings> implements ISeriesDrawing {
  private removeSelf?: () => void;
  private openSettings?: () => void;

  protected settings: FibonacciRetracementSettings = createDefaultSettings();

  protected mode: FibonacciRetracementMode = 'idle';

  private startTime: Time | null = null;
  private endTime: Time | null = null;
  private startPrice: number | null = null;
  private endPrice: number | null = null;

  private activeDragTarget: FibonacciRetracementHandle = null;
  private dragPointerId: number | null = null;
  private dragStartPoint: Point | null = null;
  private dragStateSnapshot: FibonacciRetracementState | null = null;
  private dragGeometrySnapshot: FibonacciRetracementGeometry | null = null;

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

  private readonly paneView: FibonacciRetracementPaneView;
  private readonly timeAxisPaneView: CustomTimeAxisPaneView;
  private readonly priceAxisPaneView: CustomPriceAxisPaneView;
  private readonly startTimeAxisView: CustomTimeAxisView;
  private readonly endTimeAxisView: CustomTimeAxisView;
  private readonly topPriceAxisView: CustomPriceAxisView;
  private readonly bottomPriceAxisView: CustomPriceAxisView;

  constructor(
    chart: IChartApi,
    series: SeriesApi,
    { container, formatObservable, removeSelf, openSettings }: FibonacciRetracementParams,
  ) {
    super({ chart, series, container });
    this.removeSelf = removeSelf;
    this.openSettings = openSettings;

    this.paneView = new FibonacciRetracementPaneView(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.topPriceAxisView = new CustomPriceAxisView({
      getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
      labelKind: 'top',
    });

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

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

    this.series.attachPrimitive(this);
  }

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

  public getState(): FibonacciRetracementState {
    return {
      hidden: this.hidden,
      isActive: this.isActive.value,
      mode: this.mode,
      startTime: this.startTime,
      endTime: this.endTime,
      startPrice: this.startPrice,
      endPrice: this.endPrice,
      settings: cloneFibonacciRetracementSettings(this.settings),
    };
  }

  public setState(state: unknown): void {
    const next = state as Partial<FibonacciRetracementState>;

    this.hidden = next.hidden ?? this.hidden;
    if (next.isActive !== undefined) {
      this.isActive.next(next.isActive);
    }
    this.mode = next.mode ?? this.mode;

    this.startTime = next.startTime ?? this.startTime;
    this.endTime = next.endTime ?? this.endTime;
    this.startPrice = next.startPrice ?? this.startPrice;
    this.endPrice = next.endPrice ?? this.endPrice;

    if (next.settings) {
      this.settings = mergeFibonacciRetracementSettings(createDefaultSettings(), next.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 updateAllViews(): void {
    updateViews([
      this.paneView,
      this.timeAxisPaneView,
      this.priceAxisPaneView,
      this.startTimeAxisView,
      this.endTimeAxisView,
      this.topPriceAxisView,
      this.bottomPriceAxisView,
    ]);
  }

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

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

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

  public timeAxisViews() {
    return [this.startTimeAxisView, this.endTimeAxisView];
  }

  public priceAxisViews() {
    return [this.topPriceAxisView, this.bottomPriceAxisView];
  }

  public getRenderData(): FibonacciRetracementRenderData | null {
    const geometry = this.hidden ? null : this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      ...geometry,
      showHandles: this.isActive.value,

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

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

    const point = { x, y };

    if (!this.isActive.value && !this.containsPoint(point)) {
      return null;
    }

    const handleTarget = this.isActive.value ? this.getHandleTarget(point) : null;

    if (handleTarget) {
      return {
        cursorStyle: 'pointer',
        externalId: 'fibonacci-retracement-position',
        zOrder: 'top',
      };
    }

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

    return {
      cursorStyle: this.isActive.value ? 'grab' : 'pointer',
      externalId: 'fibonacci-retracement-position',
      zOrder: 'top',
    };
  }

  protected getTimeAxisSegments(): AxisSegment[] {
    const bounds = this.isActive.value ? this.getTimeBounds() : null;

    if (!bounds) {
      return [];
    }

    const { colors } = getThemeStore();

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

  protected getPriceAxisSegments(): AxisSegment[] {
    const bounds = this.isActive.value ? this.getPriceBounds() : null;

    if (!bounds) {
      return [];
    }

    const { colors } = getThemeStore();

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

  protected getTimeAxisLabel(kind: string): AxisLabel | null {
    const coordinate = this.isActive.value ? this.getTimeCoordinate(kind as TimeLabelKind) : null;
    const text = this.getTimeText(kind as TimeLabelKind);

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

    const { colors } = getThemeStore();

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

  protected getPriceAxisLabel(kind: string): AxisLabel | null {
    const coordinate = this.isActive.value ? this.getPriceCoordinate(kind as PriceLabelKind) : null;
    const text = this.getPriceText(kind as PriceLabelKind);

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

    const { colors } = getThemeStore();

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

  protected handleDoubleClick = (event: MouseEvent): void => {
    const point = this.getMousePoint(event);

    if (this.hidden || this.mode !== 'ready' || (!this.containsPoint(point) && !this.getHandleTarget(point))) {
      return;
    }

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

    this.openSettings?.();
  };

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

    const point = this.getEventPoint(event);

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

      this.startDrawing(point);
      return;
    }

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

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

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

    if (!this.isActive.value) {
      this.isActive.next(this.containsPoint(point));
      this.render();
      return;
    }

    const dragTarget = this.getDragTarget(point);

    if (!dragTarget) {
      this.isActive.next(false);
      this.render();
      return;
    }

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

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

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

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

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

    event.preventDefault();

    if (this.activeDragTarget === 'body') {
      this.moveWhole(point);
    } else {
      this.resize(point);
    }

    this.render();
  };

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

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

    this.clearInteractionState();
    this.render();
  };

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

    if (!anchor) {
      return;
    }

    this.startTime = anchor.time;
    this.endTime = anchor.time;
    this.startPrice = anchor.price;
    this.endPrice = anchor.price;

    this.isActive.next(true);
    this.mode = 'drawing';

    this.render();
  }

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

    if (!anchor) {
      return;
    }

    this.endTime = anchor.time;
    this.endPrice = anchor.price;

    this.render();
  }

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

    if (
      !geometry ||
      geometry.width < MIN_DISTANCE ||
      Math.abs(geometry.startPoint.y - geometry.endPoint.y) < MIN_DISTANCE
    ) {
      if (this.removeSelf) {
        this.removeSelf();
        return;
      }

      this.resetToIdle();
      return;
    }

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

    this.render();
  }

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

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

    this.render();
  }

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

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

    this.startTime = null;
    this.endTime = null;
    this.startPrice = null;
    this.endPrice = null;

    this.clearInteractionState();
    this.render();
  }

  private getDragTarget(point: Point): Exclude<FibonacciRetracementHandle, null> | null {
    return this.getHandleTarget(point) ?? (this.containsPoint(point) ? 'body' : null);
  }

  private moveWhole(point: Point): void {
    const snapshot = this.dragStateSnapshot;
    const geometry = this.dragGeometrySnapshot;

    if (!snapshot || !geometry || !this.dragStartPoint) {
      return;
    }

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

    const containerSize = this.getContainerSize();

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

    const minOffsetX = -geometry.left;
    const maxOffsetX = containerSize.width - geometry.right;
    const clampedOffsetX = clamp(rawOffsetX, minOffsetX, maxOffsetX);

    const minOffsetY = -geometry.top;
    const maxOffsetY = containerSize.height - geometry.bottom;
    const clampedOffsetY = clamp(rawOffsetY, minOffsetY, maxOffsetY);

    const nextStartTime = this.shiftTime(snapshot.startTime, clampedOffsetX);
    const nextEndTime = this.shiftTime(snapshot.endTime, clampedOffsetX);

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

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

    this.startTime = nextStartTime;
    this.endTime = nextEndTime;
    this.startPrice = snapshot.startPrice + priceOffset;
    this.endPrice = snapshot.endPrice + priceOffset;
  }

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

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

    if (!anchor) {
      return;
    }

    if (this.activeDragTarget === 'start') {
      this.startTime = anchor.time;
      this.startPrice = anchor.price;
      return;
    }

    this.endTime = anchor.time;
    this.endPrice = anchor.price;
  }

  private createAnchor(point: Point): { time: Time; price: number } | null {
    return getAnchorFromPoint(this.chart, this.series, point);
  }

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

    const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
    const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
    const startY = getYCoordinateFromPrice(this.series, this.startPrice);
    const endY = getYCoordinateFromPrice(this.series, this.endPrice);

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

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

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

    const left = Math.round(Math.min(startPoint.x, endPoint.x));
    const right = Math.round(Math.max(startPoint.x, endPoint.x));
    const levels = this.getLevels();

    const top = Math.min(startPoint.y, endPoint.y, ...levels.map((level) => level.y));
    const bottom = Math.max(startPoint.y, endPoint.y, ...levels.map((level) => level.y));

    return {
      startPoint,
      endPoint,
      left,
      right,
      top,
      bottom,
      width: right - left,
      height: bottom - top,
      levels,
      areas: this.getAreas(levels),
      handles: {
        start: startPoint,
        end: endPoint,
      },
    };
  }

  private getLevels(): FibonacciRetracementLevelRenderData[] {
    if (this.startPrice === null || this.endPrice === null) {
      return [];
    }

    return getVisibleFibonacciLevels(this.settings).reduce<FibonacciRetracementLevelRenderData[]>((result, level) => {
      const price = this.getLevelPrice(level.value);
      const y = getYCoordinateFromPrice(this.series, price);

      if (y === null) {
        return result;
      }

      result.push({
        id: level.id,
        value: level.value,
        price,
        y: Math.round(Number(y)),
        color: level.color,
        text: this.getLevelText(level.value, price),
      });

      return result;
    }, []);
  }

  private getLevelPrice(value: number): number {
    const startPrice = this.startPrice ?? 0;
    const endPrice = this.endPrice ?? 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((a, b) => a.value - b.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 getTimeBounds(): { left: number; right: number } | null {
    const geometry = this.getGeometry();

    return geometry ? { left: geometry.left, right: geometry.right } : null;
  }

  private getPriceBounds(): { top: number; bottom: number } | null {
    const geometry = this.getGeometry();

    return geometry ? { top: geometry.top, bottom: geometry.bottom } : null;
  }

  private getTimeCoordinate(kind: TimeLabelKind): number | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return kind === 'start' ? geometry.startPoint.x : geometry.endPoint.x;
  }

  private getPriceCoordinate(kind: PriceLabelKind): number | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return kind === 'top' ? geometry.top : geometry.bottom;
  }

  private getTimeText(kind: TimeLabelKind): string {
    const time = kind === 'start' ? this.startTime : this.endTime;

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

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

  private getPriceText(kind: PriceLabelKind): string {
    const price = this.getPriceValueForLabel(kind);

    return price === null ? '' : (formatPrice(price) ?? '');
  }

  private getPriceValueForLabel(kind: PriceLabelKind): number | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      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.startPrice ?? 0, this.endPrice ?? 0)
      : Math.min(this.startPrice ?? 0, this.endPrice ?? 0);
  }

  private getHandleTarget(point: Point): FibonacciRetracementHandleKey | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    if (isNearPoint(point, geometry.handles.start.x, geometry.handles.start.y, HANDLE_HIT_TOLERANCE)) {
      return 'start';
    }

    if (isNearPoint(point, geometry.handles.end.x, geometry.handles.end.y, HANDLE_HIT_TOLERANCE)) {
      return 'end';
    }

    return null;
  }

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

    if (!geometry) {
      return false;
    }

    const bounds: Bounds = {
      left: geometry.left,
      right: geometry.right,
      top: geometry.top,
      bottom: geometry.bottom,
    };

    if (this.settings.showBackground && isPointInBounds(point, bounds, BODY_HIT_TOLERANCE)) {
      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 shiftTime(time: Time, offsetX: number): Time | null {
    return shiftTimeByPixels(this.chart, time, offsetX, this.series);
  }

  private getPriceDelta(fromY: number, toY: number): number {
    return getPriceDeltaFromCoordinates(this.series, fromY, toY);
  }

  private getContainerSize(): { width: number; height: number } {
    return getElementContainerSize(this.container);
  }

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

  private getMousePoint(event: MouseEvent): Point {
    const rect = this.container.getBoundingClientRect();

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



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

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
  getAnchorFromPoint,
  getPriceDelta as getPriceDeltaFromCoordinates,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  isNearPoint,
  shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';

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

import { RayPaneView } from './paneView';

import { createDefaultSettings, getRaySettingTabs, RaySettings, RayStyle, RayTextStyle } from './settings';

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

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

interface RayParams {
  container: HTMLElement;
  formatObservable?: Observable<ChartOptionsModel>;
  removeSelf?: () => void;
  openSettings?: () => void;
}

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

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

export interface RayRenderData extends RayGeometry, RayStyle, RayTextStyle {
  showHandles: boolean;
}

const POINT_HIT_TOLERANCE = 8;
const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;

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

  protected settings: RaySettings = createDefaultSettings();

  protected mode: RayMode = 'idle';

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

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

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

  private readonly paneView: RayPaneView;
  private readonly timeAxisPaneView: CustomTimeAxisPaneView;
  private readonly priceAxisPaneView: CustomPriceAxisPaneView;
  private readonly startTimeAxisView: CustomTimeAxisView;
  private readonly directionTimeAxisView: CustomTimeAxisView;
  private readonly startPriceAxisView: CustomPriceAxisView;
  private readonly directionPriceAxisView: CustomPriceAxisView;

  constructor(
    chart: IChartApi,
    series: SeriesApi,
    { container, formatObservable, removeSelf, openSettings }: RayParams,
  ) {
    super({ chart, series, container });
    this.removeSelf = removeSelf;
    this.openSettings = openSettings;

    this.paneView = new RayPaneView(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.directionTimeAxisView = new CustomTimeAxisView({
      getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
      labelKind: 'direction',
    });

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

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

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

    this.series.attachPrimitive(this);
  }

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

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

  public setState(state: unknown): void {
    const nextState = state as Partial<RayState>;

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

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

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

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

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

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

    this.render();
  }

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

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

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

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

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

  public timeAxisViews() {
    return [this.startTimeAxisView, this.directionTimeAxisView];
  }

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      ...geometry,
      showHandles: this.isActive.value,
      ...this.settings,
    };
  }

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

    const point = { x, y };

    if (this.getPointTarget(point)) {
      return {
        cursorStyle: 'move',
        externalId: 'ray',
        zOrder: 'top',
      };
    }

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

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

    return [
      {
        from: Math.min(geometry.startPoint.x, geometry.directionPoint.x),
        to: Math.max(geometry.startPoint.x, geometry.directionPoint.x),
        color: colors.axisMarkerAreaFill,
      },
    ];
  }

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

    return [
      {
        from: Math.min(geometry.startPoint.y, geometry.directionPoint.y),
        to: Math.max(geometry.startPoint.y, geometry.directionPoint.y),
        color: colors.axisMarkerAreaFill,
      },
    ];
  }

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

    const coordinate = this.getTimeCoordinate(kind);
    const text = this.getTimeText(kind);

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

    const { colors } = getThemeStore();

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

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

    const coordinate = this.getPriceCoordinate(kind);
    const text = this.getPriceText(kind);

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

    const { colors } = getThemeStore();

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

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

    const rect = this.container.getBoundingClientRect();
    const point = {
      x: event.clientX - rect.left,
      y: event.clientY - rect.top,
    };

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

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

    this.openSettings?.();
  };

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

    const point = this.getEventPoint(event);

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

      this.startDrawing(point);
      return;
    }

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

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

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

    const pointTarget = this.getPointTarget(point);

    if (!this.isActive.value) {
      if (!pointTarget && !this.isPointNearRay(point)) {
        return;
      }

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

      this.isActive.next(true);
      this.render();
      return;
    }

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

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

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

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

    if (this.isPointNearRay(point)) {
      event.preventDefault();
      event.stopPropagation();

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

    this.isActive.next(false);
    this.render();
  };

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

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

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

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

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

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

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

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

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

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

    if (!anchor) {
      return;
    }

    this.startAnchor = anchor;
    this.directionAnchor = anchor;
    this.isActive.next(true);
    this.mode = 'drawing';

    this.render();
  }

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

    if (!anchor) {
      return;
    }

    this.directionAnchor = anchor;
    this.render();
  }

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

    if (!geometry) {
      return;
    }

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

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

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

    this.render();
  }

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

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

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

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

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

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

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

    if (!anchor) {
      return;
    }

    if (this.mode === 'dragging-start') {
      this.startAnchor = anchor;
    }

    if (this.mode === 'dragging-direction') {
      this.directionAnchor = anchor;
    }
  }

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

    if (!snapshot?.startAnchor || !snapshot.directionAnchor || !this.dragStartPoint) {
      return;
    }

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

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

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

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

    this.directionAnchor = {
      time: nextDirectionTime,
      price: snapshot.directionAnchor.price + priceOffset,
    };
  }

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

  protected getGeometry(): RayGeometry | null {
    if (!this.startAnchor || !this.directionAnchor) {
      return null;
    }

    const startX = getXCoordinateFromTime(this.chart, this.startAnchor.time, this.series);
    const directionX = getXCoordinateFromTime(this.chart, this.directionAnchor.time, this.series);
    const startY = getYCoordinateFromPrice(this.series, this.startAnchor.price);
    const directionY = getYCoordinateFromPrice(this.series, this.directionAnchor.price);

    if (startX === null || directionX === null || startY === null || directionY === null) {
      return null;
    }

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

    const directionPoint = {
      x: Math.round(Number(directionX)),
      y: Math.round(Number(directionY)),
    };

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

    if (!rayEndPoint) {
      return null;
    }

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

  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: Point[] = [];

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

      const leftY = startPoint.y + leftT * dy;
      const rightY = startPoint.y + rightT * dy;

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

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

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

      const topX = startPoint.x + topT * dx;
      const bottomX = startPoint.x + bottomT * dx;

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

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

    return candidates[0] ?? directionPoint;
  }

  private getPointTarget(point: Point): 'start' | 'direction' | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    if (isNearPoint(point, geometry.startPoint.x, geometry.startPoint.y, POINT_HIT_TOLERANCE)) {
      return 'start';
    }

    if (isNearPoint(point, geometry.directionPoint.x, geometry.directionPoint.y, POINT_HIT_TOLERANCE)) {
      return 'direction';
    }

    return null;
  }

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

    if (!geometry) {
      return false;
    }

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

  private getDistanceToSegment(point: Point, startPoint: Point, endPoint: Point): number {
    const dx = endPoint.x - startPoint.x;
    const dy = endPoint.y - startPoint.y;

    if (dx === 0 && dy === 0) {
      return Math.hypot(point.x - startPoint.x, point.y - startPoint.y);
    }

    const t = Math.max(
      0,
      Math.min(1, ((point.x - startPoint.x) * dx + (point.y - startPoint.y) * dy) / (dx * dx + dy * dy)),
    );

    const projectionX = startPoint.x + t * dx;
    const projectionY = startPoint.y + t * dy;

    return Math.hypot(point.x - projectionX, point.y - projectionY);
  }

  private getTimeCoordinate(kind: TimeLabelKind): number | null {
    const anchor = kind === 'start' ? this.startAnchor : this.directionAnchor;

    if (!anchor) {
      return null;
    }

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

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

  private getPriceCoordinate(kind: PriceLabelKind): number | null {
    const anchor = kind === 'start' ? this.startAnchor : this.directionAnchor;

    if (!anchor) {
      return null;
    }

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

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

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

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

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

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

    if (!anchor) {
      return '';
    }

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



import { Observable } from 'rxjs';

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
  clamp,
  clampPointToContainer as clampPointToContainerInElement,
  getAnchorFromPoint,
  getContainerSize as getElementContainerSize,
  getPointerPoint as getPointerPointFromEvent,
  getPriceDelta as getPriceDeltaFromCoordinates,
  getPriceFromYCoordinate,
  getTimeFromXCoordinate,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  isNearPoint,
  isPointInBounds,
  normalizeBounds,
  shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';

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

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

import type { ISeriesDrawing } from '@core/Drawings/common';
import type { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab, SettingsValues } from '@src/types';
import type {
  AutoscaleInfo,
  IChartApi,
  IPrimitivePaneView,
  Logical,
  PrimitiveHoveredItem,
  SeriesAttachedParameter,
  SeriesOptionsMap,
  Time,
  UTCTimestamp,
} from 'lightweight-charts';

type RectangleMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type RectangleHandle = 'body' | 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | null;
type RectangleHandleKey = Exclude<RectangleHandle, 'body' | null>;
type TimeLabelKind = 'left' | 'right';
type PriceLabelKind = 'top' | 'bottom';

interface RectangleParams {
  container: HTMLElement;
  formatObservable?: Observable<ChartOptionsModel>;
  removeSelf?: () => void;
  openSettings?: () => void;
}

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

interface RectangleGeometry {
  left: number;
  right: number;
  top: number;
  bottom: number;
  width: number;
  height: number;
  handles: Record<RectangleHandleKey, Point>;
}

export interface RectangleRenderData extends RectangleGeometry, RectangleStyle, RectangleTextStyle {
  showFill: boolean;
  showHandles: boolean;
}

const HANDLE_HIT_TOLERANCE = 8;
const BODY_HIT_TOLERANCE = 6;
const MIN_RECTANGLE_SIZE = 6;

export class Rectangle extends SeriesDrawingBase<RectangleSettings> implements ISeriesDrawing {
  private removeSelf?: () => void;
  private openSettings?: () => void;

  protected settings: RectangleSettings = createDefaultSettings();

  protected mode: RectangleMode = 'idle';

  private startTime: Time | null = null;
  private endTime: Time | null = null;
  private startPrice: number | null = null;
  private endPrice: number | null = null;

  private activeDragTarget: RectangleHandle = null;
  private dragPointerId: number | null = null;
  private dragStartPoint: Point | null = null;
  private dragStateSnapshot: RectangleState | null = null;
  private dragGeometrySnapshot: RectangleGeometry | null = null;

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

  private readonly paneView: RectanglePaneView;
  private readonly timeAxisPaneView: CustomTimeAxisPaneView;
  private readonly priceAxisPaneView: CustomPriceAxisPaneView;
  private readonly leftTimeAxisView: CustomTimeAxisView;
  private readonly rightTimeAxisView: CustomTimeAxisView;
  private readonly topPriceAxisView: CustomPriceAxisView;
  private readonly bottomPriceAxisView: CustomPriceAxisView;

  constructor(
    chart: IChartApi,
    series: SeriesApi,
    { container, formatObservable, removeSelf, openSettings }: RectangleParams,
  ) {
    super({ chart, series, container });
    this.removeSelf = removeSelf;
    this.openSettings = openSettings;

    this.paneView = new RectanglePaneView(this);

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

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

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

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

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

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

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

    this.series.attachPrimitive(this);
  }

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

  public getState(): RectangleState {
    return {
      hidden: this.hidden,
      isActive: this.isActive.value,
      mode: this.mode,
      startTime: this.startTime,
      endTime: this.endTime,
      startPrice: this.startPrice,
      endPrice: this.endPrice,
      settings: { ...this.settings },
    };
  }

  public setState(state: unknown): void {
    const nextState = state as Partial<RectangleState>;

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

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

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

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

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

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

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

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

    this.render();
  }

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

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

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

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

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

  public timeAxisViews() {
    return [this.leftTimeAxisView, this.rightTimeAxisView];
  }

  public priceAxisViews() {
    return [this.topPriceAxisView, this.bottomPriceAxisView];
  }

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      ...geometry,
      showFill: true,
      showHandles: this.isActive.value,
      ...this.settings,
    };
  }

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

    const point = { x, y };

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

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

    const handleTarget = this.getHandleTarget(point);

    if (handleTarget) {
      return {
        cursorStyle: this.getCursorStyle(handleTarget),
        externalId: 'rectangle-position',
        zOrder: 'top',
      };
    }

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

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

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

    const bounds = this.getTimeBounds();

    if (!bounds) {
      return [];
    }

    const { colors } = getThemeStore();

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

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

    const bounds = this.getPriceBounds();

    if (!bounds) {
      return [];
    }

    const { colors } = getThemeStore();

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

  protected getTimeAxisLabel(kind: string): AxisLabel | null {
    if (!this.isActive.value || (kind !== 'left' && kind !== 'right')) {
      return null;
    }

    const labelKind = kind as TimeLabelKind;
    const coordinate = this.getTimeCoordinate(labelKind);
    const text = this.getTimeText(labelKind);

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

    const { colors } = getThemeStore();

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

  protected getPriceAxisLabel(kind: string): AxisLabel | null {
    if (!this.isActive.value || (kind !== 'top' && kind !== 'bottom')) {
      return null;
    }

    const labelKind = kind as PriceLabelKind;
    const coordinate = this.getPriceCoordinate(labelKind);
    const text = this.getPriceText(labelKind);

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

    const { colors } = getThemeStore();

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

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

    const rect = this.container.getBoundingClientRect();
    const point = {
      x: event.clientX - rect.left,
      y: event.clientY - rect.top,
    };

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

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

    this.openSettings?.();
  };

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

    const point = this.getEventPoint(event);

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

      this.startDrawing(point);
      return;
    }

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

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

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

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

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

      this.isActive.next(true);
      this.render();
      return;
    }

    const dragTarget = this.getDragTarget(point);

    if (!dragTarget) {
      this.isActive.next(false);
      this.render();
      return;
    }

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

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

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

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

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

    event.preventDefault();

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

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

    if (!anchor) {
      return;
    }

    this.startTime = anchor.time;
    this.endTime = anchor.time;
    this.startPrice = anchor.price;
    this.endPrice = anchor.price;

    this.isActive.next(true);
    this.mode = 'drawing';

    this.render();
  }

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

    if (!anchor) {
      return;
    }

    this.endTime = anchor.time;
    this.endPrice = anchor.price;

    this.render();
  }

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

    if (!geometry || geometry.width < MIN_RECTANGLE_SIZE || geometry.height < MIN_RECTANGLE_SIZE) {
      if (this.removeSelf) {
        this.removeSelf();
        return;
      }

      this.resetToIdle();
      return;
    }

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

    this.render();
  }

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

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

    this.render();
  }

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

    this.clearInteractionState();
    this.render();
  }

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

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

    this.startTime = null;
    this.endTime = null;
    this.startPrice = null;
    this.endPrice = null;
    this.clearInteractionState();
    this.render();
  }

  private getDragTarget(point: Point): Exclude<RectangleHandle, null> | null {
    const handleTarget = this.getHandleTarget(point);

    if (handleTarget) {
      return handleTarget;
    }

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

    return null;
  }

  private moveWhole(point: Point): void {
    const snapshot = this.dragStateSnapshot;
    const geometry = this.dragGeometrySnapshot;

    if (!snapshot || !geometry || !this.dragStartPoint) {
      return;
    }

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

    const containerSize = this.getContainerSize();

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

    const minOffsetX = -geometry.left;
    const maxOffsetX = containerSize.width - geometry.right;
    const clampedOffsetX = clamp(rawOffsetX, minOffsetX, maxOffsetX);

    const minOffsetY = -geometry.top;
    const maxOffsetY = containerSize.height - geometry.bottom;
    const clampedOffsetY = clamp(rawOffsetY, minOffsetY, maxOffsetY);

    const nextStartTime = this.shiftTime(snapshot.startTime, clampedOffsetX);
    const nextEndTime = this.shiftTime(snapshot.endTime, clampedOffsetX);

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

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

    this.startTime = nextStartTime;
    this.endTime = nextEndTime;
    this.startPrice = snapshot.startPrice + priceOffset;
    this.endPrice = snapshot.endPrice + priceOffset;
  }

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

    if (!geometry || !this.activeDragTarget || this.activeDragTarget === 'body') {
      return;
    }

    const clampedPoint = this.clampPointToContainer(point);

    let { left } = geometry;
    let { right } = geometry;
    let { top } = geometry;
    let { bottom } = geometry;

    switch (this.activeDragTarget) {
      case 'nw':
        left = clampedPoint.x;
        top = clampedPoint.y;
        break;
      case 'n':
        top = clampedPoint.y;
        break;
      case 'ne':
        right = clampedPoint.x;
        top = clampedPoint.y;
        break;
      case 'e':
        right = clampedPoint.x;
        break;
      case 'se':
        right = clampedPoint.x;
        bottom = clampedPoint.y;
        break;
      case 's':
        bottom = clampedPoint.y;
        break;
      case 'sw':
        left = clampedPoint.x;
        bottom = clampedPoint.y;
        break;
      case 'w':
        left = clampedPoint.x;
        break;
      default:
        return;
    }

    this.setRectangleBounds(left, right, top, bottom);
  }

  private setRectangleBounds(left: number, right: number, top: number, bottom: number): boolean {
    const bounds = normalizeBounds(left, right, top, bottom, this.container);

    const startTime = getTimeFromXCoordinate(this.chart, bounds.left);
    const endTime = getTimeFromXCoordinate(this.chart, bounds.right);
    const startPrice = getPriceFromYCoordinate(this.series, bounds.top);
    const endPrice = getPriceFromYCoordinate(this.series, bounds.bottom);

    if (startTime === null || endTime === null || startPrice === null || endPrice === null) {
      return false;
    }

    this.startTime = startTime;
    this.endTime = endTime;
    this.startPrice = startPrice;
    this.endPrice = endPrice;

    return true;
  }

  private createAnchor(point: Point): { time: Time; price: number } | null {
    return getAnchorFromPoint(this.chart, this.series, point);
  }

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

    const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
    const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
    const startY = getYCoordinateFromPrice(this.series, this.startPrice);
    const endY = getYCoordinateFromPrice(this.series, this.endPrice);

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

    const left = Math.round(Math.min(Number(startX), Number(endX)));
    const right = Math.round(Math.max(Number(startX), Number(endX)));
    const top = Math.round(Math.min(Number(startY), Number(endY)));
    const bottom = Math.round(Math.max(Number(startY), Number(endY)));

    const centerX = (left + right) / 2;
    const centerY = (top + bottom) / 2;

    return {
      left,
      right,
      top,
      bottom,
      width: right - left,
      height: bottom - top,
      handles: {
        nw: { x: left, y: top },
        n: { x: centerX, y: top },
        ne: { x: right, y: top },
        e: { x: right, y: centerY },
        se: { x: right, y: bottom },
        s: { x: centerX, y: bottom },
        sw: { x: left, y: bottom },
        w: { x: left, y: centerY },
      },
    };
  }

  private getTimeBounds(): { left: number; right: number } | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      left: geometry.left,
      right: geometry.right,
    };
  }

  private getPriceBounds(): { top: number; bottom: number } | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      top: geometry.top,
      bottom: geometry.bottom,
    };
  }

  private getTimeCoordinate(kind: TimeLabelKind): number | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return kind === 'left' ? geometry.left : geometry.right;
  }

  private getPriceCoordinate(kind: PriceLabelKind): number | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return kind === 'top' ? geometry.top : geometry.bottom;
  }

  private getTimeText(kind: TimeLabelKind): string {
    const time = this.getTimeValueForLabel(kind);

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

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

  private getPriceText(kind: PriceLabelKind): string {
    const price = this.getPriceValueForLabel(kind);

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

    return formatPrice(price) ?? '';
  }

  private getTimeValueForLabel(kind: TimeLabelKind): Time | null {
    if (this.startTime === null || this.endTime === null) {
      return null;
    }

    const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
    const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);

    if (startX === null || endX === null) {
      return kind === 'left' ? this.startTime : this.endTime;
    }

    const startIsLeft = Number(startX) <= Number(endX);

    if (kind === 'left') {
      return startIsLeft ? this.startTime : this.endTime;
    }

    return startIsLeft ? this.endTime : this.startTime;
  }

  private getPriceValueForLabel(kind: PriceLabelKind): number | null {
    if (this.startPrice === null || this.endPrice === null) {
      return null;
    }

    const startY = getYCoordinateFromPrice(this.series, this.startPrice);
    const endY = getYCoordinateFromPrice(this.series, this.endPrice);

    if (startY === null || endY === null) {
      return kind === 'top' ? Math.max(this.startPrice, this.endPrice) : Math.min(this.startPrice, this.endPrice);
    }

    const startIsTop = Number(startY) <= Number(endY);

    if (kind === 'top') {
      return startIsTop ? this.startPrice : this.endPrice;
    }

    return startIsTop ? this.endPrice : this.startPrice;
  }

  private getHandleTarget(point: Point): RectangleHandleKey | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    const handleOrder: RectangleHandleKey[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];

    for (const handleName of handleOrder) {
      const handle = geometry.handles[handleName];

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

    return null;
  }

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

    if (!geometry) {
      return false;
    }

    return isPointInBounds(point, geometry, BODY_HIT_TOLERANCE);
  }

  private getCursorStyle(handle: Exclude<RectangleHandle, null>): PrimitiveHoveredItem['cursorStyle'] {
    switch (handle) {
      case 'nw':
      case 'se':
        return 'nwse-resize';
      case 'ne':
      case 'sw':
        return 'nesw-resize';
      case 'n':
      case 's':
        return 'ns-resize';
      case 'e':
      case 'w':
        return 'ew-resize';
      case 'body':
        return 'grab';
      default:
        return 'default';
    }
  }

  private shiftTime(time: Time, offsetX: number): Time | null {
    return shiftTimeByPixels(this.chart, time, offsetX, this.series);
  }

  private getPriceDelta(fromY: number, toY: number): number {
    return getPriceDeltaFromCoordinates(this.series, fromY, toY);
  }

  private getContainerSize(): { width: number; height: number } {
    return getElementContainerSize(this.container);
  }

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



import { PrimitiveHoveredItem } from 'lightweight-charts';
import { Observable, skip } from 'rxjs';

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import { getPriceFromYCoordinate, getXCoordinateFromTime, getYCoordinateFromPrice } from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';

import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { ChartOptionsModel, Direction } from '@src/types';
import { Defaults } from '@src/types/defaults';
import { SettingsTab, SettingsValues } from '@src/types/settings';
import { formatPrice, formatVolume } from '@src/utils';
import { formatDate } from '@src/utils/formatter';

import { RulerPaneView } from './paneView';

import type { ISeriesDrawing } from '@core/Drawings/common';
import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type {
  AutoscaleInfo,
  Coordinate,
  IChartApi,
  IPrimitivePaneView,
  ISeriesApi,
  ISeriesPrimitiveAxisView,
  Logical,
  MouseEventHandler,
  MouseEventParams,
  SeriesAttachedParameter,
  SeriesOptionsMap,
  Time,
  UTCTimestamp,
} from 'lightweight-charts';

type SeriesApi = ISeriesApi<keyof SeriesOptionsMap, Time>;
type RulerMode = 'idle' | 'placingEnd' | 'ready';

interface RulerState {
  hidden: boolean;
  mode: RulerMode;
  startAnchor: Anchor | null;
  endAnchor: Anchor | null;
}

interface RulerParams {
  container: HTMLElement;
  formatObservable?: Observable<ChartOptionsModel>;
  resetTriggers?: Observable<unknown>[];
  removeSelf?: () => void;
}

export interface RulerRenderData {
  hidden: boolean;
  startPoint: Point | null;
  endPoint: Point | null;
  lineColor: string;
  fillColor: string;
  textColor: string;
  infoLines: string[];
  horizontalArrowSide: Direction.Left | Direction.Right | null;
  verticalArrowSide: Direction.Top | Direction.Bottom | null;
}

export class Ruler extends SeriesDrawingBase implements ISeriesDrawing {
  private removeSelf?: () => void;
  protected settings: SettingsValues = {};

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

  protected mode: RulerMode = 'idle';

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

  private readonly clickHandler: MouseEventHandler<Time>;
  private readonly moveHandler: MouseEventHandler<Time>;

  private readonly paneView: RulerPaneView;
  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: IChartApi,
    series: SeriesApi,
    { resetTriggers = [], formatObservable, removeSelf, container }: RulerParams,
  ) {
    super({ chart, series, container });
    this.removeSelf = removeSelf;

    this.clickHandler = (params) => this.handleClick(params);
    this.moveHandler = (params) => this.handleMove(params);

    this.paneView = new RulerPaneView(this);

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

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

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

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

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

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

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

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

    this.series.attachPrimitive(this);
  }

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

  public getSettingsTabs(): SettingsTab[] {
    return [];
  }

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

  public setState(state: unknown): void {
    const nextState = state as Partial<RulerState>;

    this.hidden = nextState.hidden ?? this.hidden;
    this.mode = nextState.mode ?? this.mode;
    this.startAnchor = nextState.startAnchor ?? this.startAnchor;
    this.endAnchor = nextState.endAnchor ?? this.endAnchor;

    this.render();
  }

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

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

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

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

  public timeAxisViews(): readonly ISeriesPrimitiveAxisView[] {
    return [this.startTimeAxisView, this.endTimeAxisView];
  }

  public priceAxisViews(): readonly ISeriesPrimitiveAxisView[] {
    return [this.startPriceAxisView, this.endPriceAxisView];
  }

  public autoscaleInfo(startTimePoint: Logical, endTimePoint: Logical): AutoscaleInfo | null {
    if (this.hidden || this.mode === 'placingEnd') {
      return null;
    }

    if (!this.startAnchor || !this.endAnchor) {
      return null;
    }

    const startCoordinate = getXCoordinateFromTime(this.chart, this.startAnchor.time, this.series);
    const endCoordinate = getXCoordinateFromTime(this.chart, this.endAnchor.time, this.series);

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

    const startLogical = this.chart.timeScale().coordinateToLogical(startCoordinate as Coordinate);
    const endLogical = this.chart.timeScale().coordinateToLogical(endCoordinate as Coordinate);

    if (startLogical === null || endLogical === null) {
      return null;
    }

    const leftLogical = Math.min(Number(startLogical), Number(endLogical));
    const rightLogical = Math.max(Number(startLogical), Number(endLogical));

    if (endTimePoint < leftLogical || startTimePoint > rightLogical) {
      return null;
    }

    return {
      priceRange: {
        minValue: Math.min(Number(this.startAnchor.price), Number(this.endAnchor.price)),
        maxValue: Math.max(Number(this.startAnchor.price), Number(this.endAnchor.price)),
      },
    };
  }

  public getRenderData(): RulerRenderData {
    const startPoint = this.startAnchor ? this.getPoint(this.startAnchor) : null;
    const endPoint = this.endAnchor ? this.getPoint(this.endAnchor) : null;

    const startPrice = this.startAnchor ? Number(this.startAnchor.price) : 0;
    const endPrice = this.endAnchor ? Number(this.endAnchor.price) : 0;
    const priceDiff = endPrice - startPrice;
    const percentDiff = startPrice !== 0 ? (priceDiff / startPrice) * 100 : null;

    const startIndex = this.startAnchor ? this.findIndexByTime(this.startAnchor.time) : -1;
    const endIndex = this.endAnchor ? this.findIndexByTime(this.endAnchor.time) : -1;

    const barsCount = startIndex >= 0 && endIndex >= 0 ? Math.abs(endIndex - startIndex) : 0;
    const volume = this.getVolumeInRange();
    const isLong = priceDiff >= 0;

    const horizontalArrowSide =
      startPoint && endPoint && startPoint.x !== endPoint.x
        ? endPoint.x > startPoint.x
          ? Direction.Right
          : Direction.Left
        : null;

    const verticalArrowSide =
      startPoint && endPoint && startPoint.y !== endPoint.y
        ? endPoint.y > startPoint.y
          ? Direction.Bottom
          : Direction.Top
        : null;

    const { colors } = getThemeStore();

    return {
      hidden: this.hidden,
      startPoint,
      endPoint,
      lineColor: isLong ? colors.chartLineColor : colors.chartLineColorAlternative,
      fillColor: isLong ? colors.rulerPositiveFill : colors.rulerNegativeFill,
      textColor: colors.chartPriceLineText,
      infoLines: [
        `${formatPrice(Math.abs(priceDiff))} (${percentDiff === null ? '-' : `${formatPrice(Math.abs(percentDiff))}%`})`,
        `${barsCount} ${t('bars')},`,
        `${t('Vol')} ${formatVolume(volume)}`,
      ],
      horizontalArrowSide,
      verticalArrowSide,
    };
  }

  public getTimeCoordinate(kind: 'start' | 'end'): Coordinate | null {
    const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;

    if (!anchor) {
      return null;
    }

    return getXCoordinateFromTime(this.chart, anchor.time, this.series);
  }

  public getPriceCoordinate(kind: 'start' | 'end'): Coordinate | null {
    const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;

    if (!anchor) {
      return null;
    }

    return getYCoordinateFromPrice(this.series, anchor.price);
  }

  public getTimeBounds(): { left: number; right: number } | null {
    const startX = this.getTimeCoordinate('start');
    const endX = this.getTimeCoordinate('end');

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

    return {
      left: Math.min(startX, endX),
      right: Math.max(startX, endX),
    };
  }

  public getPriceBounds(): { top: number; bottom: number } | null {
    const startY = this.getPriceCoordinate('start');
    const endY = this.getPriceCoordinate('end');

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

    return {
      top: Math.min(startY, endY),
      bottom: Math.max(startY, endY),
    };
  }

  public getTimeText(kind: 'start' | 'end'): string {
    const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;

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

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

  public getPriceText(kind: 'start' | 'end'): string {
    const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;

    if (!anchor) {
      return '';
    }

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

  public hitTest(x: number, y: number): PrimitiveHoveredItem | null {
    return null;
  }

  protected getTimeAxisSegments(): AxisSegment[] {
    const bounds = this.getTimeBounds();

    if (!bounds) {
      return [];
    }

    const { colors } = getThemeStore();

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

  protected getPriceAxisSegments(): AxisSegment[] {
    const bounds = this.getPriceBounds();

    if (!bounds) {
      return [];
    }

    const { colors } = getThemeStore();

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

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

    const coordinate = this.getTimeCoordinate(kind);
    const text = this.getTimeText(kind);

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

    const { colors } = getThemeStore();

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

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

    const coordinate = this.getPriceCoordinate(kind);
    const text = this.getPriceText(kind);

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

    const { colors } = getThemeStore();

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

  private findIndexByTime(time: Time): number {
    const data = this.series.data() ?? [];

    return data.findIndex((item) => {
      if (typeof item.time === 'number' && typeof time === 'number') {
        return item.time === time;
      }

      return false;
    });
  }

  protected bindEvents(): void {
    // todo: попробовать привести к виду базового класса
    if (this.isBound) {
      return;
    }

    this.isBound = true;
    this.chart.subscribeClick(this.clickHandler);
    this.chart.subscribeCrosshairMove(this.moveHandler);
  }

  protected getGeometry(): void {
    console.log('stub');
  }

  protected unbindEvents(): void {
    // todo: попробовать привести к виду базового класса
    if (!this.isBound) {
      return;
    }

    this.isBound = false;
    this.chart.unsubscribeClick(this.clickHandler);
    this.chart.unsubscribeCrosshairMove(this.moveHandler);
  }

  private handleClick(params: MouseEventParams<Time>): void {
    if (this.hidden || !params.point) {
      return;
    }

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

    const anchor = this.createAnchor(params);

    if (!anchor) {
      return;
    }

    if (this.mode === 'idle') {
      this.startAnchor = anchor;
      this.endAnchor = anchor;
      this.mode = 'placingEnd';

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

    if (this.mode === 'placingEnd') {
      this.endAnchor = anchor;
      this.mode = 'ready';
      this.resolveReady?.();
      this.showCrosshair();
      this.render();
    }
  }

  private handleMove(params: MouseEventParams<Time>): void {
    if (this.hidden || !params.point) {
      return;
    }

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

    const anchor = this.createAnchor(params);

    if (!anchor) {
      return;
    }

    this.endAnchor = anchor;
    this.render();
  }

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

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

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

    return {
      price,
      time,
    };
  }

  private getPoint(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,
      y,
    };
  }

  private getVolumeInRange(): number {
    if (!this.startAnchor || !this.endAnchor) {
      return 0;
    }

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

    if (!data.length) {
      return 0;
    }

    const startIndex = this.findIndexByTime(this.startAnchor.time);
    const endIndex = this.findIndexByTime(this.endAnchor.time);

    if (startIndex < 0 || endIndex < 0) {
      return 0;
    }

    const from = Math.min(startIndex, endIndex);
    const to = Math.max(startIndex, endIndex);

    let volume = 0;

    for (let index = from; index <= to; index += 1) {
      const item = data[index] as unknown as Record<string, unknown> | undefined;

      if (!item) {
        continue;
      }

      if (typeof item.volume === 'number') {
        volume += item.volume;
        continue;
      }

      const customValues = item.customValues as Record<string, unknown> | undefined;

      if (customValues && typeof customValues.volume === 'number') {
        volume += customValues.volume;
      }
    }

    return volume;
  }
}



import { Observable, skip } from 'rxjs';

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

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

import { SliderPaneView } from './paneView';

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

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

type SliderSide = 'long' | 'short';
type SliderMode = 'idle' | 'ready' | 'dragging';
type DragTarget = 'body' | 'entry' | 'target' | 'stop' | 'end' | null;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'target' | 'entry' | 'stop';

interface SliderPositionParams {
  side: SliderSide;
  container: HTMLElement;
  formatObservable?: Observable<ChartOptionsModel>;
  resetTriggers?: Observable<unknown>[];
  removeSelf?: () => void;
  openSettings?: () => void;
}

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

interface SliderGeometry {
  startX: number;
  endX: number;
  leftX: number;
  rightX: number;
  entryY: number;
  stopY: number;
  targetY: number;
  entryPrice: number;
  stopPrice: number;
  targetPrice: number;
  profitTop: number;
  profitBottom: number;
  lossTop: number;
  lossBottom: number;
}

export interface SliderRenderData extends SliderGeometry, SliderPositionStyle, SliderPositionTextStyle {
  targetText: string;
  centerText: string;
  stopText: string;
  centerBoxColor: string;
  showFill: boolean;
  showHandles: boolean;
  showLabels: boolean;
}

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

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

  protected settings: SliderPositionSettings = createDefaultSettings();

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

  protected mode: SliderMode = 'idle';
  private side: SliderSide;

  private startTime: Time | null = null;
  private endTime: Time | null = null;
  private entryPrice: number | null = null;
  private stopPrice: number | null = null;
  private targetPrice: number | null = null;

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

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

  private ignoreNextChartClick = false;

  private readonly clickHandler: MouseEventHandler<Time>;
  private readonly paneView: SliderPaneView;
  private readonly timeAxisPaneView: CustomTimeAxisPaneView;
  private readonly priceAxisPaneView: CustomPriceAxisPaneView;
  private readonly startTimeAxisView: CustomTimeAxisView;
  private readonly endTimeAxisView: CustomTimeAxisView;
  private readonly targetPriceAxisView: CustomPriceAxisView;
  private readonly entryPriceAxisView: CustomPriceAxisView;
  private readonly stopPriceAxisView: CustomPriceAxisView;

  constructor(
    chart: IChartApi,
    series: SeriesApi,
    { side, container, formatObservable, resetTriggers = [], removeSelf, openSettings }: SliderPositionParams,
  ) {
    super({ chart, series, container });
    this.side = side;
    this.removeSelf = removeSelf;
    this.openSettings = openSettings;

    this.clickHandler = (params) => this.handleChartClick(params);

    this.paneView = new SliderPaneView(this);

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

    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.targetPriceAxisView = new CustomPriceAxisView({
      getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
      labelKind: 'target',
    });

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

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

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

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

    this.series.attachPrimitive(this);
  }

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

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

  public setState(state: unknown): void {
    const next = state as Partial<SliderPositionState>;

    this.hidden = next.hidden ?? this.hidden;
    if (typeof next.active === 'boolean') {
      this.isActive.next(next.active);
    }
    this.mode = next.mode ?? this.mode;

    this.startTime = next.startTime ?? this.startTime;
    this.endTime = next.endTime ?? this.endTime;
    this.entryPrice = next.entryPrice ?? this.entryPrice;
    this.stopPrice = next.stopPrice ?? this.stopPrice;
    this.amount = next.amount ?? this.amount;

    if (typeof next.tickSize === 'number' && next.tickSize > 0) {
      this.tickSize = next.tickSize;
    }

    if (next.targetPrice !== undefined) {
      this.targetPrice = next.targetPrice;
    } else if (
      this.entryPrice !== null &&
      this.stopPrice !== null &&
      typeof next.riskRewardRatio === 'number' &&
      next.riskRewardRatio > 0
    ) {
      const risk = Math.abs(this.entryPrice - this.stopPrice);

      this.targetPrice =
        this.side === 'long'
          ? this.entryPrice + risk * next.riskRewardRatio
          : this.entryPrice - risk * next.riskRewardRatio;
    }

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

    this.render();
  }

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

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

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

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

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

  public timeAxisViews() {
    return [this.startTimeAxisView, this.endTimeAxisView];
  }

  public priceAxisViews() {
    return [this.targetPriceAxisView, this.entryPriceAxisView, this.stopPriceAxisView];
  }

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    const reward = Math.abs(geometry.targetPrice - geometry.entryPrice);
    const qty = reward > 0 ? this.amount / reward : 0;

    const selectedPrice = this.getPriceAtTime(this.endTime);
    const pnl =
      selectedPrice === null
        ? 0
        : this.side === 'long'
          ? (selectedPrice - geometry.entryPrice) * qty
          : (geometry.entryPrice - selectedPrice) * qty;

    const { colors } = getThemeStore();

    return {
      ...geometry,
      targetText: this.getTargetText(geometry),
      centerText: this.getCenterText(qty, pnl),
      stopText: this.getStopText(geometry),
      centerBoxColor: pnl >= 0 ? colors.chartCandleUp : colors.chartCandleDown,
      showFill: true,
      showHandles: this.isActive.value,
      showLabels: this.isActive.value,
      ...this.settings,
    };
  }

  public getTimeBounds(): { left: number; right: number } | null {
    const start = this.getTimeCoordinate('start');
    const end = this.getTimeCoordinate('end');

    if (start === null || end === null) {
      return null;
    }

    return {
      left: Math.min(start, end),
      right: Math.max(start, end),
    };
  }

  public getTimeCoordinate(kind: TimeLabelKind): number | null {
    const time = kind === 'start' ? this.startTime : this.endTime;

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

    const coordinate = getXCoordinateFromTime(this.chart, time, this.series);
    return coordinate === null ? null : Number(coordinate);
  }

  public getTimeText(kind: TimeLabelKind): string {
    const time = kind === 'start' ? this.startTime : this.endTime;

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

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

  public getPriceCoordinate(kind: PriceLabelKind): number | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    switch (kind) {
      case 'target':
        return geometry.targetY;
      case 'entry':
        return geometry.entryY;
      case 'stop':
        return geometry.stopY;
      default:
        return null;
    }
  }

  public getPriceText(kind: PriceLabelKind): string {
    const geometry = this.getGeometry();

    if (!geometry) {
      return '';
    }

    switch (kind) {
      case 'target':
        return formatPrice(geometry.targetPrice) ?? '';
      case 'entry':
        return formatPrice(geometry.entryPrice) ?? '';
      case 'stop':
        return formatPrice(geometry.stopPrice) ?? '';
      default:
        return '';
    }
  }

  public hitTest(x: number, y: number): PrimitiveHoveredItem | null {
    const point = { x, y };

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

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

    const dragTarget = this.getHandleTarget(point);

    if (!dragTarget) {
      return null;
    }

    let cursorStyle: PrimitiveHoveredItem['cursorStyle'] = 'grab';

    if (dragTarget === 'target' || dragTarget === 'stop') {
      cursorStyle = 'ns-resize';
    }

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

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

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

    const bounds = this.getTimeBounds();

    if (!bounds) {
      return [];
    }

    const { colors } = getThemeStore();

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

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

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

    const labelKind = kind as TimeLabelKind;
    const coordinate = this.getTimeCoordinate(labelKind);
    const text = this.getTimeText(labelKind);

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

    const { colors } = getThemeStore();

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

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

    const labelKind = kind as PriceLabelKind;
    const coordinate = this.getPriceCoordinate(labelKind);
    const text = this.getPriceText(labelKind);

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

    const { colors } = getThemeStore();

    let backgroundColor = colors.axisMarkerLabelDefaultFill;

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

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

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

  protected bindEvents(): void {
    // todo: почему то отличается от базового класса
    if (this.isBound) {
      return;
    }

    this.isBound = true;

    this.chart.subscribeClick(this.clickHandler);

    this.container.addEventListener('dblclick', this.handleDoubleClick);
    this.container.addEventListener('pointerdown', this.handlePointerDown);
    window.addEventListener('pointermove', this.handlePointerMove);
    window.addEventListener('pointerup', this.handlePointerUp);
    window.addEventListener('pointercancel', this.handlePointerUp);
  }

  protected unbindEvents(): void {
    // todo: почему то отличается от базового класса
    if (!this.isBound) {
      return;
    }

    this.isBound = false;

    this.chart.unsubscribeClick(this.clickHandler);

    this.container.removeEventListener('dblclick', this.handleDoubleClick);
    this.container.removeEventListener('pointerdown', this.handlePointerDown);
    window.removeEventListener('pointermove', this.handlePointerMove);
    window.removeEventListener('pointerup', this.handlePointerUp);
    window.removeEventListener('pointercancel', this.handlePointerUp);
  }

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

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

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

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

    this.openSettings?.();
  };

  private handleChartClick(params: MouseEventParams<Time>): void {
    if (this.ignoreNextChartClick) {
      this.ignoreNextChartClick = false;
      return;
    }

    if (this.hidden || !params.point) {
      return;
    }

    if (this.mode === 'idle') {
      const anchor = this.createAnchor(params);

      if (!anchor) {
        return;
      }

      const distance = this.getInitialZoneDistance(anchor.price);
      const stopDirection = this.side === 'long' ? -1 : 1;
      const targetDirection = -stopDirection;

      this.startTime = anchor.time;
      this.endTime = this.shiftTime(anchor.time, INITIAL_WIDTH_PX) ?? anchor.time;
      this.entryPrice = anchor.price;
      this.stopPrice = this.normalizeStop(anchor.price, anchor.price + distance * stopDirection, distance);
      this.targetPrice = this.normalizeTarget(
        anchor.price,
        anchor.price + distance * targetDirection * this.defaultRiskRewardRatio,
        distance,
      );

      this.isActive.next(true);
      this.mode = 'ready';
      this.resolveReady?.();

      this.render();
      return;
    }

    this.isActive.next(this.containsPoint({ x: params.point.x, y: params.point.y }));
    this.render();
  }

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

    const point = this.getLocalPoint(event);
    const dragTarget = this.getHandleTarget(point);

    if (!dragTarget) {
      return;
    }

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

    this.activeDragTarget = dragTarget;
    this.dragPointerId = event.pointerId;
    this.dragStartPoint = point;
    this.dragStateSnapshot = this.getState();
    this.didDrag = false;
    this.mode = 'dragging';
  };

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

    event.preventDefault();

    this.didDrag = true;
    this.applyDrag(this.getLocalPoint(event));
    this.render();
  };

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

    this.ignoreNextChartClick = this.didDrag;

    this.activeDragTarget = null;
    this.dragPointerId = null;
    this.dragStartPoint = null;
    this.dragStateSnapshot = null;
    this.didDrag = false;
    this.mode = 'ready';
    this.resolveReady?.();

    this.render();
  };

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

    if (!snapshot) {
      return;
    }

    switch (this.activeDragTarget) {
      case 'body':
        this.moveWhole(snapshot, point);
        break;
      case 'entry':
        this.moveEntry(snapshot, point);
        break;
      case 'target':
        this.moveTarget(snapshot, point);
        break;
      case 'stop':
        this.moveStop(snapshot, point);
        break;
      case 'end':
        this.resizeEnd(snapshot, point);
        break;
      default:
        break;
    }
  }

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

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

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

    const minDistance = this.getEditMinimumDistance();
    const low = Math.min(snapshot.stopPrice, snapshot.targetPrice) + minDistance;
    const high = Math.max(snapshot.stopPrice, snapshot.targetPrice) - minDistance;

    if (low > high) {
      return;
    }

    this.entryPrice = Math.max(low, Math.min(nextPrice, high));
  }

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

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

    if (!this.dragStartPoint) {
      return;
    }

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

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

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

    let nextEntryPrice = snapshot.entryPrice + priceOffset;
    let nextStopPrice = snapshot.stopPrice + priceOffset;
    let nextTargetPrice = snapshot.targetPrice + priceOffset;

    const range = this.getPriceScaleRange();

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

      if (minValue < range.min) {
        const shift = range.min - minValue;
        nextEntryPrice += shift;
        nextStopPrice += shift;
        nextTargetPrice += shift;
      }

      if (maxValue > range.max) {
        const shift = maxValue - range.max;
        nextEntryPrice -= shift;
        nextStopPrice -= shift;
        nextTargetPrice -= shift;
      }
    }

    this.startTime = nextStartTime;
    this.endTime = nextEndTime;
    this.entryPrice = nextEntryPrice;
    this.stopPrice = nextStopPrice;
    this.targetPrice = nextTargetPrice;
  }

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

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

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

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

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

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

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

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

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

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

    this.startTime = snapshot.startTime;
    this.endTime = nextEndTime;
  }

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

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

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

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

  private normalizeStop(entryPrice: number, rawPrice: number, minDistance = this.getEditMinimumDistance()): number {
    const distance = this.getAllowedMinimumDistance(entryPrice, 'stop', minDistance);

    return this.side === 'long' ? Math.min(rawPrice, entryPrice - distance) : Math.max(rawPrice, entryPrice + distance);
  }

  private normalizeTarget(entryPrice: number, rawPrice: number, minDistance = this.getEditMinimumDistance()): number {
    const distance = this.getAllowedMinimumDistance(entryPrice, 'target', minDistance);

    return this.side === 'long' ? Math.max(rawPrice, entryPrice + distance) : Math.min(rawPrice, entryPrice - distance);
  }

  private getAllowedMinimumDistance(entryPrice: number, kind: 'stop' | 'target', minDistance: number): number {
    const range = this.getPriceScaleRange();

    if (!range) {
      return minDistance;
    }

    const availableDistance =
      this.side === 'long'
        ? kind === 'stop'
          ? Math.max(entryPrice - range.min, 0)
          : Math.max(range.max - entryPrice, 0)
        : kind === 'stop'
          ? Math.max(range.max - entryPrice, 0)
          : Math.max(entryPrice - range.min, 0);

    return Math.min(minDistance, availableDistance);
  }

  private clampPriceToRange(price: number): number {
    const range = this.getPriceScaleRange();

    if (!range) {
      return price;
    }

    return Math.max(range.min, Math.min(price, range.max));
  }

  private getInitialZoneDistance(entryPrice: number): number {
    const fallback = Math.max(Math.abs(entryPrice) * 0.0075, this.tickSize * 3, MIN_DISTANCE);
    const range = this.getPriceScaleRange();

    if (!range) {
      return fallback;
    }

    const size = range.max - range.min;

    if (size <= 0) {
      return fallback;
    }

    return Math.max(size * 0.05, this.tickSize * 3, MIN_DISTANCE);
  }

  private getEditMinimumDistance(): number {
    return Math.max(this.tickSize, MIN_DISTANCE);
  }

  private getPriceScaleRange(): { min: number; max: number } | null {
    return getPriceRangeInContainer(this.series, this.container);
  }

  private getPriceDelta(fromY: number, toY: number): number {
    return getPriceDeltaFromCoordinates(this.series, fromY, toY);
  }

  private shiftTime(time: Time, offsetX: number): Time | null {
    return shiftTimeByPixels(this.chart, time, offsetX, this.series);
  }

  private getCurrentRiskRewardRatio(): number {
    if (this.entryPrice === null || this.stopPrice === null || this.targetPrice === null) {
      return this.defaultRiskRewardRatio;
    }

    const risk = Math.abs(this.entryPrice - this.stopPrice);

    if (!risk) {
      return this.defaultRiskRewardRatio;
    }

    return Math.abs(this.targetPrice - this.entryPrice) / risk;
  }

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

    const data = this.series.data() ?? [];
    let lastPrice: number | null = null;

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

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

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

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

    return lastPrice;
  }

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

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

    const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
    const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
    const entryY = getYCoordinateFromPrice(this.series, this.entryPrice);
    const stopY = getYCoordinateFromPrice(this.series, this.stopPrice);
    const targetY = getYCoordinateFromPrice(this.series, this.targetPrice);

    if (startX === null || endX === null || entryY === null || stopY === null || targetY === null) {
      return null;
    }

    const start = Number(startX);
    const end = Number(endX);
    const entry = Number(entryY);
    const stop = Number(stopY);
    const target = Number(targetY);

    return {
      startX: start,
      endX: end,
      leftX: Math.min(start, end),
      rightX: Math.max(start, end),
      entryY: entry,
      stopY: stop,
      targetY: target,
      entryPrice: this.entryPrice,
      stopPrice: this.stopPrice,
      targetPrice: this.targetPrice,
      profitTop: Math.min(target, entry),
      profitBottom: Math.max(target, entry),
      lossTop: Math.min(stop, entry),
      lossBottom: Math.max(stop, entry),
    };
  }

  private getTargetText(geometry: SliderGeometry): string {
    const diff = Math.abs(geometry.targetPrice - geometry.entryPrice);
    const percent = geometry.entryPrice !== 0 ? (diff / Math.abs(geometry.entryPrice)) * 100 : 0;
    const ticks = this.tickSize > 0 ? diff / this.tickSize : 0;

    const formattedDiff = formatPrice(diff) ?? '0';
    const formattedTicks = formatPrice(ticks) ?? '0';
    const formattedAmount = formatPrice(this.amount) ?? '0';

    return `${t('Target')}: ${formattedDiff} (${formatPercent(percent)}) ${formattedTicks}, ${t('Amount')}: ${formattedAmount}`;
  }

  private getCenterText(qty: number, pnl: number): string {
    const formattedQty = formatPrice(qty) ?? '0';
    const formattedRatio = formatPrice(this.getCurrentRiskRewardRatio()) ?? '0';

    return `${t('Open P&L')}: ${formatSignedNumber(pnl)}, ${t('Qty')}: ${formattedQty}\n${t('Risk/Reward Ratio')}: ${formattedRatio}`;
  }

  private getStopText(geometry: SliderGeometry): string {
    const stopDiff = Math.abs(geometry.stopPrice - geometry.entryPrice);
    const rewardDiff = Math.abs(geometry.targetPrice - geometry.entryPrice);
    const percent = geometry.entryPrice !== 0 ? (stopDiff / Math.abs(geometry.entryPrice)) * 100 : 0;
    const ticks = this.tickSize > 0 ? stopDiff / this.tickSize : 0;
    const qty = rewardDiff > 0 ? this.amount / rewardDiff : 0;
    const stopAmount = stopDiff * qty;

    const formattedStopDiff = formatPrice(stopDiff) ?? '0';
    const formattedTicks = formatPrice(ticks) ?? '0';
    const formattedStopAmount = formatPrice(stopAmount) ?? '0';

    return `${t('Stop')}: ${formattedStopDiff} (${formatPercent(percent)}) ${formattedTicks}, ${t('Amount')}: ${formattedStopAmount}`;
  }

  private getHandleTarget(point: Point): DragTarget {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    if (isNearPoint(point, geometry.startX, geometry.entryY, HIT_TOLERANCE)) {
      return 'entry';
    }

    if (isNearPoint(point, geometry.startX, geometry.targetY, HIT_TOLERANCE)) {
      return 'target';
    }

    if (isNearPoint(point, geometry.startX, geometry.stopY, HIT_TOLERANCE)) {
      return 'stop';
    }

    if (isNearPoint(point, geometry.endX, geometry.entryY, HIT_TOLERANCE)) {
      return 'end';
    }

    const minY = Math.min(geometry.targetY, geometry.stopY);
    const maxY = Math.max(geometry.targetY, geometry.stopY);

    const bounds: Bounds = {
      left: geometry.leftX,
      right: geometry.rightX,
      top: minY,
      bottom: maxY,
    };

    if (isPointInBounds(point, bounds)) {
      return 'body';
    }

    return null;
  }

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

    if (!geometry) {
      return false;
    }

    const minY = Math.min(geometry.targetY, geometry.stopY);
    const maxY = Math.max(geometry.targetY, geometry.stopY);

    const bounds: Bounds = {
      left: geometry.leftX,
      right: geometry.rightX,
      top: minY,
      bottom: maxY,
    };

    return isPointInBounds(point, bounds, HIT_TOLERANCE);
  }

  private getLocalPoint(event: PointerEvent): Point {
    return getPointerPointFromEvent(this.container, event);
  }
}



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

import { CustomPriceAxisView, CustomTimeAxisView } from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
  clamp,
  clampPointToContainer as clampPointToContainerInElement,
  getAnchorFromPoint,
  getContainerSize as getElementContainerSize,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  isPointInBounds,
} from '@core/Drawings/helpers';
import { AxisSegment } from '@core/Drawings/types';
import { updateViews } from '@core/Drawings/utils';

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

import { Defaults } from '@src/types/defaults';

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

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

import type { ISeriesDrawing } from '@core/Drawings/common';
import type { Anchor, AxisLabel, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel } from '@src/types';

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

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

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

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

interface TextParams {
  container: HTMLElement;
  formatObservable?: Observable<ChartOptionsModel>;
  removeSelf?: () => void;
  openSettings?: () => void;
}

const UI = {
  padding: 6,
  lineHeightMultiplier: 1.2,
};

let measureCanvas: HTMLCanvasElement | null = null;

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

  protected mode: TextMode = 'idle';

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

  private point: Anchor | null = null;
  protected settings: TextSettings = createDefaultSettings();

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

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

  constructor(chart: IChartApi, series: SeriesApi, params: TextParams) {
    super({ chart, series, container: params.container });
    const { formatObservable, removeSelf, openSettings } = params;

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

    this.paneView = new TextPaneView(this);

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

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

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

    this.series.attachPrimitive(this);
  }

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

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

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

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

    const nextState = state as Partial<TextState>;

    this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
    if (typeof nextState.isActive === 'boolean') {
      this.isActive.next(nextState.isActive);
    }
    this.mode = nextState.mode ?? this.mode;
    this.point = nextState.point ?? this.point;

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

    this.render();
  }

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

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

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

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

  public timeAxisViews() {
    return [this.timeAxisView];
  }

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      ...geometry,
      ...this.settings,
      showHandles: this.isActive.value,
    };
  }

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

    if (!this.containsPoint({ x, y })) {
      return null;
    }

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

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

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

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

    const { colors } = getThemeStore();

    return {
      coordinate,
      text: formatDate(
        this.point.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 (kind !== 'main' || !this.isActive.value || !this.point) {
      return null;
    }

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

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

    const { colors } = getThemeStore();

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

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

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

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

    const point = this.getEventPoint(event);

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

      this.setPoint(point);
      return;
    }

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

    const containsPoint = this.containsPoint(point);

    if (!this.isActive.value) {
      if (!containsPoint) {
        return;
      }

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

      this.isActive.next(true);
      this.render();
      return;
    }

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

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

    this.isActive.next(false);
    this.render();
  };

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

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

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

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

    this.openSettings?.();
  };

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

    event.preventDefault();

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

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

    if (this.mode === 'dragging') {
      this.finishDragging();
    }
  };

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

    if (!anchor) {
      return;
    }

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

    this.render();
  }

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

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

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

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

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

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

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

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

    const offsetX = eventPoint.x - dragStartPoint.x;
    const offsetY = eventPoint.y - dragStartPoint.y;

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

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

    if (!anchor) {
      return;
    }

    this.point = anchor;
  }

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

  protected getGeometry(): TextGeometry | null {
    if (!this.point) {
      return null;
    }

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

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

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

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

    const lines = getTextLines(this.settings.text);
    const font = getFont(this.settings);
    const lineHeight = Math.round(this.settings.fontSize * UI.lineHeightMultiplier);
    const measured = measureTextBlock(lines, font, lineHeight);

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

    const left = clamp(anchorPoint.x, 0, Math.max(0, containerWidth - width));
    const top = clamp(anchorPoint.y, 0, Math.max(0, containerHeight - height));
    const right = left + width;
    const bottom = top + height;

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

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

    if (!geometry) {
      return false;
    }

    return isPointInBounds(point, geometry, 2);
  }

  private getContainerSize(): { width: number; height: number } {
    return getElementContainerSize(this.container);
  }

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

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

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

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

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

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

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

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

  context.font = font;

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

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

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

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



import { IChartApi, IPrimitivePaneView, PrimitiveHoveredItem } from 'lightweight-charts';
import { Observable } from 'rxjs';

import { CustomPriceAxisPaneView, CustomTimeAxisPaneView } from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
  clamp,
  clampPointToContainer as clampPointToContainerInElement,
  getAnchorFromPoint,
  getContainerSize as getElementContainerSize,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  isNearPoint,
} from '@core/Drawings/helpers';
import { AxisLabel } from '@core/Drawings/types';
import { updateViews } from '@core/Drawings/utils';

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

import { TraectoryPaneView } from './paneView';

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

import type { ISeriesDrawing } from '@core/Drawings/common';
import type { Anchor, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab } from '@src/types';

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

interface TraectoryParams {
  container: HTMLElement;
  formatObservable?: Observable<ChartOptionsModel>;
  removeSelf?: () => void;
  openSettings?: () => void;
}

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

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

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

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

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

  protected settings: TraectorySettings = createDefaultSettings();

  protected mode: TraectoryMode = 'idle';

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

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

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

  constructor(chart: IChartApi, series: SeriesApi, params: TraectoryParams) {
    super({ chart, series, container: params.container });
    const { removeSelf, openSettings } = params;

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

    this.paneView = new TraectoryPaneView(this);

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

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

    this.series.attachPrimitive(this);
  }

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

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

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

    const nextState = state as Partial<TraectoryState>;

    this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
    if (typeof nextState.isActive === 'boolean') {
      this.isActive.next(nextState.isActive);
    }
    this.mode = nextState.mode ?? this.mode;

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

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

    this.render();
  }

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

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

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

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

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

  public timeAxisViews() {
    return [];
  }

  public priceAxisViews() {
    return [];
  }

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

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

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

    const point = { x, y };
    const pointIndex = this.getPointIndexAt(point);

    if (pointIndex !== null) {
      return {
        cursorStyle: 'move',
        externalId: 'traectory',
        zOrder: 'top',
      };
    }

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

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

  protected getTimeAxisSegments(): AxisSegment[] {
    if (!this.isActive.value) {
      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.isActive.value) {
      return [];
    }

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

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

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

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

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

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

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

      this.appendPoint(point);
      this.finishDrawing();
      return;
    }

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

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

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

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

    this.openSettings?.();
  };

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

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

    this.finishDrawing();
  };

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

    const point = this.getEventPoint(event);

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

      this.startDrawing(point);
      return;
    }

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

      if (event.detail > 1) {
        return;
      }

      this.appendPoint(point);
      return;
    }

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

    const pointIndex = this.getPointIndexAt(point);
    const isInsideTraectory = pointIndex !== null || this.isPointNearTraectory(point);

    if (!this.isActive.value) {
      if (!isInsideTraectory) {
        return;
      }

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

      this.isActive.next(true);
      this.render();
      return;
    }

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

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

    if (!this.isPointNearTraectory(point)) {
      this.isActive.next(false);
      this.render();
      return;
    }

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

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

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

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

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

    if (this.mode === 'dragging-point') {
      event.preventDefault();
      this.movePoint(point);
      this.render();
      return;
    }

    if (this.mode === 'dragging-body') {
      event.preventDefault();
      this.moveBody(point);
      this.render();
    }
  };

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

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

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

    if (!anchor) {
      return;
    }

    this.points = [anchor];
    this.previewAnchor = anchor;
    this.isActive.next(true);
    this.mode = 'drawing';

    this.render();
  }

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

    if (!anchor) {
      return;
    }

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

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

    this.points = [...this.points, anchor];
    this.previewAnchor = anchor;
    this.render();
  }

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

    if (!anchor) {
      return;
    }

    this.previewAnchor = anchor;
    this.render();
  }

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

      this.resetToIdle();
      return;
    }

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

    this.render();
  }

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

    this.dragPointerId = pointerId;
    this.dragStartPoint = point;
    this.dragPointIndex = pointIndex;
    this.dragGeometrySnapshot = this.getGeometry();

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

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

    this.dragPointerId = pointerId;
    this.dragStartPoint = point;
    this.dragPointIndex = null;
    this.dragGeometrySnapshot = this.getGeometry();

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

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

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

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

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

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

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

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

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

    const nextPoint = this.clampPointToContainer(point);
    const nextPoints = [...geometry.points];

    nextPoints[pointIndex] = nextPoint;

    this.setAnchorsFromPoints(nextPoints);
  }

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

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

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

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

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

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

    this.setAnchorsFromPoints(nextPoints);
  }

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

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

      if (!anchor) {
        return;
      }

      nextAnchors.push(anchor);
    }

    this.points = nextAnchors;
  }

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

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

    const { width, height } = this.getContainerSize();
    const screenPoints: Point[] = [];

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

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

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

    const left = Math.round(Math.min(...screenPoints.map((point) => point.x)));
    const right = Math.round(Math.max(...screenPoints.map((point) => point.x)));
    const top = Math.round(Math.min(...screenPoints.map((point) => point.y)));
    const bottom = Math.round(Math.max(...screenPoints.map((point) => point.y)));

    return {
      points: screenPoints,
      left,
      right,
      top,
      bottom,
    };
  }

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

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

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

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

    return {
      x: clamp(Math.round(Number(x)), 0, width),
      y: clamp(Math.round(Number(y)), 0, height),
    };
  }

  private getPointIndexAt(point: Point): number | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    const index = geometry.points.findIndex((item) => isNearPoint(point, item.x, item.y, POINT_HIT_TOLERANCE));

    return index >= 0 ? index : null;
  }

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

    if (!geometry) {
      return false;
    }

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

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

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

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

    return false;
  }

  private getDistanceToSegment(point: Point, startPoint: Point, endPoint: Point): number {
    const dx = endPoint.x - startPoint.x;
    const dy = endPoint.y - startPoint.y;

    if (dx === 0 && dy === 0) {
      return Math.hypot(point.x - startPoint.x, point.y - startPoint.y);
    }

    const t = Math.max(
      0,
      Math.min(1, ((point.x - startPoint.x) * dx + (point.y - startPoint.y) * dy) / (dx * dx + dy * dy)),
    );

    const projectionX = startPoint.x + t * dx;
    const projectionY = startPoint.y + t * dy;

    return Math.hypot(point.x - projectionX, point.y - projectionY);
  }

  private getContainerSize(): { width: number; height: number } {
    return getElementContainerSize(this.container);
  }

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



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

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
  getAnchorFromPoint,
  getPriceDelta as getPriceDeltaFromCoordinates,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  isNearPoint,
  shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';

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

import { TrendLinePaneView } from './paneView';

import {
  createDefaultSettings,
  getTrendLineSettingsTabs,
  TrendLineSettings,
  TrendLineStyle,
  TrendLineTextStyle,
} from './settings';

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

type TrendLineMode = 'idle' | 'drawing' | 'ready' | 'dragging-start' | 'dragging-end' | 'dragging-body';
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'start' | 'end';

interface TrendLineParams {
  container: HTMLElement;
  formatObservable?: Observable<ChartOptionsModel>;
  removeSelf?: () => void;
  openSettings?: () => void;
}

interface TrendLineState {
  hidden: boolean;
  isActive: boolean;
  mode: TrendLineMode;
  startAnchor: Anchor | null;
  endAnchor: Anchor | null;
  settings: TrendLineSettings;
}

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

export interface TrendLineRenderData extends TrendLineGeometry, TrendLineStyle, TrendLineTextStyle {
  showHandles: boolean;
}

const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;

export class TrendLine extends SeriesDrawingBase<TrendLineSettings> implements ISeriesDrawing {
  private removeSelf?: () => void;
  private openSettings?: () => void;

  protected settings: TrendLineSettings = createDefaultSettings();

  protected mode: TrendLineMode = 'idle';

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

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

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

  private readonly paneView: TrendLinePaneView;
  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: IChartApi,
    series: SeriesApi,
    { container, formatObservable, removeSelf, openSettings }: TrendLineParams,
  ) {
    super({ chart, series, container });
    this.removeSelf = removeSelf;
    this.openSettings = openSettings;

    this.paneView = new TrendLinePaneView(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 (formatObservable) {
      this.subscriptions.add(
        formatObservable.subscribe((format) => {
          this.displayFormat = format;
          this.render();
        }),
      );
    }

    this.series.attachPrimitive(this);
  }

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

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

  public setState(state: unknown): void {
    const nextState = state as Partial<TrendLineState>;

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

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

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

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

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

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

    this.render();
  }

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

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

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

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

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

  public timeAxisViews() {
    return [this.startTimeAxisView, this.endTimeAxisView];
  }

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      ...geometry,
      showHandles: this.isActive.value && !this.isLocked(),
      ...this.settings,
    };
  }

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

    const point = { x, y };

    if (this.getPointTarget(point)) {
      return {
        cursorStyle: this.isLocked() ? 'pointer' : 'move',
        externalId: 'trend-line',
        zOrder: 'top',
      };
    }

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

    return {
      cursorStyle: this.isLocked() ? 'pointer' : 'grab',
      externalId: 'trend-line',
      zOrder: 'top',
    };
  }

  protected getTimeAxisSegments(): AxisSegment[] {
    if (!this.isActive.value) {
      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.isActive.value) {
      return [];
    }

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

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

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

    const coordinate = this.getTimeCoordinate(kind);
    const text = this.getTimeText(kind);

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

    const { colors } = getThemeStore();

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

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

    const coordinate = this.getPriceCoordinate(kind);
    const text = this.getPriceText(kind);

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

    const { colors } = getThemeStore();

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

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

    const rect = this.container.getBoundingClientRect();
    const point = {
      x: event.clientX - rect.left,
      y: event.clientY - rect.top,
    };

    if (!this.getPointTarget(point) && !this.isPointNearLine(point)) {
      return;
    }

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

    this.openSettings?.();
  };

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

    const point = this.getEventPoint(event);

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

      this.startDrawing(point);
      return;
    }

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

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

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

    const pointTarget = this.getPointTarget(point);
    const isNearLine = this.isPointNearLine(point);
    const isDrawingHit = pointTarget !== null || isNearLine;

    if (this.isLocked()) {
      if (isDrawingHit && !this.isActive.value) {
        this.isActive.next(true);
        this.render();
      }

      if (!isDrawingHit && this.isActive.value) {
        this.isActive.next(false);
        this.render();
      }

      return;
    }

    if (!this.isActive.value) {
      if (!isDrawingHit) {
        return;
      }

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

      this.isActive.next(true);
      this.render();
      return;
    }

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

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

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

      this.startDragging('dragging-end', point, event.pointerId);
      return;
    }

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

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

    this.isActive.next(false);
    this.render();
  };

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

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

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

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

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

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

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

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

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

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

    if (!anchor) {
      return;
    }

    this.startAnchor = anchor;
    this.endAnchor = anchor;
    this.isActive.next(true);
    this.mode = 'drawing';

    this.render();
  }

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

    if (!anchor) {
      return;
    }

    this.endAnchor = anchor;
    this.render();
  }

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

    if (!geometry) {
      return;
    }

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

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

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

    this.render();
  }

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

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

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

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

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

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

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

    if (!anchor) {
      return;
    }

    if (this.mode === 'dragging-start') {
      this.startAnchor = anchor;
    }

    if (this.mode === 'dragging-end') {
      this.endAnchor = anchor;
    }
  }

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

    if (!snapshot?.startAnchor || !snapshot.endAnchor || !this.dragStartPoint) {
      return;
    }

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

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

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

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

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

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

  protected getGeometry(): TrendLineGeometry | null {
    if (!this.startAnchor || !this.endAnchor) {
      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.startAnchor.price);
    const endY = getYCoordinateFromPrice(this.series, this.endAnchor.price);

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

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

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

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

  private getPointTarget(point: Point): 'start' | 'end' | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    if (isNearPoint(point, geometry.startPoint.x, geometry.startPoint.y, 8)) {
      return 'start';
    }

    if (isNearPoint(point, geometry.endPoint.x, geometry.endPoint.y, 8)) {
      return 'end';
    }

    return null;
  }

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

    if (!geometry) {
      return false;
    }

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

  private getDistanceToSegment(point: Point, startPoint: Point, endPoint: Point): number {
    const dx = endPoint.x - startPoint.x;
    const dy = endPoint.y - startPoint.y;

    if (dx === 0 && dy === 0) {
      return Math.hypot(point.x - startPoint.x, point.y - startPoint.y);
    }

    const t = Math.max(
      0,
      Math.min(1, ((point.x - startPoint.x) * dx + (point.y - startPoint.y) * dy) / (dx * dx + dy * dy)),
    );

    const projectionX = startPoint.x + t * dx;
    const projectionY = startPoint.y + t * dy;

    return Math.hypot(point.x - projectionX, point.y - projectionY);
  }

  private getTimeCoordinate(kind: TimeLabelKind): number | null {
    const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;

    if (!anchor) {
      return null;
    }

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

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

  private getPriceCoordinate(kind: PriceLabelKind): number | null {
    const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;

    if (!anchor) {
      return null;
    }

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

    return coordinate === null ? null : Number(coordinate);
  }

  private getTimeText(kind: TimeLabelKind): string {
    const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;

    if (!anchor || typeof anchor.time !== 'number') {
      return '';
    }

    return formatDate(
      anchor.time as UTCTimestamp,
      this.displayFormat.dateFormat,
      this.displayFormat.timeFormat,
      this.displayFormat.showTime,
    );
  }

  private getPriceText(kind: PriceLabelKind): string {
    const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;

    if (!anchor) {
      return '';
    }

    return formatPrice(anchor.price) ?? '';
  }
}



import { IChartApi, IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';

import { Observable } from 'rxjs';

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
  clamp,
  clampPointToContainer as clampPointToContainerInElement,
  getAnchorFromPoint,
  getContainerSize as getElementContainerSize,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  isNearPoint,
  isPointInBounds,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';

import { SettingsTab } from '@src/types';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';

import { VolumeProfilePaneView } from './paneView';

import {
  createDefaultSettings,
  getVolumeProfileSettingsTabs,
  VolumeProfileSettings,
  VolumeProfileStyle,
} from './settings';

import type { ISeriesDrawing } from '@core/Drawings/common';
import type { Anchor, AxisLabel, AxisSegment, Bounds, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel } from '@src/types';

export type VolumeProfileKind = 'fixedRange' | 'visibleRange';

type VolumeProfileMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type DragTarget = 'body' | 'poc' | 'start' | 'end' | null;

interface VolumeProfileParams {
  container: HTMLElement;
  profileKind?: VolumeProfileKind;
  formatObservable?: Observable<ChartOptionsModel>;
  removeSelf?: () => void;
  openSettings?: () => void;
}

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;
  isActive: 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;
  showHandles: boolean;
}

const PROFILE_ROW_COUNT = 24;
const HANDLE_HIT_TOLERANCE = 8;
const BODY_HIT_TOLERANCE = 4;
const MIN_PROFILE_SIZE = 8;
const MAX_VISIBLE_RANGE_START_RATIO = 0.95;

export class VolumeProfile extends SeriesDrawingBase<VolumeProfileSettings> 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 displayFormat: ChartOptionsModel = {
    dateFormat: Defaults.dateFormat,
    timeFormat: Defaults.timeFormat,
    showTime: Defaults.showTime,
  };

  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: IChartApi,
    series: SeriesApi,
    { container, profileKind = 'fixedRange', formatObservable, removeSelf, openSettings }: VolumeProfileParams,
  ) {
    super({ chart, series, container });
    this.profileKind = profileKind;
    this.removeSelf = removeSelf;
    this.openSettings = openSettings;

    if (this.profileKind === 'visibleRange') {
      this.mode = 'ready';
      this.isActive.next(true);
    }

    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 (formatObservable) {
      this.subscriptions.add(
        formatObservable.subscribe((format) => {
          this.displayFormat = format;
          this.render();
        }),
      );
    }

    if (this.profileKind === 'visibleRange') {
      this.chart.timeScale().subscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);
      this.calculateProfile();
    }

    this.series.attachPrimitive(this);
  }

  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,
      isActive: this.isActive.value,
      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 (typeof nextState.isActive === 'boolean') {
      this.isActive.next(nextState.isActive);
    }

    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,
      showHandles: this.isActive.value || this.mode === 'drawing',
      ...this.settings,
    };
  }

  protected getTimeAxisSegments(): AxisSegment[] {
    if (this.profileKind === 'visibleRange' || !this.isActive.value) {
      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.profileKind === 'visibleRange' || !this.isActive.value) {
      return [];
    }

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

    return [
      {
        from: geometry.top,
        to: geometry.bottom,
        color: colors.axisMarkerAreaFill,
      },
    ];
  }

  public hitTest(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.isActive.value ? 'grab' : 'pointer',
      externalId: 'volume-profile',
      zOrder: 'top',
    };
  }

  protected getTimeAxisLabel(kind: string): AxisLabel | null {
    if (this.profileKind === 'visibleRange') {
      return null;
    }

    if (!this.isActive.value || (kind !== 'start' && kind !== 'end')) {
      return null;
    }

    const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;

    if (!anchor || typeof anchor.time !== 'number') {
      return null;
    }

    const coordinate = getXCoordinateFromTime(this.chart, anchor.time, this.series);

    if (coordinate === null) {
      return null;
    }

    const { colors } = getThemeStore();

    return {
      coordinate,
      text: formatDate(
        anchor.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.profileKind === 'visibleRange') {
      return null;
    }

    if (!this.isActive.value || (kind !== 'start' && kind !== 'end')) {
      return null;
    }

    // const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
    const price = kind === 'start' ? this.profileMinPrice : this.profileMaxPrice;

    if (!price) {
      return null;
    }

    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,
    };
  }

  private handleVisibleLogicalRangeChange = (): void => {
    if (this.profileKind !== 'visibleRange') {
      return;
    }

    this.calculateProfile();
    this.render();
  };

  protected handleDoubleClick = (event: MouseEvent): void => {
    if (this.hidden || this.mode !== 'ready' || !this.isActive.value) {
      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 {
    const dragTarget = this.getVisibleRangeDragTarget(point);

    if (!dragTarget) {
      this.isActive.next(false);
      this.render();
      return;
    }

    event.preventDefault();
    event.stopPropagation();

    this.isActive.next(true);

    if (dragTarget !== 'poc') {
      this.render();
      return;
    }

    this.activeDragTarget = 'poc';
    this.dragPointerId = event.pointerId;
    this.mode = 'dragging';

    this.hideCrosshair();
    this.render();
  }

  private handleFixedRangePointerDown(event: PointerEvent, point: Point): void {
    if (this.mode === 'idle') {
      event.preventDefault();
      event.stopPropagation();

      this.startDrawing(point);
      return;
    }

    if (this.mode === 'drawing') {
      event.preventDefault();
      event.stopPropagation();

      this.updateDrawing(point);
      this.finishDrawing();
      return;
    }

    if (this.mode !== 'ready') {
      return;
    }

    const dragTarget = this.getFixedRangeDragTarget(point);

    if (!this.isActive.value) {
      if (!dragTarget) {
        return;
      }

      event.preventDefault();
      event.stopPropagation();

      this.isActive.next(true);
      this.render();
      return;
    }

    if (!dragTarget) {
      this.isActive.next(false);
      this.render();
      return;
    }

    event.preventDefault();
    event.stopPropagation();

    this.startDragging(point, event.pointerId, dragTarget);
  }

  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(this.clampPointToContainer(point));

    if (!anchor) {
      return;
    }

    this.startAnchor = anchor;
    this.endAnchor = anchor;
    this.isActive.next(true);
    this.mode = 'drawing';

    this.calculateProfile();
    this.render();
  }

  private updateDrawing(point: Point): void {
    const anchor = this.createAnchor(this.clampPointToContainer(point));

    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.isActive.next(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 } = this.getContainerSize();

    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(this.clampPointToContainer(point));

    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 } = this.getContainerSize();

    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(this.clampPointToContainer(startPoint));
    const endAnchor = this.createAnchor(this.clampPointToContainer(endPoint));

    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 geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    const pocY = this.getPocY(geometry);

    if (pocY !== null && isNearPoint(point, geometry.left, pocY, HANDLE_HIT_TOLERANCE)) {
      return 'poc';
    }

    if (this.containsPoint(point)) {
      return 'body';
    }

    return null;
  }

  private getFixedRangeDragTarget(point: Point): Exclude<DragTarget, null> | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    if (isNearPoint(point, geometry.startPoint.x, geometry.startPoint.y, HANDLE_HIT_TOLERANCE)) {
      return 'start';
    }

    if (isNearPoint(point, geometry.endPoint.x, geometry.endPoint.y, HANDLE_HIT_TOLERANCE)) {
      return 'end';
    }

    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 } = this.getContainerSize();

    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 } = this.getContainerSize();

    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);
  }

  private getContainerSize(): { width: number; height: number } {
    return getElementContainerSize(this.container);
  }

  private clampPointToContainer(point: Point): Point {
    return clampPointToContainerInElement(point, this.container);
  }
}

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 { IChartApi, ISeriesApi, SeriesType } from 'lightweight-charts';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';

import { EventManager } from '@core';
import { DOMModel } from '@core/DOMModel';
import { Drawing } from '@core/Drawings';
import { Hotkeys, Keys } from '@core/Hotkeys';

import { EntitySettingsModal } from '@src/components/EntitySettingsModal';
import { drawingLabelById, drawingsMap, DrawingsNames } from '@src/constants';
import { ModalRenderer } from '@src/core/ModalRenderer';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ActiveDrawingTool } from '@src/types';

interface DrawingsManagerParams {
  eventManager: EventManager;
  mainSeries$: Observable<SeriesStrategies | null>;
  lwcChart: IChartApi;
  DOM: DOMModel;
  container: HTMLElement;
  modalRenderer: ModalRenderer;
  paneId: number;
  hotkeys: Hotkeys;
}

export interface DrawingSnapshotItem {
  id: string;
  drawingName: DrawingsNames;
  state: unknown;
  isLocked?: boolean;
}

interface CreateDrawingOptions {
  id?: string;
  state?: unknown;
  isLocked?: boolean;
  shouldUpdateDrawingsList?: boolean;
}

export type DrawingsManagerSnapshot = DrawingSnapshotItem[];

export class DrawingsManager {
  private eventManager: EventManager;
  private lwcChart: IChartApi;
  private DOM: DOMModel;
  private container: HTMLElement;
  private modalRenderer: ModalRenderer;

  private mainSeries: SeriesStrategies | null = null;
  private subscriptions = new Subscription();
  private drawings$ = new BehaviorSubject<Drawing[]>([]);
  private selectedDrawing$ = new BehaviorSubject<Drawing | null>(null);
  private activeTool$ = new BehaviorSubject<ActiveDrawingTool>('crosshair');
  private endlessMode$ = new BehaviorSubject(false);
  private recreateScheduled = false;
  private pendingSnapshot: DrawingsManagerSnapshot | null = null;
  private paneId: number;

  private hotkeys: Hotkeys;
  private copyPasteBuffer: DrawingSnapshotItem | null = null;
  private escapeUnregisterHash: string | null = null;

  constructor({
    eventManager,
    mainSeries$,
    lwcChart,
    DOM,
    container,
    modalRenderer,
    paneId,
    hotkeys,
  }: DrawingsManagerParams) {
    this.DOM = DOM;
    this.eventManager = eventManager;
    this.paneId = paneId;
    this.lwcChart = lwcChart;
    this.container = container;
    this.modalRenderer = modalRenderer;
    this.hotkeys = hotkeys;

    this.subscriptions.add(
      mainSeries$.subscribe((series) => {
        if (!series) {
          return;
        }

        this.mainSeries = series;
        this.drawings$.value.forEach((drawing) => drawing.rebind(series));

        if (this.pendingSnapshot) {
          const snapshot = this.pendingSnapshot;
          this.pendingSnapshot = null;
          this.setSnapshot(snapshot);
        }
      }),
    );

    window.addEventListener('pointerup', this.handlePointerUp);
    this.container.addEventListener('click', this.handleClick);
    this.container.addEventListener('pointerdown', this.handlePointerDown);

    // todo: implement ctrl+v
    // hotkeys.register({
    //   keys: [Keys.control, Keys.v],
    //   callback: () => {
    //     const bufferWithAppliedPosition = {
    //       ...this.copyPasteBuffer,
    //       state: {
    //         ...this.copyPasteBuffer?.state,
    //         startAnchor: {
    //           price: 73.36210252637723,
    //           time: 1783679170
    //         }
    //       }
    //     }
    //
    //     this.setSnapshot([
    //       ...this.getSnapshot(),
    //       bufferWithAppliedPosition
    //     ])
    //     // hotkeys.unregister({ // todo: unregister all else ctrl+c's
    //     //   keys: [Keys.control, Keys.c]
    //     // })
    //   }
    // })
  }

  private handlePointerDown = (): void => {
    this.DOM.refreshEntities();
    queueMicrotask(this.updateSelectedDrawing);
  };

  private handlePointerUp = (): void => {
    this.DOM.refreshEntities();
    this.updateActiveTool();
    queueMicrotask(this.updateSelectedDrawing);
  };

  private handleClick = (): void => {
    this.DOM.refreshEntities();
    this.updateActiveTool();
    this.updateSelectedDrawing();
  };

  private updateSelectedDrawing = (): void => {
    const selectedDrawing =
      this.drawings$.value.find((drawing) => drawing.isSelected() && !drawing.isCreationPending()) ?? null;

    if (this.selectedDrawing$.value === selectedDrawing) {
      return;
    }

    this.selectedDrawing$.next(selectedDrawing);
  };

  private updateActiveTool = (): void => {
    const hasPendingDrawing = this.drawings$.value.some((drawing) => drawing.isCreationPending());

    if (hasPendingDrawing) {
      return;
    }

    const activeTool = this.activeTool$.value;
    const isSingleInstanceTool = activeTool !== 'crosshair' && drawingsMap[activeTool]?.singleInstance;

    if (activeTool !== 'crosshair' && this.endlessMode$.value && !isSingleInstanceTool) {
      if (this.recreateScheduled) {
        return;
      }

      this.recreateScheduled = true;

      queueMicrotask(() => {
        this.recreateScheduled = false;

        const currentTool = this.activeTool$.value;
        const hasPendingAfterTick = this.drawings$.value.some((drawing) => drawing.isCreationPending());

        if (currentTool === 'crosshair') {
          return;
        }

        if (!this.endlessMode$.value) {
          return;
        }

        if (drawingsMap[currentTool]?.singleInstance) {
          return;
        }

        if (hasPendingAfterTick) {
          return;
        }

        this.createDrawing(currentTool);
      });

      return;
    }

    this.activeTool$.next('crosshair');
  };

  private removeDrawing = (id: string): void => {
    const drawing = this.drawings$.value.find((item) => item.id === id);

    if (!drawing) {
      return;
    }

    this.removeDrawings([drawing]);
  };

  private removeDrawingsByName(name: DrawingsNames, shouldUpdateTool = true): void {
    const drawingsToRemove = this.drawings$.value.filter((drawing) => drawing.getDrawingName() === name);

    this.removeDrawings(drawingsToRemove, shouldUpdateTool);
  }

  private removePendingDrawings(shouldUpdateTool = true): void {
    const drawingsToRemove = this.drawings$.value.filter((drawing) => drawing.isCreationPending());

    this.removeDrawings(drawingsToRemove, shouldUpdateTool);
  }

  private removeDrawings(drawingsToRemove: Drawing[], shouldUpdateTool = true): void {
    if (!drawingsToRemove.length) {
      return;
    }

    const selectedDrawing = this.selectedDrawing$.value;

    if (selectedDrawing && drawingsToRemove.includes(selectedDrawing)) {
      this.selectedDrawing$.next(null);
    }

    drawingsToRemove.forEach((drawing) => {
      drawing.destroy();
      this.DOM.removeEntity(drawing);
    });

    this.drawings$.next(this.drawings$.value.filter((drawing) => !drawingsToRemove.includes(drawing)));

    if (shouldUpdateTool) {
      this.updateActiveTool();
    }

    this.updateSelectedDrawing();
    this.DOM.refreshEntities();
  }

  public addDrawingForce = (name: DrawingsNames): Promise<void> => {
    this.removePendingDrawings(false);

    if (drawingsMap[name].singleInstance) {
      this.removeDrawingsByName(name, false);
    }

    this.activeTool$.next(name);
    const drawing = this.createDrawing(name);
    this.DOM.refreshEntities();

    return drawing.waitForCreation();
  };

  private createDrawing(name: DrawingsNames, options: CreateDrawingOptions = {}): Drawing {
    if (!this.mainSeries) {
      throw new Error('[Drawings] main series is not defined');
    }

    const { id, state, isLocked = false, shouldUpdateDrawingsList = true } = options;

    const config = drawingsMap[name];
    const drawingId = id ?? crypto.randomUUID();

    let createdDrawing: Drawing | null = null;

    const construct = (chart: IChartApi, series: ISeriesApi<SeriesType>) => {
      const paneElement = series.getPane().getHTMLElement();

      if (!paneElement) {
        throw new Error('[Drawing Manager]: cannot place drawing, there is no pane');
      }

      const cells = paneElement.querySelectorAll<HTMLTableCellElement>(':scope > td');
      const canvasElement = cells.item(1);

      return config.construct({
        chart,
        series,
        eventManager: this.eventManager,
        container: canvasElement,
        removeSelf: () => this.removeDrawing(drawingId),
        openSettings: () => {
          if (createdDrawing) {
            this.openSettings(createdDrawing);
          }
        },
      });
    };

    const drawingFactory = (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) =>
      new Drawing({
        lwcChart: this.lwcChart,
        mainSeries: this.mainSeries as SeriesStrategies,
        id: drawingId,
        drawingName: name,
        name: drawingLabelById()[name],
        onDelete: this.removeDrawing,
        zIndex,
        moveDown,
        moveUp,
        construct,
        paneId: this.paneId,
        hotkeys: this.hotkeys,
        setCopyPasteBuffer: (copyPasteBuffer) => {
          this.copyPasteBuffer = copyPasteBuffer;
        },
        resetActiveTool: () => {
          this.activeTool$.next('crosshair');
        },
      });

    const entity = this.DOM.setEntity<Drawing>(drawingFactory);
    createdDrawing = entity;

    if (state !== undefined) {
      entity.setState(state);
    }

    entity.setLocked(isLocked);

    if (shouldUpdateDrawingsList) {
      this.drawings$.next([...this.drawings$.value, entity]);
    }

    return entity;
  }

  public getSnapshot(): DrawingsManagerSnapshot {
    return this.drawings$.value
      .filter((drawing) => !drawing.isCreationPending())
      .map((drawing) => ({
        id: drawing.id,
        drawingName: drawing.getDrawingName(),
        state: drawing.getState(),
        isLocked: drawing.isLocked(),
      }));
  }

  public setSnapshot(snapshot: DrawingsManagerSnapshot): void {
    if (!Array.isArray(snapshot)) {
      return;
    }

    if (!this.mainSeries) {
      this.pendingSnapshot = snapshot;
      return;
    }

    this.removeDrawings(this.drawings$.value, false);

    const restoredDrawings = snapshot.reduce<Drawing[]>((drawings, item) => {
      if (!drawingsMap[item.drawingName]) {
        return drawings;
      }

      drawings.push(
        this.createDrawing(item.drawingName, {
          id: item.id,
          state: item.state,
          isLocked: item.isLocked,
          shouldUpdateDrawingsList: false,
        }),
      );

      return drawings;
    }, []);

    this.drawings$.next(restoredDrawings);
    this.activeTool$.next('crosshair');
    this.updateSelectedDrawing();
    this.DOM.refreshEntities();
  }

  public setEndlessDrawingMode = (value: boolean): void => {
    if (value) {
      this.escapeUnregisterHash = this.hotkeys.register({
        keys: [Keys.escape],
        callback: () => {
          this.setEndlessDrawingMode(false);
        },
      });
    } else {
      this.hotkeys.unregister({
        keys: [Keys.escape],
        hash: this.escapeUnregisterHash,
      });
      this.escapeUnregisterHash = null;
    }
    this.endlessMode$.next(value);
  };

  public isEndlessDrawingsMode(): Observable<boolean> {
    return this.endlessMode$.asObservable();
  }

  public getActiveTool(): Observable<ActiveDrawingTool> {
    return this.activeTool$.asObservable();
  }

  public activateCrosshair(): void {
    this.removePendingDrawings(false);
    this.activeTool$.next('crosshair');
    this.DOM.refreshEntities();
  }

  public entities(): Observable<Drawing[]> {
    return this.drawings$.asObservable();
  }

  public selectedDrawing(): Observable<Drawing | null> {
    return this.selectedDrawing$.asObservable();
  }

  public openSelectedDrawingSettings(): void {
    const drawing = this.selectedDrawing$.value;

    if (!drawing) {
      return;
    }

    this.openSettings(drawing);
  }

  public deleteSelectedDrawing(): void {
    const drawing = this.selectedDrawing$.value;

    if (!drawing) {
      return;
    }

    this.removeDrawing(drawing.id);
  }

  public toggleSelectedDrawingLock(): void {
    const drawing = this.selectedDrawing$.value;

    if (!drawing) {
      return;
    }

    drawing.setLocked(!drawing.isLocked());
  }

  private openSettings = (drawing: Drawing): void => {
    const tabs = drawing.getSettingsTabs();

    if (!tabs.length || tabs.every((tab) => tab.fields.length === 0)) {
      return;
    }

    let settings = drawing.getSettings();

    this.modalRenderer.renderComponent(
      <EntitySettingsModal
        tabs={tabs}
        values={settings}
        onChange={(nextSettings) => {
          settings = nextSettings;
        }}
        initialTabKey={tabs[0]?.key}
      />,
      {
        size: 'sm',
        title: drawing.name,
        onSave: () => drawing.updateSettings(settings),
      },
    );
  };

  public getDrawings(): Drawing[] {
    return this.drawings$.value;
  }

  public hideAll(): void {
    this.selectedDrawing$.next(null);
    this.drawings$.value.forEach((drawing) => drawing.hide());
    this.DOM.refreshEntities();
  }

  public destroy(): void {
    this.hotkeys.unregister({
      keys: [Keys.escape],
      hash: this.escapeUnregisterHash,
    });
    window.removeEventListener('pointerup', this.handlePointerUp);
    this.container.removeEventListener('click', this.handleClick);
    this.container.removeEventListener('pointerdown', this.handlePointerDown);

    this.drawings$.value.forEach((drawing) => drawing.destroy());

    this.subscriptions.unsubscribe();
    this.drawings$.complete();
    this.selectedDrawing$.complete();
    this.activeTool$.complete();
    this.endlessMode$.complete();
  }
}


import { IChartApi, ISeriesApi, SeriesType } from 'lightweight-charts';

import { Subscription } from 'rxjs';

import { DOMObject, DOMObjectParams } from '@core/DOMObject';
import { ISeriesDrawing } from '@core/Drawings/common';
import { DrawingSnapshotItem } from '@core/DrawingsManager';
import { Hotkeys, Keys } from '@core/Hotkeys';

import { DrawingsNames } from '@src/constants';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { SettingsTab, SettingsValues } from '@src/types/settings';

type IDrawing = DOMObject;

interface DrawingParams extends DOMObjectParams {
  drawingName: DrawingsNames;
  lwcChart: IChartApi;
  mainSeries: SeriesStrategies;
  onDelete: (id: string) => void;
  construct: (chart: IChartApi, series: ISeriesApi<SeriesType>) => ISeriesDrawing;
  hotkeys: Hotkeys;
  setCopyPasteBuffer: (copiedObject: DrawingSnapshotItem) => void;
  resetActiveTool: () => void;
}

export class Drawing extends DOMObject implements IDrawing {
  private lwcDrawing: ISeriesDrawing;
  private mainSeries: SeriesStrategies;
  private drawingName: DrawingsNames;
  private hotkeys: Hotkeys;

  private escapeUnregisterHash: string | null = null;
  private deleteUnregisterHash: string | null = null;
  private copyUnregisterHash: string | null = null;

  constructor({
    lwcChart,
    name,
    mainSeries,
    drawingName,
    id,
    onDelete,
    zIndex,
    moveUp,
    moveDown,
    construct,
    paneId,
    hotkeys,
    setCopyPasteBuffer,
    resetActiveTool,
  }: DrawingParams) {
    super({ id, name, zIndex, onDelete, moveUp, moveDown, paneId });

    this.hotkeys = hotkeys;
    this.lwcDrawing = construct(lwcChart, mainSeries);
    this.onDelete = onDelete;

    this.escapeUnregisterHash = hotkeys.register({
      keys: [Keys.escape],
      callback: () => {
        this.delete();
        resetActiveTool();
      },
    });

    this.lwcDrawing.subscribeIsSelected((isSelected) => {
      if (isSelected) {
        this.deleteUnregisterHash = hotkeys.register({
          keys: [Keys.delete],
          callback: () => {
            this.delete();
          },
        });

        this.copyUnregisterHash = hotkeys.register({
          keys: [Keys.control, Keys.c],
          callback: () => {
            if (!this.isCreationPending()) {
              const copiedObject = {
                id: this.id,
                drawingName: this.getDrawingName(),
                state: this.getState(),
                isLocked: this.isLocked(),
              };

              setCopyPasteBuffer(copiedObject);
            }
          },
        });
      } else {
        hotkeys.unregister({
          keys: [Keys.delete],
          hash: this.deleteUnregisterHash,
        });

        hotkeys.unregister({
          keys: [Keys.control, Keys.c],
          hash: this.copyUnregisterHash,
        });
      }
    });

    this.mainSeries = mainSeries;
    this.drawingName = drawingName;

    this.afterCreation(() => {
      hotkeys.unregister({
        keys: [Keys.escape],
        hash: this.escapeUnregisterHash,
      });
    });
  }

  public delete(): void {
    this.destroy();
    super.delete();
  }

  public getDrawingName(): DrawingsNames {
    return this.drawingName;
  }

  public getLwcDrawing(): ISeriesDrawing {
    return this.lwcDrawing;
  }

  public show(): void {
    this.lwcDrawing.show();
    super.show();
  }

  public hide(): void {
    this.lwcDrawing.hide();
    super.hide();
  }

  public rebind = (nextMainSeries: SeriesStrategies): void => {
    this.lwcDrawing.rebind(nextMainSeries);
    this.mainSeries = nextMainSeries;
  };

  public isCreationPending(): boolean {
    return this.lwcDrawing.isCreationPending();
  }

  public subscribeIsLocked(callback: (isLocked: boolean) => void): Subscription {
    return this.lwcDrawing.subscribeIsLocked(callback);
  }

  public isLocked(): boolean {
    return this.lwcDrawing.isLocked();
  }

  public isSelected(): boolean {
    return this.lwcDrawing.isSelected();
  }

  public setLocked(isLocked: boolean): void {
    this.lwcDrawing.setLocked(isLocked);
  }

  public async waitForCreation(): Promise<void> {
    return this.lwcDrawing.waitTillReady();
  }

  public shouldShowInObjectTree(): boolean {
    return this.lwcDrawing.shouldShowInObjectTree();
  }

  public getState(): unknown {
    return this.lwcDrawing.getState();
  }

  public setState(state: unknown): void {
    this.lwcDrawing.setState(state);
  }

  public getSettings(): SettingsValues {
    return this.lwcDrawing.getSettings();
  }

  public updateSettings(settings: SettingsValues): void {
    this.lwcDrawing.updateSettings(settings);
  }

  public getSettingsTabs(): SettingsTab[] {
    return this.lwcDrawing.getSettingsTabs();
  }

  public hasSettings(): boolean {
    return this.getSettingsTabs().some((tab) => tab.fields.length > 0);
  }

  public destroy(): void {
    this.hotkeys.unregister({
      keys: [Keys.delete],
      hash: this.deleteUnregisterHash,
    });

    this.hotkeys.unregister({
      keys: [Keys.escape],
      hash: this.escapeUnregisterHash,
    });

    this.hotkeys.unregister({
      keys: [Keys.control, Keys.c],
      hash: this.copyUnregisterHash,
    });

    this.mainSeries.detachPrimitive(this.lwcDrawing);
    this.lwcDrawing.destroy();
  }

  private async afterCreation(callback: () => void): Promise<void> {
    await this.lwcDrawing.waitTillReady();
    callback();
  }
}