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


import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';

import { Rectangle } from './rectangle';

const UI = {
  borderWidth: 1,
  textOffset: 4,
  textLineHeightMultiplier: 1.2,
};

export class RectanglePaneRenderer implements IPrimitivePaneRenderer {
  private readonly rectangle: Rectangle;

  constructor(rectangle: Rectangle) {
    this.rectangle = rectangle;
  }

  public draw(target: CanvasRenderingTarget2D): void {
    const data = this.rectangle.getRenderData();

    if (!data) {
      return;
    }

    target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
      const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
      const left = data.left * horizontalPixelRatio;
      const right = data.right * horizontalPixelRatio;
      const top = data.top * verticalPixelRatio;
      const bottom = data.bottom * verticalPixelRatio;

      context.save();

      context.fillStyle = data.fillColor;
      context.fillRect(left, top, right - left, bottom - top);

      context.lineWidth = UI.borderWidth * pixelRatio;
      context.strokeStyle = data.borderColor;
      context.strokeRect(left, top, right - left, bottom - top);

      drawRectangleText(context, {
        left,
        top,
        text: data.text,
        fontSize: data.fontSize,
        isBold: data.isBold,
        isItalic: data.isItalic,
        textColor: data.textColor,
        pixelRatio,
      });

      context.restore();
    });
  }
}

function drawRectangleText(
  context: CanvasRenderingContext2D,
  params: {
    left: number;
    top: number;
    text: string;
    fontSize: number;
    isBold: boolean;
    isItalic: boolean;
    textColor: string;
    pixelRatio: number;
  },
): void {
  const { left, top, text, fontSize, isBold, isItalic, textColor, pixelRatio } = params;

  if (!text.trim()) {
    return;
  }

  const lines = text.split('\n');
  const safeFontSize = Math.max(1, fontSize);
  const fontSizePx = safeFontSize * pixelRatio;
  const lineHeight = safeFontSize * UI.textLineHeightMultiplier * pixelRatio;
  const fontWeight = isBold ? '700 ' : '';
  const fontStyle = isItalic ? 'italic ' : '';
  const textOffset = UI.textOffset * pixelRatio;

  const blockHeight = lines.length * lineHeight;
  const firstLineY = top - textOffset - blockHeight + lineHeight / 2;

  context.save();

  context.font = `${fontStyle}${fontWeight}${fontSizePx}px Inter, sans-serif`;
  context.fillStyle = textColor;
  context.textAlign = 'left';
  context.textBaseline = 'middle';

  lines.forEach((line, index) => {
    context.fillText(line, left, firstLineY + index * lineHeight);
  });

  context.restore();
}



import { IPrimitivePaneRenderer, IPrimitivePaneView, PrimitivePaneViewZOrder } from 'lightweight-charts';

import { RectanglePaneRenderer } from './paneRenderer';
import { Rectangle } from './rectangle';

export class RectanglePaneView implements IPrimitivePaneView {
  private readonly rectangle: Rectangle;

  constructor(rectangle: Rectangle) {
    this.rectangle = rectangle;
  }

  public update(): void {}

  public renderer(): IPrimitivePaneRenderer {
    return new RectanglePaneRenderer(this.rectangle);
  }

  public zOrder(): PrimitivePaneViewZOrder {
    return 'top';
  }
}



import { clamp } from 'lodash-es';

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

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 { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
import type { IPrimitivePaneView, PrimitiveHoveredItem, 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';

type RectangleParams = BaseDrawingParams;

interface RectangleState {
  hidden: 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;
}

export type RectangleRenderData = RectangleGeometry & RectangleStyle & RectangleTextStyle;

const BODY_HIT_TOLERANCE = 6;
const MIN_RECTANGLE_SIZE = 6;

export class Rectangle extends SeriesDrawingBase<RectangleSettings, RectangleHandleKey> 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,
    series,
    container,
    interaction,
    formatObservable,
    removeSelf,
    openSettings,
    initialEvent,
  }: RectangleParams) {
    super({ chart, series, container, interaction });

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

    if (initialEvent && initialEvent.sourceEvent) {
      const point = this.getEventPoint(initialEvent.sourceEvent);
      this.startDrawing(point);
    }
  }

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

  public getState(): RectangleState {
    return {
      hidden: this.hidden,
      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 ('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,
      ...this.settings,
    };
  }

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

    if (!geometry) {
      return [];
    }

    const { left, right, top, bottom } = geometry;
    const centerX = (left + right) / 2;
    const centerY = (top + bottom) / 2;

    return [
      { id: 'w', x: left, y: centerY, shape: 'rounded' },
      { id: 'sw', x: left, y: bottom, shape: 'circle' },
      { id: 's', x: centerX, y: bottom, shape: 'rounded' },
      { id: 'se', x: right, y: bottom, shape: 'circle' },
      { id: 'e', x: right, y: centerY, shape: 'rounded' },
      { id: 'ne', x: right, y: top, shape: 'circle' },
      { id: 'n', x: centerX, y: top, shape: 'rounded' },
      { id: 'nw', x: left, y: top, shape: 'circle' },
    ];
  }

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

    const point = { x, y };

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

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

    const handleTarget = this.getDrawingHandleAtPoint(point)?.id;

    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.isSelected() && !this.isCreationPending()) {
      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.isSelected() && !this.isCreationPending()) {
      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.isSelected() && !this.isCreationPending()) || (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.isSelected() && !this.isCreationPending()) || (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 point = this.getEventPoint(event as PointerEvent);

    if (!this.containsPoint(point) && !this.getDrawingHandleAtPoint(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.isSelected()) {
      if (!this.containsPoint(point)) {
        return;
      }

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

      this.select();
      return;
    }

    const dragTarget = this.getDragTarget(point);

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

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

    this.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.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.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.getDrawingHandleAtPoint(point)?.id;

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

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

  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 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 { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { SettingField, SettingsTab, SettingsValues } from '@src/types';

export interface RectangleStyle {
  borderColor: string;
  fillColor: string;
}

export interface RectangleTextStyle {
  text: string;
  fontSize: number;
  isBold: boolean;
  isItalic: boolean;
  textColor: string;
}

export type RectangleSettings = RectangleStyle & RectangleTextStyle & SettingsValues;

export function createDefaultSettings(): RectangleSettings {
  const { colors } = getThemeStore();

  return {
    borderColor: colors.rectangleBorderFill,
    fillColor: colors.rectangleAreaFill,
    text: '',
    fontSize: 14,
    isBold: false,
    isItalic: false,
    textColor: colors.rectangleBorderFill,
  };
}

export function getRectangleSettingsTabs(settings: RectangleSettings): SettingsTab[] {
  const styleFields: SettingField[] = [
    {
      key: 'borderColor',
      label: t('Border'),
      type: 'color',
      defaultValue: settings.borderColor,
      toolbar: {
        control: 'color',
        role: 'line',
      },
    },
    {
      key: 'fillColor',
      label: t('Background'),
      type: 'color',
      defaultValue: settings.fillColor,
      toolbar: {
        control: 'color',
        role: 'fill',
      },
    },
  ];

  const textFields: SettingField[] = [
    {
      key: 'fontSize',
      label: t('Font size'),
      type: 'number',
      defaultValue: settings.fontSize,
      min: 8,
      max: 24,
    },
    {
      key: 'text',
      label: t('Text'),
      type: 'textarea',
      defaultValue: settings.text,
      placeholder: t('Enter text'),
    },
    {
      key: 'isBold',
      label: t('Bold'),
      type: 'boolean',
      defaultValue: settings.isBold,
    },
    {
      key: 'isItalic',
      label: t('Italic'),
      type: 'boolean',
      defaultValue: settings.isItalic,
    },
    {
      key: 'textColor',
      label: t('Text'),
      type: 'color',
      defaultValue: settings.textColor,
      toolbar: {
        control: 'color',
        role: 'text',
      },
    },
  ];

  return [
    {
      key: 'style',
      label: t('Style'),
      fields: styleFields,
    },
    {
      key: 'text',
      label: t('Text'),
      fields: textFields,
    },
  ];
}


import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';

import type { RegressionTrend } from './regressionTrend';

import type { Point } from '@core/Drawings/types';

const UI = {
  lineWidth: 2,
  middleLineWidth: 1,
  middleLineDash: 5,
  middleLineGap: 4,
  correlationFontSize: 12,
  correlationOffsetX: 0,
  correlationOffsetY: 6,
};

export class RegressionTrendPaneRenderer implements IPrimitivePaneRenderer {
  private readonly regressionTrend: RegressionTrend;

  constructor(regressionTrend: RegressionTrend) {
    this.regressionTrend = regressionTrend;
  }

  public draw(target: CanvasRenderingTarget2D): void {
    const data = this.regressionTrend.getRenderData();

    if (!data) {
      return;
    }

    target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
      const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);

      const baseStart = scalePoint(data.baseStartPoint, horizontalPixelRatio, verticalPixelRatio);
      const baseEnd = scalePoint(data.baseEndPoint, horizontalPixelRatio, verticalPixelRatio);
      const upperStart = scalePoint(data.upperStartPoint, horizontalPixelRatio, verticalPixelRatio);
      const upperEnd = scalePoint(data.upperEndPoint, horizontalPixelRatio, verticalPixelRatio);
      const lowerStart = scalePoint(data.lowerStartPoint, horizontalPixelRatio, verticalPixelRatio);
      const lowerEnd = scalePoint(data.lowerEndPoint, horizontalPixelRatio, verticalPixelRatio);

      context.save();

      if (data.showChannel) {
        drawArea(context, upperStart, upperEnd, baseEnd, baseStart, data.upperFillColor);
        drawArea(context, baseStart, baseEnd, lowerEnd, lowerStart, data.lowerFillColor);

        context.strokeStyle = data.lineColor;
        context.lineWidth = UI.lineWidth * pixelRatio;

        drawLine(context, upperStart, upperEnd);
        drawLine(context, lowerStart, lowerEnd);
      }

      if (data.showMiddleLine || !data.showChannel) {
        context.save();

        context.strokeStyle = data.middleLineColor;
        context.lineWidth = UI.middleLineWidth * pixelRatio;
        context.setLineDash([UI.middleLineDash * pixelRatio, UI.middleLineGap * pixelRatio]);

        drawLine(context, baseStart, baseEnd);

        context.restore();
      }

      if (data.showChannel && data.showCorrelation) {
        const correlationPoint = baseEnd.y < baseStart.y ? upperStart : lowerStart;
        const correlationDirection = baseEnd.y < baseStart.y ? 'upper' : 'lower';

        drawCorrelation(context, data.correlation, correlationPoint, correlationDirection, data.lineColor, pixelRatio);
      }

      context.restore();
    });
  }
}

function scalePoint(point: Point, horizontalPixelRatio: number, verticalPixelRatio: number): Point {
  return {
    x: point.x * horizontalPixelRatio,
    y: point.y * verticalPixelRatio,
  };
}

function drawLine(context: CanvasRenderingContext2D, start: Point, end: Point): void {
  context.beginPath();
  context.moveTo(start.x, start.y);
  context.lineTo(end.x, end.y);
  context.stroke();
}

function drawArea(
  context: CanvasRenderingContext2D,
  first: Point,
  second: Point,
  third: Point,
  fourth: Point,
  color: string,
): void {
  context.fillStyle = color;

  context.beginPath();
  context.moveTo(first.x, first.y);
  context.lineTo(second.x, second.y);
  context.lineTo(third.x, third.y);
  context.lineTo(fourth.x, fourth.y);
  context.closePath();
  context.fill();
}

function drawCorrelation(
  context: CanvasRenderingContext2D,
  correlation: number,
  point: Point,
  direction: 'upper' | 'lower',
  color: string,
  pixelRatio: number,
): void {
  const offsetX = UI.correlationOffsetX * pixelRatio;
  const offsetY = UI.correlationOffsetY * pixelRatio;

  context.save();
  context.font = `${UI.correlationFontSize * pixelRatio}px Inter, sans-serif`;
  context.fillStyle = color;
  context.textAlign = 'left';

  if (direction === 'upper') {
    context.textBaseline = 'bottom';
    context.fillText(correlation.toString(), point.x + offsetX, point.y - offsetY);
  } else {
    context.textBaseline = 'top';
    context.fillText(correlation.toString(), point.x + offsetX, point.y + offsetY);
  }

  context.restore();
}



import { IPrimitivePaneRenderer, IPrimitivePaneView, PrimitivePaneViewZOrder } from 'lightweight-charts';

import { RegressionTrendPaneRenderer } from './paneRenderer';

import type { RegressionTrend } from './regressionTrend';

export class RegressionTrendPaneView implements IPrimitivePaneView {
  private readonly regressionTrend: RegressionTrend;

  constructor(regressionTrend: RegressionTrend) {
    this.regressionTrend = regressionTrend;
  }

  public update(): void {}

  public renderer(): IPrimitivePaneRenderer {
    return new RegressionTrendPaneRenderer(this.regressionTrend);
  }

  public zOrder(): PrimitivePaneViewZOrder {
    return 'top';
  }
}



import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import { getTimeFromXCoordinate, getXCoordinateFromTime, getYCoordinateFromPrice } from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';

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

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

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

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

type RegressionTrendParams = BaseDrawingParams;

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

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

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

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

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

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

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

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

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

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

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

  private paneView: RegressionTrendPaneView;
  private timeAxisPaneView: CustomTimeAxisPaneView;
  private priceAxisPaneView: CustomPriceAxisPaneView;
  private startTimeAxisView: CustomTimeAxisView;
  private endTimeAxisView: CustomTimeAxisView;
  private startPriceAxisView: CustomPriceAxisView;
  private endPriceAxisView: CustomPriceAxisView;

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

    this.openSettings = openSettings;

    this.paneView = new RegressionTrendPaneView(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);

    if (initialEvent && initialEvent.sourceEvent) {
      const point = this.getEventPoint(initialEvent.sourceEvent);
      this.startDrawing(point);
    }
  }

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

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

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

    const next = state as Partial<RegressionTrendState>;

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

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

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

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

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

    this.render();
  }

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

  public 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(): RegressionTrendRenderData | null {
    if (this.hidden) {
      return null;
    }

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

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

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

    if (!geometry) {
      return [];
    }

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

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

    const point = { x, y };

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

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

    const dragTarget = this.getDragTarget(point);

    if (!dragTarget) {
      return null;
    }

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

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

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

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

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

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

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

    const { colors } = getThemeStore();

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

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

    const labelKind = kind as PriceLabelKind;
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

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

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

    const { colors } = getThemeStore();

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

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

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

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

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

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

      this.endTime = time;

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

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

      this.render();
      return;
    }

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

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

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

      this.select();
      return;
    }

    const dragTarget = this.getDragTarget(point);

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

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

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

    this.mode = 'dragging';

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

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

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

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

      return;
    }

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

    event.preventDefault();

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

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

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

    this.mode = 'ready';

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

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

    const regression = this.getRegression();

    if (!regression) {
      return null;
    }

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

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

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

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

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

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

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

    this.render();
  }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    if (!range) {
      return null;
    }

    const values: number[] = [];

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

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

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

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

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

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

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

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

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

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

    if (!range) {
      return false;
    }

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

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

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

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

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

    return data[index].time;
  }

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

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

    return findNearestBarIndex(data, time);
  }

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

    return findNearestBarIndex(data, time);
  }

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

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

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

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

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

    if (handle) {
      return handle.id;
    }

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

    return null;
  }

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

    if (!geometry) {
      return false;
    }

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

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

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

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

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

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

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

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

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

  let residualSum = 0;

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

    residualSum += residual * residual;
  }

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

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

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

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

  return null;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

    if (distance >= minDistance) {
      return;
    }

    minDistance = distance;
    result = index;
  });

  return result;
}

function getDistanceToSegment(point: Point, start: Point, end: Point): number {
  const deltaX = end.x - start.x;
  const deltaY = end.y - start.y;

  if (deltaX === 0 && deltaY === 0) {
    return Math.hypot(point.x - start.x, point.y - start.y);
  }

  const ratio = Math.max(
    0,
    Math.min(1, ((point.x - start.x) * deltaX + (point.y - start.y) * deltaY) / (deltaX * deltaX + deltaY * deltaY)),
  );

  const x = start.x + ratio * deltaX;
  const y = start.y + ratio * deltaY;

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

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

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

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

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

  return inside;
}


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

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

export interface RegressionTrendStyle {
  lineColor: string;
  middleLineColor: string;
  upperFillColor: string;
  lowerFillColor: string;
  showMiddleLine: boolean;
  showCorrelation: boolean;
}

export type RegressionTrendSettings = SettingsValues & RegressionTrendStyle;

export function createDefaultSettings(): RegressionTrendSettings {
  const { colors } = getThemeStore();

  return {
    lineColor: colors.chartLineColor,
    middleLineColor: colors.chartCrosshairLine,
    upperFillColor: colors.sliderPositiveFill,
    lowerFillColor: colors.sliderNegativeFill,
    showMiddleLine: true,
    showCorrelation: true,
  };
}

export function getRegressionTrendSettingsTabs(settings: RegressionTrendSettings): SettingsTab[] {
  const styleFields: SettingField[] = [
    {
      key: 'lineColor',
      label: t('Channel line'),
      type: 'color',
      defaultValue: settings.lineColor,
      toolbar: {
        control: 'color',
        role: 'line',
      },
    },
    {
      key: 'middleLineColor',
      label: t('Middle line'),
      type: 'color',
      defaultValue: settings.middleLineColor,
    },
    {
      key: 'upperFillColor',
      label: t('Upper area'),
      type: 'color',
      defaultValue: settings.upperFillColor,
      toolbar: {
        control: 'color',
        role: 'fill',
      },
    },
    {
      key: 'lowerFillColor',
      label: t('Lower area'),
      type: 'color',
      defaultValue: settings.lowerFillColor,
      toolbar: {
        control: 'color',
        role: 'fill',
      },
    },
    {
      key: 'showMiddleLine',
      label: t('Show middle line'),
      type: 'boolean',
      defaultValue: settings.showMiddleLine,
    },
    {
      key: 'showCorrelation',
      label: t('Show correlation'),
      type: 'boolean',
      defaultValue: settings.showCorrelation,
    },
  ];

  return [
    {
      key: 'style',
      label: t('Style'),
      fields: styleFields,
    },
  ];
}



import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';

import { Direction } from '@src/types';

import type { Ruler } from './ruler';

import type { Bounds, Point } from '@core/Drawings/types';

const UI = {
  lineWidth: 2,
  arrowSize: 10,
  infoFont: '12px Inter, sans-serif',
  infoPadding: 4,
  infoOffset: 8,
  infoRadius: 2,
  infoLineHeight: 14,
  infoGap: 2,
};

export class RulerPaneRenderer implements IPrimitivePaneRenderer {
  private readonly ruler: Ruler;

  constructor(ruler: Ruler) {
    this.ruler = ruler;
  }

  public draw(target: CanvasRenderingTarget2D): void {
    const data = this.ruler.getRenderData();

    if (data.hidden || !data.startPoint || !data.endPoint) {
      return;
    }

    const bounds = getBounds(data.startPoint, data.endPoint);

    target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
      const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);

      const left = bounds.left * horizontalPixelRatio;
      const right = bounds.right * horizontalPixelRatio;
      const top = bounds.top * verticalPixelRatio;
      const bottom = bounds.bottom * verticalPixelRatio;

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

      context.save();

      context.fillStyle = data.fillColor;
      context.fillRect(left, top, right - left, bottom - top);

      context.lineWidth = UI.lineWidth * pixelRatio;
      context.strokeStyle = data.lineColor;

      drawHorizontalArrow(context, left, right, centerY, UI.arrowSize * pixelRatio, data.horizontalArrowSide);
      drawVerticalArrow(context, centerX, top, bottom, UI.arrowSize * pixelRatio, data.verticalArrowSide);

      drawInfoBox(
        context,
        centerX,
        top - UI.infoOffset * pixelRatio,
        data.infoLines,
        data.fillColor,
        data.textColor,
        pixelRatio,
        verticalPixelRatio,
      );

      context.restore();
    });
  }
}

function getBounds(startPoint: Point, endPoint: Point): Bounds {
  return {
    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),
  };
}

function drawHorizontalArrow(
  context: CanvasRenderingContext2D,
  left: number,
  right: number,
  y: number,
  size: number,
  side: Direction.Left | Direction.Right | null,
): void {
  context.beginPath();
  context.moveTo(left, y);
  context.lineTo(right, y);
  context.stroke();

  if (!side) {
    return;
  }

  context.beginPath();

  if (side === Direction.Left) {
    context.moveTo(left, y);
    context.lineTo(left + size, y - size);
    context.moveTo(left, y);
    context.lineTo(left + size, y + size);
  }

  if (side === Direction.Right) {
    context.moveTo(right, y);
    context.lineTo(right - size, y - size);
    context.moveTo(right, y);
    context.lineTo(right - size, y + size);
  }

  context.stroke();
}

function drawVerticalArrow(
  context: CanvasRenderingContext2D,
  x: number,
  top: number,
  bottom: number,
  size: number,
  side: Direction.Top | Direction.Bottom | null,
): void {
  context.beginPath();
  context.moveTo(x, top);
  context.lineTo(x, bottom);
  context.stroke();

  if (!side) {
    return;
  }

  context.beginPath();

  if (side === Direction.Top) {
    context.moveTo(x, top);
    context.lineTo(x - size, top + size);
    context.moveTo(x, top);
    context.lineTo(x + size, top + size);
  }

  if (side === Direction.Bottom) {
    context.moveTo(x, bottom);
    context.lineTo(x - size, bottom - size);
    context.moveTo(x, bottom);
    context.lineTo(x + size, bottom - size);
  }

  context.stroke();
}

function drawInfoBox(
  context: CanvasRenderingContext2D,
  centerX: number,
  topY: number,
  lines: readonly string[],
  fillColor: string,
  textColor: string,
  pixelRatio: number,
  verticalPixelRatio: number,
): void {
  context.save();
  context.font = UI.infoFont;
  context.textAlign = 'center';

  const padding = UI.infoPadding * pixelRatio;
  const lineHeight = UI.infoLineHeight * verticalPixelRatio;
  const gap = UI.infoGap * verticalPixelRatio;

  let maxWidth = 0;

  for (const line of lines) {
    maxWidth = Math.max(maxWidth, context.measureText(line).width);
  }

  const boxWidth = maxWidth + padding * 2;
  const boxHeight = lines.length * lineHeight + (lines.length - 1) * gap + padding * 2;

  const boxX = centerX - boxWidth / 2;
  const boxY = topY - boxHeight;

  context.fillStyle = fillColor;
  context.beginPath();
  drawRoundedRect(context, boxX, boxY, boxWidth, boxHeight, UI.infoRadius * pixelRatio);
  context.fill();

  context.fillStyle = textColor;

  let textY = boxY + padding + lineHeight * 0.8;

  for (const line of lines) {
    context.fillText(line, centerX, textY);
    textY += lineHeight + gap;
  }

  context.restore();
}

function drawRoundedRect(
  context: CanvasRenderingContext2D,
  x: number,
  y: number,
  width: number,
  height: number,
  radius: number,
): void {
  const safeRadius = Math.min(radius, width / 2, height / 2);

  context.moveTo(x + safeRadius, y);
  context.arcTo(x + width, y, x + width, y + height, safeRadius);
  context.arcTo(x + width, y + height, x, y + height, safeRadius);
  context.arcTo(x, y + height, x, y, safeRadius);
  context.arcTo(x, y, x + width, y, safeRadius);
  context.closePath();
}



import { IPrimitivePaneRenderer, IPrimitivePaneView, PrimitivePaneViewZOrder } from 'lightweight-charts';

import { RulerPaneRenderer } from './paneRenderer';

import type { Ruler } from './ruler';

export class RulerPaneView implements IPrimitivePaneView {
  private readonly ruler: Ruler;

  constructor(ruler: Ruler) {
    this.ruler = ruler;
  }

  public update(): void {}

  public renderer(): IPrimitivePaneRenderer {
    return new RulerPaneRenderer(this.ruler);
  }

  public zOrder(): PrimitivePaneViewZOrder {
    return 'top';
  }
}


import { PrimitiveHoveredItem } from 'lightweight-charts';
import { Observable, skip } from 'rxjs';

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

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 { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type {
  AutoscaleInfo,
  Coordinate,
  IPrimitivePaneView,
  ISeriesApi,
  ISeriesPrimitiveAxisView,
  Logical,
  MouseEventHandler,
  MouseEventParams,
  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 extends BaseDrawingParams {
  resetTriggers?: Observable<unknown>[];
}

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 = {};
  protected mode: RulerMode = 'idle';

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

  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,
    series,
    resetTriggers = [],
    formatObservable,
    removeSelf,
    container,
    interaction,
    initialEvent,
  }: RulerParams) {
    super({
      chart,
      series,
      container,
      interaction,
    });

    this.removeSelf = removeSelf;

    this.clickHandler = (params) => this.handleChartClick(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);

    if (initialEvent && initialEvent.sourceEvent) {
      const point = this.getEventPoint(initialEvent.sourceEvent);
      this.startDrawing(point);
    }
  }

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

  protected getHoveredItem(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 handleChartClick(params: MouseEventParams<Time>): void {
    if (this.hidden || !params.point || !params.sourceEvent) {
      return;
    }

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

    if (!params.sourceEvent) {
      return;
    }

    const anchor = this.createAnchor(params);

    if (!anchor) {
      return;
    }

    const point = this.getEventPoint(params.sourceEvent);

    if (this.mode === 'idle') {
      this.startDrawing(point);

      return;
    }

    if (this.mode === 'placingEnd') {
      this.endAnchor = anchor;
      this.mode = 'ready';
      this.resolveReady?.();

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

  private startDrawing(point: Point): void {
    const anchor = getAnchorFromPoint(this.chart, this.series, point);
    this.startAnchor = anchor;
    this.endAnchor = anchor;
    this.mode = 'placingEnd';

    this.hideCrosshair();
    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 { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';

import type { SliderPositionTextStyle } from './settings';
import type { SliderPosition } from './sliderPosition';

const UI = {
  lineWidth: 1,
  lineHeightMultiplier: 1.2,
  mainBoxHeight: 28,
  sideBoxHeight: 16,
  padding: 4,
  boxRadius: 4,
  labelOffset: 10,
};

export class SliderPaneRenderer implements IPrimitivePaneRenderer {
  private readonly slider: SliderPosition;

  constructor(slider: SliderPosition) {
    this.slider = slider;
  }

  public draw(target: CanvasRenderingTarget2D): void {
    const data = this.slider.getRenderData();

    if (!data) {
      return;
    }

    target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio, bitmapSize }) => {
      const left = data.leftX * horizontalPixelRatio;
      const right = data.rightX * horizontalPixelRatio;
      const entryY = data.entryY * verticalPixelRatio;
      const stopY = data.stopY * verticalPixelRatio;
      const targetY = data.targetY * verticalPixelRatio;
      const profitTop = data.profitTop * verticalPixelRatio;
      const profitBottom = data.profitBottom * verticalPixelRatio;
      const lossTop = data.lossTop * verticalPixelRatio;
      const lossBottom = data.lossBottom * verticalPixelRatio;
      const centerX = (left + right) / 2;
      const labelOffsetPx = UI.labelOffset * verticalPixelRatio;

      const targetBoxHeight = getTextBoxHeight(data.targetText, data, UI.sideBoxHeight, verticalPixelRatio);
      const centerBoxHeight = getTextBoxHeight(data.centerText, data, UI.mainBoxHeight, verticalPixelRatio);
      const stopBoxHeight = getTextBoxHeight(data.stopText, data, UI.sideBoxHeight, verticalPixelRatio);

      const targetLabelCenterY = getOuterLabelCenterY(
        targetY,
        data.targetLabelDirection,
        targetBoxHeight,
        labelOffsetPx,
      );

      const stopLabelCenterY = getOuterLabelCenterY(stopY, data.stopLabelDirection, stopBoxHeight, labelOffsetPx);

      const centerLabelCenterY = getCenterLabelCenterY(
        entryY,
        targetY,
        stopY,
        centerBoxHeight,
        labelOffsetPx,
        bitmapSize.height,
      );

      context.save();

      context.fillStyle = data.positiveFillColor;
      context.fillRect(left, profitTop, right - left, profitBottom - profitTop);

      context.fillStyle = data.negativeFillColor;
      context.fillRect(left, lossTop, right - left, lossBottom - lossTop);

      context.lineWidth = UI.lineWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
      context.strokeStyle = data.lineColor;

      drawHorizontalLine(context, left, right, entryY);

      if (data.showLabels) {
        drawTextBox(
          context,
          centerX,
          targetLabelCenterY,
          data.targetText,
          data.positiveFillColor,
          data,
          horizontalPixelRatio,
          verticalPixelRatio,
          UI.sideBoxHeight,
        );

        drawTextBox(
          context,
          centerX,
          centerLabelCenterY,
          data.centerText,
          data.centerBoxColor,
          data,
          horizontalPixelRatio,
          verticalPixelRatio,
          UI.mainBoxHeight,
        );

        drawTextBox(
          context,
          centerX,
          stopLabelCenterY,
          data.stopText,
          data.negativeFillColor,
          data,
          horizontalPixelRatio,
          verticalPixelRatio,
          UI.sideBoxHeight,
        );
      }

      context.restore();
    });
  }
}

function getOuterLabelCenterY(
  coordinate: number,
  direction: 'up' | 'down',
  boxHeight: number,
  labelOffset: number,
): number {
  const offset = labelOffset + boxHeight / 2;

  return direction === 'up' ? coordinate - offset : coordinate + offset;
}

function getCenterLabelCenterY(
  entryY: number,
  targetY: number,
  stopY: number,
  boxHeight: number,
  labelOffset: number,
  viewportHeight: number,
): number {
  const targetSpace = Math.abs(targetY - entryY);
  const stopSpace = Math.abs(stopY - entryY);

  let direction: -1 | 1;

  if (targetSpace < stopSpace) {
    direction = stopY < entryY ? -1 : 1;
  } else {
    direction = targetY < entryY ? -1 : 1;
  }

  return clampTextBoxCenter(entryY + direction * (labelOffset + boxHeight / 2), boxHeight, viewportHeight);
}

function clampTextBoxCenter(coordinate: number, boxHeight: number, viewportHeight: number): number {
  if (viewportHeight <= boxHeight) {
    return viewportHeight / 2;
  }

  const halfHeight = boxHeight / 2;

  return Math.max(halfHeight, Math.min(coordinate, viewportHeight - halfHeight));
}

function drawHorizontalLine(context: CanvasRenderingContext2D, left: number, right: number, y: number): void {
  context.beginPath();
  context.moveTo(left, y);
  context.lineTo(right, y);
  context.stroke();
}

function drawTextBox(
  context: CanvasRenderingContext2D,
  centerX: number,
  centerY: number,
  text: string,
  fillColor: string,
  textStyle: SliderPositionTextStyle,
  horizontalPixelRatio: number,
  verticalPixelRatio: number,
  fixedHeight: number,
): void {
  context.save();

  const lines = text.split('\n');
  const fontSize = textStyle.fontSize * verticalPixelRatio;
  const lineHeight = Math.round(textStyle.fontSize * UI.lineHeightMultiplier) * verticalPixelRatio;
  const paddingX = UI.padding * horizontalPixelRatio;
  const boxHeight = getTextBoxHeight(text, textStyle, fixedHeight, verticalPixelRatio);
  const radius = UI.boxRadius * Math.max(horizontalPixelRatio, verticalPixelRatio);

  context.font = getTextFont(textStyle, fontSize);
  context.textAlign = 'center';
  context.textBaseline = 'middle';

  let maxTextWidth = 0;

  for (const line of lines) {
    maxTextWidth = Math.max(maxTextWidth, context.measureText(line).width);
  }

  const width = maxTextWidth + paddingX * 2;
  const x = centerX - width / 2;
  const y = centerY - boxHeight / 2;

  context.fillStyle = fillColor;
  context.beginPath();
  drawRoundedRect(context, x, y, width, boxHeight, radius);
  context.fill();

  context.fillStyle = textStyle.textColor;

  if (lines.length === 1) {
    context.fillText(lines[0], centerX, centerY);
    context.restore();
    return;
  }

  const textBlockHeight = lines.length * lineHeight;
  const firstLineCenterY = centerY - textBlockHeight / 2 + lineHeight / 2;

  lines.forEach((line, index) => {
    context.fillText(line, centerX, firstLineCenterY + index * lineHeight);
  });

  context.restore();
}

function getTextBoxHeight(
  text: string,
  textStyle: SliderPositionTextStyle,
  fixedHeight: number,
  verticalPixelRatio: number,
): number {
  const linesCount = text.split('\n').length;
  const lineHeight = Math.round(textStyle.fontSize * UI.lineHeightMultiplier) * verticalPixelRatio;
  const paddingY = UI.padding * verticalPixelRatio;
  const minBoxHeight = fixedHeight * verticalPixelRatio;

  return Math.max(minBoxHeight, linesCount * lineHeight + paddingY * 2);
}

function getTextFont(style: SliderPositionTextStyle, fontSize: number): string {
  const italic = style.isItalic ? 'italic ' : '';
  const bold = style.isBold ? '700 ' : '';

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

function drawRoundedRect(
  context: CanvasRenderingContext2D,
  x: number,
  y: number,
  width: number,
  height: number,
  radius: number,
): void {
  const safeRadius = Math.min(radius, width / 2, height / 2);

  context.moveTo(x + safeRadius, y);
  context.arcTo(x + width, y, x + width, y + height, safeRadius);
  context.arcTo(x + width, y + height, x, y + height, safeRadius);
  context.arcTo(x, y + height, x, y, safeRadius);
  context.arcTo(x, y, x + width, y, safeRadius);
  context.closePath();
}



import { IPrimitivePaneRenderer, IPrimitivePaneView, PrimitivePaneViewZOrder } from 'lightweight-charts';

import { SliderPaneRenderer } from './paneRenderer';

import type { SliderPosition } from './sliderPosition';

export class SliderPaneView implements IPrimitivePaneView {
  private readonly slider: SliderPosition;

  constructor(slider: SliderPosition) {
    this.slider = slider;
  }

  public update(): void {}

  public renderer(): IPrimitivePaneRenderer {
    return new SliderPaneRenderer(this.slider);
  }

  public zOrder(): PrimitivePaneViewZOrder {
    return 'top';
  }
}


import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { SettingField, SettingsTab, SettingsValues } from '@src/types';

export interface SliderPositionStyle {
  lineColor: string;
  positiveFillColor: string;
  negativeFillColor: string;
}

export interface SliderPositionTextStyle {
  textColor: string;
  fontSize: number;
  isBold: boolean;
  isItalic: boolean;
}

export type SliderPositionSettings = SettingsValues & SliderPositionStyle & SliderPositionTextStyle;

export function createDefaultSettings(): SliderPositionSettings {
  const { colors } = getThemeStore();

  return {
    lineColor: colors.chartCrosshairLine,
    positiveFillColor: colors.sliderPositiveFill,
    negativeFillColor: colors.sliderNegativeFill,
    textColor: colors.chartPriceLineText,
    fontSize: 10,
    isBold: false,
    isItalic: false,
  };
}

export function getSliderPositionSettingsTabs(settings: SliderPositionSettings): SettingsTab[] {
  const styleFields: SettingField[] = [
    {
      key: 'lineColor',
      label: t('Line'),
      type: 'color',
      defaultValue: settings.lineColor,
    },
    {
      key: 'positiveFillColor',
      label: t('Profit zone'),
      type: 'color',
      defaultValue: settings.positiveFillColor,
      toolbar: {
        control: 'color',
        role: 'fill',
      },
    },
    {
      key: 'negativeFillColor',
      label: t('Loss zone'),
      type: 'color',
      defaultValue: settings.negativeFillColor,
      toolbar: {
        control: 'color',
        role: 'fill',
      },
    },
  ];

  const textFields: SettingField[] = [
    {
      key: 'fontSize',
      label: t('Font size'),
      type: 'number',
      defaultValue: settings.fontSize,
      min: 8,
      max: 24,
    },
    {
      key: 'isBold',
      label: t('Bold'),
      type: 'boolean',
      defaultValue: settings.isBold,
    },
    {
      key: 'isItalic',
      label: t('Italic'),
      type: 'boolean',
      defaultValue: settings.isItalic,
    },
    {
      key: 'textColor',
      label: t('Text'),
      type: 'color',
      defaultValue: settings.textColor,
      toolbar: {
        control: 'color',
        role: 'text',
      },
    },
  ];

  return [
    {
      key: 'style',
      label: t('Style'),
      fields: styleFields,
    },
    {
      key: 'text',
      label: t('Text'),
      fields: textFields,
    },
  ];
}


import { Observable, skip } from 'rxjs';

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
  getPriceDelta as getPriceDeltaFromCoordinates,
  getPriceFromYCoordinate,
  getPriceRangeInContainer,
  getTimeFromXCoordinate,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  isPointInBounds,
  shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';

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 { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment, Bounds, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
import type {
  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 SliderHandleId = Exclude<DragTarget, 'body' | null>;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'target' | 'entry' | 'stop';

interface SliderPositionParams extends BaseDrawingParams {
  side: SliderSide;
  resetTriggers?: Observable<unknown>[];
}

interface SliderPositionState {
  hidden: 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;
  targetLabelDirection: 'up' | 'down';
  stopLabelDirection: 'up' | 'down';
  showLabels: boolean;
}

const BODY_HIT_TOLERANCE = 8;
const INITIAL_WIDTH_PX = 160;
const MIN_DISTANCE = 0.00000001;

export class SliderPosition
  extends SeriesDrawingBase<SliderPositionSettings, SliderHandleId>
  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 defaultRiskRewardRatio = 1;
  private amount = 1000;
  private tickSize = 1;

  private clickHandler: MouseEventHandler<Time>;
  private paneView: SliderPaneView;
  private timeAxisPaneView: CustomTimeAxisPaneView;
  private priceAxisPaneView: CustomPriceAxisPaneView;
  private startTimeAxisView: CustomTimeAxisView;
  private endTimeAxisView: CustomTimeAxisView;
  private targetPriceAxisView: CustomPriceAxisView;
  private entryPriceAxisView: CustomPriceAxisView;
  private stopPriceAxisView: CustomPriceAxisView;

  constructor({
    chart,
    series,
    side,
    container,
    interaction,
    formatObservable,
    resetTriggers = [],
    removeSelf,
    openSettings,
    initialEvent,
  }: SliderPositionParams) {
    super({ chart, series, container, interaction });

    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(),
      zOrder: 'normal',
    });

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

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

    if (initialEvent) {
      this.handleChartClick(initialEvent);
    }
  }

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

  public getState(): SliderPositionState {
    return {
      hidden: this.hidden,
      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 {
    if (!state || typeof state !== 'object') {
      return;
    }

    const next = state as Partial<SliderPositionState>;

    this.hidden = next.hidden ?? this.hidden;
    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,
      targetLabelDirection: this.side === 'long' ? 'up' : 'down',
      stopLabelDirection: this.side === 'long' ? 'down' : 'up',
      showLabels: this.isSelected(),
      ...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 '';
    }
  }

  protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
    const point = { x, y };

    if (!this.isSelected()) {
      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.isSelected()) {
      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.isSelected()) {
      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.isSelected() || (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 {
    if (this.isBound) {
      return;
    }

    super.bindEvents();
    this.chart.subscribeClick(this.clickHandler);
  }

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

    this.chart.unsubscribeClick(this.clickHandler);
    super.unbindEvents();
  }

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

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

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

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

  private handleChartClick(params: MouseEventParams<Time>): void {
    // todo: привести к общему виду дровингов как handleChartClick => startDrawing
    if (this.hidden || !params.point || this.mode !== 'idle') {
      return;
    }

    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.mode = 'ready';
    this.resolveReady?.();
    this.render();
  }

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

    const point = this.getEventPoint(event);

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

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

    const dragTarget = this.getHandleTarget(point);

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

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

    this.activeDragTarget = dragTarget;
    this.dragPointerId = event.pointerId;
    this.dragStartPoint = point;
    this.dragStateSnapshot = this.getState();
    this.mode = 'dragging';
  };

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

    event.preventDefault();
    this.applyDrag(this.getEventPoint(event));
    this.render();
  };

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

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

    this.mode = 'ready';
    this.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 minPrice = Math.min(snapshot.stopPrice, snapshot.targetPrice);
    const maxPrice = Math.max(snapshot.stopPrice, snapshot.targetPrice);

    this.entryPrice = Math.max(minPrice, Math.min(nextPrice, maxPrice));
  }

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

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

  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 = 0): 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 = 0): 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 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);
    const reward = Math.abs(this.targetPrice - this.entryPrice);

    if (risk <= MIN_DISTANCE || reward <= MIN_DISTANCE) {
      return 0;
    }

    return reward / 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 getDrawingHandles(): readonly DrawingHandle<SliderHandleId>[] {
    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    return [
      {
        id: 'target',
        x: geometry.startX,
        y: geometry.targetY,
      },
      {
        id: 'stop',
        x: geometry.startX,
        y: geometry.stopY,
      },
      {
        id: 'end',
        x: geometry.endX,
        y: geometry.entryY,
      },
      {
        id: 'entry',
        x: geometry.startX,
        y: geometry.entryY,
        shape: 'circle',
      },
    ];
  }

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

    if (handle) {
      return handle.id;
    }

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    const bounds: Bounds = {
      left: geometry.leftX,
      right: geometry.rightX,
      top: Math.min(geometry.targetY, geometry.stopY),
      bottom: Math.max(geometry.targetY, geometry.stopY),
    };

    return isPointInBounds(point, bounds) ? 'body' : null;
  }

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

    if (!geometry) {
      return false;
    }

    const bounds: Bounds = {
      left: geometry.leftX,
      right: geometry.rightX,
      top: Math.min(geometry.targetY, geometry.stopY),
      bottom: Math.max(geometry.targetY, geometry.stopY),
    };

    return isPointInBounds(point, bounds, BODY_HIT_TOLERANCE);
  }
}



import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';

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

import { Text } from './text';

const UI = {
  borderWidth: 1,
  borderRadius: 4,
  padding: 6,
  selectionBorderWidth: 1,
};

export class TextPaneRenderer implements IPrimitivePaneRenderer {
  private readonly text: Text;

  constructor(text: Text) {
    this.text = text;
  }

  public draw(target: CanvasRenderingTarget2D): void {
    const data = this.text.getRenderData();

    if (!data) {
      return;
    }

    target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
      const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
      const left = data.left * horizontalPixelRatio;
      const top = data.top * verticalPixelRatio;
      const width = data.width * horizontalPixelRatio;
      const height = data.height * verticalPixelRatio;
      const paddingX = UI.padding * horizontalPixelRatio;
      const paddingY = UI.padding * verticalPixelRatio;
      const borderRadius = UI.borderRadius * pixelRatio;

      context.save();

      context.fillStyle = data.backgroundColor;
      fillRoundedRect(context, left, top, width, height, borderRadius);

      context.strokeStyle = data.borderColor;
      context.lineWidth = UI.borderWidth * pixelRatio;
      strokeRoundedRect(context, left, top, width, height, borderRadius);

      context.save();
      context.beginPath();
      context.rect(left, top, width, height);
      context.clip();

      context.font = data.font;
      context.fillStyle = data.textColor;
      context.textAlign = 'left';
      context.textBaseline = 'top';

      data.lines.forEach((line, index) => {
        context.fillText(line, left + paddingX, top + paddingY + index * data.lineHeight * verticalPixelRatio);
      });

      context.restore();

      if (data.showSelectionBorder) {
        drawSelectionBorder(context, left, top, width, height, borderRadius, pixelRatio);
      }

      context.restore();
    });
  }
}

function drawSelectionBorder(
  context: CanvasRenderingContext2D,
  x: number,
  y: number,
  width: number,
  height: number,
  radius: number,
  pixelRatio: number,
): void {
  const { colors } = getThemeStore();

  context.save();
  context.strokeStyle = colors.chartLineColor;
  context.lineWidth = UI.selectionBorderWidth * pixelRatio;

  strokeRoundedRect(context, x, y, width, height, radius);

  context.restore();
}

function fillRoundedRect(
  context: CanvasRenderingContext2D,
  x: number,
  y: number,
  width: number,
  height: number,
  radius: number,
): void {
  context.beginPath();
  buildRoundedRectPath(context, x, y, width, height, radius);
  context.fill();
}

function strokeRoundedRect(
  context: CanvasRenderingContext2D,
  x: number,
  y: number,
  width: number,
  height: number,
  radius: number,
): void {
  context.beginPath();
  buildRoundedRectPath(context, x, y, width, height, radius);
  context.stroke();
}

function buildRoundedRectPath(
  context: CanvasRenderingContext2D,
  x: number,
  y: number,
  width: number,
  height: number,
  radius: number,
): void {
  const safeRadius = Math.min(radius, width / 2, height / 2);

  context.moveTo(x + safeRadius, y);
  context.arcTo(x + width, y, x + width, y + height, safeRadius);
  context.arcTo(x + width, y + height, x, y + height, safeRadius);
  context.arcTo(x, y + height, x, y, safeRadius);
  context.arcTo(x, y, x + width, y, safeRadius);
  context.closePath();
}


import { IPrimitivePaneRenderer, IPrimitivePaneView, PrimitivePaneViewZOrder } from 'lightweight-charts';

import { TextPaneRenderer } from './paneRenderer';
import { Text } from './text';

export class TextPaneView implements IPrimitivePaneView {
  private readonly paneRenderer: TextPaneRenderer;

  constructor(text: Text) {
    this.paneRenderer = new TextPaneRenderer(text);
  }

  public update(): void {}

  public renderer(): IPrimitivePaneRenderer {
    return this.paneRenderer;
  }

  public zOrder(): PrimitivePaneViewZOrder {
    return 'top';
  }
}



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

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

export interface TextStyle {
  backgroundColor: string;
  borderColor: string;
}

export interface TextContentStyle {
  text: string;
  fontSize: number;
  isBold: boolean;
  isItalic: boolean;
  textColor: string;
}

export type TextSettings = SettingsValues & TextStyle & TextContentStyle;

export function createDefaultSettings(): TextSettings {
  const { colors } = getThemeStore();

  return {
    text: t('Text'),
    fontSize: 14,
    isBold: false,
    isItalic: false,
    textColor: colors.chartPriceLineText,
    backgroundColor: '#00000000',
    borderColor: '#00000000',
  };
}

export function getTextSettingsTabs(settings: TextSettings): SettingsTab[] {
  const styleFields: SettingField[] = [
    {
      key: 'borderColor',
      label: t('Border'),
      type: 'color',
      defaultValue: settings.borderColor,
    },
    {
      key: 'backgroundColor',
      label: t('Background'),
      type: 'color',
      defaultValue: settings.backgroundColor,
    },
  ];

  const textFields: SettingField[] = [
    {
      key: 'fontSize',
      label: t('Font size'),
      type: 'number',
      defaultValue: settings.fontSize,
      min: 8,
      max: 72,
    },
    {
      key: 'text',
      label: t('Text'),
      type: 'textarea',
      defaultValue: settings.text,
      placeholder: t('Enter text'),
    },
    {
      key: 'isBold',
      label: t('Bold'),
      type: 'boolean',
      defaultValue: settings.isBold,
    },
    {
      key: 'isItalic',
      label: t('Italic'),
      type: 'boolean',
      defaultValue: settings.isItalic,
    },
    {
      key: 'textColor',
      label: t('Text'),
      type: 'color',
      defaultValue: settings.textColor,
      toolbar: {
        control: 'color',
        role: 'text',
      },
    },
  ];

  return [
    { key: 'style', label: t('Style'), fields: styleFields },
    { key: 'text', label: t('Text'), fields: textFields },
  ];
}



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

import { CustomPriceAxisView, CustomTimeAxisView } from '@core/Drawings/axis';
import {
  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 { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';

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 { Anchor, AxisLabel, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type { ChartOptionsModel } from '@src/types';

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

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

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

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

type TextParams = BaseDrawingParams;

const UI = {
  padding: 6,
};

let measureCanvas: HTMLCanvasElement | null = null;

export class Text extends 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,
    series,
    formatObservable,
    removeSelf,
    openSettings,
    container,
    interaction,
    initialEvent,
  }: TextParams) {
    super({
      chart,
      series,
      container,
      interaction,
    });

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

    if (initialEvent && initialEvent.sourceEvent) {
      const point = this.getEventPoint(initialEvent.sourceEvent);
      this.startDrawing(point);
    }
  }

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

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

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

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

    const nextState = state as Partial<TextState>;

    this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
    this.mode = nextState.mode ?? this.mode;
    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,
      showSelectionBorder: this.isSelected(),
    };
  }

  protected getHoveredItem(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.isSelected() || !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.isSelected() || !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.startDrawing(point);
      return;
    }

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

    const containsPoint = this.containsPoint(point);

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

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

      this.select();
      return;
    }

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

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

    this.deselect();
  };

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

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

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

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

    this.openSettings?.();
  };

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

    event.preventDefault();

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

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

    if (this.mode === 'dragging') {
      this.finishDragging();
    }
  };

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

    if (!anchor) {
      return;
    }

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

    this.render();
  }

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

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

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

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

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

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

    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 = this.settings.fontSize;
    const measured = measureTextBlock(lines, font, lineHeight);

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

    const left = clamp(anchorPoint.x, 0, Math.max(0, containerWidth - width));
    const top = clamp(anchorPoint.y, 0, Math.max(0, containerHeight - height));
    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 { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';

import { Traectory } from './traectory';

const UI = {
  lineWidth: 2,
  previewLineWidth: 1,
  previewDash: [6, 4],
  arrowLength: 14,
  arrowHalfWidth: 6,
};

export class TraectoryPaneRenderer implements IPrimitivePaneRenderer {
  private readonly traectory: Traectory;

  constructor(traectory: Traectory) {
    this.traectory = traectory;
  }

  public draw(target: CanvasRenderingTarget2D): void {
    const data = this.traectory.getRenderData();

    if (!data) {
      return;
    }

    target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
      const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
      const lineWidth = UI.lineWidth * pixelRatio;
      const previewLineWidth = UI.previewLineWidth * pixelRatio;
      const arrowLength = UI.arrowLength * pixelRatio;
      const arrowHalfWidth = UI.arrowHalfWidth * pixelRatio;

      context.save();
      context.lineJoin = 'round';
      context.lineCap = 'round';
      context.strokeStyle = data.lineColor;
      context.fillStyle = data.lineColor;

      if (data.points.length > 1) {
        context.lineWidth = lineWidth;
        context.beginPath();

        data.points.forEach((point, index) => {
          const x = point.x * horizontalPixelRatio;
          const y = point.y * verticalPixelRatio;

          if (index === 0) {
            context.moveTo(x, y);
            return;
          }

          context.lineTo(x, y);
        });

        context.stroke();
      }

      if (data.showArrow && data.points.length > 1) {
        const previousPoint = data.points[data.points.length - 2];
        const lastPoint = data.points[data.points.length - 1];

        context.lineWidth = lineWidth;

        drawArrowHead(
          context,
          previousPoint.x * horizontalPixelRatio,
          previousPoint.y * verticalPixelRatio,
          lastPoint.x * horizontalPixelRatio,
          lastPoint.y * verticalPixelRatio,
          arrowLength,
          arrowHalfWidth,
        );
      }

      if (data.previewPoint && data.points.length > 0) {
        const lastPoint = data.points[data.points.length - 1];

        context.save();
        context.lineWidth = previewLineWidth;
        context.setLineDash(UI.previewDash.map((value) => value * pixelRatio));
        context.beginPath();
        context.moveTo(lastPoint.x * horizontalPixelRatio, lastPoint.y * verticalPixelRatio);
        context.lineTo(data.previewPoint.x * horizontalPixelRatio, data.previewPoint.y * verticalPixelRatio);
        context.stroke();
        context.restore();
      }

      context.restore();
    });
  }
}

function drawArrowHead(
  context: CanvasRenderingContext2D,
  fromX: number,
  fromY: number,
  toX: number,
  toY: number,
  arrowLength: number,
  arrowHalfWidth: number,
): void {
  const dx = toX - fromX;
  const dy = toY - fromY;
  const distance = Math.hypot(dx, dy);

  if (distance === 0) {
    return;
  }

  const directionX = dx / distance;
  const directionY = dy / distance;
  const normalX = -directionY;
  const normalY = directionX;

  const leftX = toX - directionX * arrowLength + normalX * arrowHalfWidth;
  const leftY = toY - directionY * arrowLength + normalY * arrowHalfWidth;

  const rightX = toX - directionX * arrowLength - normalX * arrowHalfWidth;
  const rightY = toY - directionY * arrowLength - normalY * arrowHalfWidth;

  context.beginPath();
  context.moveTo(toX, toY);
  context.lineTo(leftX, leftY);
  context.moveTo(toX, toY);
  context.lineTo(rightX, rightY);
  context.stroke();
}


import { IPrimitivePaneRenderer, IPrimitivePaneView, PrimitivePaneViewZOrder } from 'lightweight-charts';

import { TraectoryPaneRenderer } from './paneRenderer';
import { Traectory } from './traectory';

export class TraectoryPaneView implements IPrimitivePaneView {
  private readonly paneRenderer: TraectoryPaneRenderer;

  constructor(traectory: Traectory) {
    this.paneRenderer = new TraectoryPaneRenderer(traectory);
  }

  public update(): void {}

  public renderer(): IPrimitivePaneRenderer {
    return this.paneRenderer;
  }

  public zOrder(): PrimitivePaneViewZOrder {
    return 'top';
  }
}



import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { SettingField, SettingsTab, SettingsValues } from '@src/types';

export interface TraectoryStyle {
  lineColor: string;
}

export type TraectorySettings = SettingsValues & TraectoryStyle;

export function createDefaultSettings(): TraectorySettings {
  const { colors } = getThemeStore();

  return {
    lineColor: colors.chartLineColor,
  };
}

export function getTraectorySettingsTabs(settings: TraectorySettings): SettingsTab[] {
  const fields: SettingField[] = [
    {
      key: 'lineColor',
      label: t('Line'),
      type: 'color',
      defaultValue: settings.lineColor,
      toolbar: {
        control: 'color',
        role: 'line',
      },
    },
  ];

  return [{ key: 'style', label: t('Style'), fields }];
}



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

import { CustomPriceAxisPaneView, CustomTimeAxisPaneView } from '@core/Drawings/axis';
import {
  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 { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';

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

import { TraectoryPaneView } from './paneView';

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

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

type TraectoryMode = 'idle' | 'drawing' | 'ready' | 'dragging-point' | 'dragging-body';
type TraectoryHandleId = `${number}`;

type TraectoryParams = BaseDrawingParams;

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

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

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

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

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

  protected settings: TraectorySettings = createDefaultSettings();
  protected mode: TraectoryMode = 'idle';

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

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

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

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

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

    this.paneView = new TraectoryPaneView(this);

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

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

    this.series.attachPrimitive(this);

    if (initialEvent && initialEvent.sourceEvent) {
      const point = this.getEventPoint(initialEvent.sourceEvent);
      this.startDrawing(point);
    }
  }

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

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

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

    const nextState = state as Partial<TraectoryState>;

    this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
    this.mode = nextState.mode ?? this.mode;
    this.points = Array.isArray(nextState.points) ? nextState.points : this.points;

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

    this.render();
  }

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

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

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

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

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

  public timeAxisViews() {
    return [];
  }

  public priceAxisViews() {
    return [];
  }

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

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

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

    if (!geometry) {
      return [];
    }

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

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

    const point = { x, y };

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

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

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

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

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

    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 handleClick = (event: MouseEvent): void => {
    if (this.hidden || this.mode !== 'drawing' || event.detail !== 1) {
      return;
    }

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

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

    this.appendPoint(point);
  };

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

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

      this.finishDrawing();
      return;
    }

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

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

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

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

    this.openSettings?.();
  };

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

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

    this.finishDrawing();
  };

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

    const point = this.getEventPoint(event);

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

      this.startDrawing(point);
      return;
    }

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

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

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

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

      this.select();
      return;
    }

    const pointIndex = this.getPointIndexAt(point);

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

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

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

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

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

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

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

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

    if (this.mode === 'dragging-point') {
      event.preventDefault();
      this.movePoint(point);
      this.render();
      return;
    }

    if (this.mode === 'dragging-body') {
      event.preventDefault();
      this.moveBody(point);
      this.render();
    }
  };

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

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

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

    if (!anchor) {
      return;
    }

    this.points = [anchor];
    this.previewAnchor = anchor;
    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.mode = 'ready';
    this.resolveReady?.();

    this.render();
  }

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

    this.dragPointerId = pointerId;
    this.dragStartPoint = point;
    this.dragPointIndex = pointIndex;
    this.dragGeometrySnapshot = this.getGeometry();

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

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

    this.dragPointerId = pointerId;
    this.dragStartPoint = point;
    this.dragPointIndex = null;
    this.dragGeometrySnapshot = this.getGeometry();

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

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

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

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

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

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

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

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

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

    const 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 handle = this.getDrawingHandleAtPoint(point);

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

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

    if (!geometry) {
      return false;
    }

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

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

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

      if (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 { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';

import type { VolumeProfile, VolumeProfileKind } from './volumeProfile';

const UI = {
  rowGap: 1,
  pocLineWidth: 2,
};

type DrawDirection = 'leftToRight' | 'rightToLeft';

export class VolumeProfilePaneRenderer implements IPrimitivePaneRenderer {
  private readonly volumeProfile: VolumeProfile;

  constructor(volumeProfile: VolumeProfile) {
    this.volumeProfile = volumeProfile;
  }

  public draw(target: CanvasRenderingTarget2D): void {
    const data = this.volumeProfile.getRenderData();

    if (!data) {
      return;
    }

    target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
      const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);

      const left = data.left * horizontalPixelRatio;
      const right = data.right * horizontalPixelRatio;
      const top = data.top * verticalPixelRatio;
      const bottom = data.bottom * verticalPixelRatio;

      context.save();

      context.fillStyle = data.areaFillColor;
      context.fillRect(left, top, right - left, bottom - top);

      const direction: DrawDirection = data.profileKind === 'visibleRange' ? 'rightToLeft' : 'leftToRight';
      const rowStartX = data.profileKind === 'visibleRange' ? right : left;

      data.rows.forEach((row) => {
        const rowTop = row.top * verticalPixelRatio;
        const rowHeight = row.height * verticalPixelRatio;
        const buyWidth = row.buyWidth * horizontalPixelRatio;
        const sellWidth = row.sellWidth * horizontalPixelRatio;

        drawVolumeRow(
          context,
          rowStartX,
          rowTop,
          rowHeight,
          buyWidth,
          sellWidth,
          pixelRatio,
          data.buyFillColor,
          data.sellFillColor,
          direction,
        );
      });

      if (data.pocY !== null) {
        const pocY = data.pocY * verticalPixelRatio;

        drawPocLine(context, data.profileKind, left, right, pocY, pixelRatio, data.pocLineColor);
      }

      context.restore();
    });
  }
}

function drawVolumeRow(
  context: CanvasRenderingContext2D,
  startX: number,
  top: number,
  height: number,
  buyWidth: number,
  sellWidth: number,
  pixelRatio: number,
  buyFillColor: string,
  sellFillColor: string,
  direction: DrawDirection,
): void {
  const safeHeight = Math.max(1, height - UI.rowGap * pixelRatio);

  if (buyWidth <= 0 && sellWidth <= 0) {
    return;
  }

  if (direction === 'rightToLeft') {
    const buyLeft = startX - buyWidth;
    const sellLeft = buyLeft - sellWidth;

    if (sellWidth > 0) {
      context.fillStyle = sellFillColor;
      context.fillRect(sellLeft, top, sellWidth, safeHeight);
    }

    if (buyWidth > 0) {
      context.fillStyle = buyFillColor;
      context.fillRect(buyLeft, top, buyWidth, safeHeight);
    }

    return;
  }

  if (buyWidth > 0) {
    context.fillStyle = buyFillColor;
    context.fillRect(startX, top, buyWidth, safeHeight);
  }

  if (sellWidth > 0) {
    context.fillStyle = sellFillColor;
    context.fillRect(startX + buyWidth, top, sellWidth, safeHeight);
  }
}

function drawPocLine(
  context: CanvasRenderingContext2D,
  profileKind: VolumeProfileKind,
  left: number,
  right: number,
  y: number,
  pixelRatio: number,
  color: string,
): void {
  const lineLeft = profileKind === 'visibleRange' ? 0 : left;
  const lineRight = profileKind === 'visibleRange' ? context.canvas.width : right;

  context.strokeStyle = color;
  context.lineWidth = UI.pocLineWidth * pixelRatio;

  context.beginPath();
  context.moveTo(lineLeft, y);
  context.lineTo(lineRight, y);
  context.stroke();
}



import { IPrimitivePaneRenderer, IPrimitivePaneView, PrimitivePaneViewZOrder } from 'lightweight-charts';

import { VolumeProfilePaneRenderer } from './paneRenderer';

import type { VolumeProfile } from './volumeProfile';

export class VolumeProfilePaneView implements IPrimitivePaneView {
  private readonly volumeProfile: VolumeProfile;

  constructor(volumeProfile: VolumeProfile) {
    this.volumeProfile = volumeProfile;
  }

  public update(): void {}

  public renderer(): IPrimitivePaneRenderer {
    return new VolumeProfilePaneRenderer(this.volumeProfile);
  }

  public zOrder(): PrimitivePaneViewZOrder {
    return 'top';
  }
}


import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { SettingField, SettingsTab, SettingsValues } from '@src/types';

export interface VolumeProfileStyle {
  areaFillColor: string;
  buyFillColor: string;
  sellFillColor: string;
  pocLineColor: string;
}

export type VolumeProfileSettings = SettingsValues & VolumeProfileStyle;

export function createDefaultSettings(): VolumeProfileSettings {
  const { colors } = getThemeStore();

  return {
    areaFillColor: colors.volumeProfileAreaFill,
    buyFillColor: colors.volumeProfileBuyFill,
    sellFillColor: colors.volumeProfileSellFill,
    pocLineColor: colors.volumeProfilePocLine,
  };
}

export function getVolumeProfileSettingsTabs(settings: VolumeProfileSettings): SettingsTab[] {
  const fields: SettingField[] = [
    {
      key: 'areaFillColor',
      label: t('Background'),
      type: 'color',
      defaultValue: settings.areaFillColor,
    },
    {
      key: 'buyFillColor',
      label: t('Growing volume'),
      type: 'color',
      defaultValue: settings.buyFillColor,
    },
    {
      key: 'sellFillColor',
      label: t('Decreasing volume'),
      type: 'color',
      defaultValue: settings.sellFillColor,
    },
    {
      key: 'pocLineColor',
      label: t('POC-line'),
      type: 'color',
      defaultValue: settings.pocLineColor,
    },
  ];

  return [
    {
      key: 'style',
      label: t('Style'),
      fields,
    },
  ];
}



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

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

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 { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, AxisSegment, Bounds, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type { ChartOptionsModel } from '@src/types';

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

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

interface VolumeProfileParams extends BaseDrawingParams {
  profileKind?: VolumeProfileKind;
}

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

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

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

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

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

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

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

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

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

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

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

  private visibleRangeStartRatio = 0;

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

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

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

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

    this.paneView = new VolumeProfilePaneView(this);

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

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

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

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

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

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

    if (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);

    if (initialEvent && initialEvent.sourceEvent) {
      const point = this.getEventPoint(initialEvent.sourceEvent);
      this.startDrawing(point);
    }
  }

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

    super.destroy();
  }

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

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

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

    return super.shouldShowInObjectTree();
  }

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

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

    const nextState = state as Partial<VolumeProfileState>;

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

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

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

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

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

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

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

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

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

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

    return [this.timeAxisPaneView];
  }

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

    return [this.priceAxisPaneView];
  }

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

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

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

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

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

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

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

    if (!geometry) {
      return [];
    }

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

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

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

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

  protected getTimeAxisSegments(): AxisSegment[] {
    if (this.profileKind === 'visibleRange' || (!this.isSelected() && !this.isCreationPending())) {
      return [];
    }

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

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

  protected getPriceAxisSegments(): AxisSegment[] {
    if (this.profileKind === 'visibleRange' || (!this.isSelected() && !this.isCreationPending())) {
      return [];
    }

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

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

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

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

    if (!dragTarget) {
      return null;
    }

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

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

  protected getTimeAxisLabel(kind: string): AxisLabel | null {
    if (this.profileKind === 'visibleRange') {
      return null;
    }

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

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

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

    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.isSelected() && !this.isCreationPending()) || (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.isSelected()) {
      return;
    }

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

    if (!this.getDragTarget(point)) {
      return;
    }

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

    this.openSettings?.();
  };

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

    const point = this.getEventPoint(event);

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

    this.handleFixedRangePointerDown(event, point);
  };

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

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

      return;
    }

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

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

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

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

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

  private handleFixedRangePointerDown(event: PointerEvent, point: Point): void {
    if (this.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.isSelected()) {
      if (!dragTarget) {
        return;
      }

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

      this.select();
      return;
    }

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

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

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

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

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

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

    return null;
  }

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

    if (handle) {
      return handle.id;
    }

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

    return null;
  }

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

    if (!geometry) {
      return false;
    }

    return isPointInBounds(point, geometry, BODY_HIT_TOLERANCE);
  }

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

    this.calculateFixedRangeVolumeProfile();
  }

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

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

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

    this.calculateProfileByLogicalRange(start, to);
  }

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

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

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

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

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

    this.calculateProfileByCandles(selectedCandles);
  }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

      if (volume <= 0) {
        return;
      }

      const price = this.getCandlePrice(candle);

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

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

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

      const isBuyVolume = this.isBuyVolume(candle);

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

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

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

        if (overlap <= 0) {
          return;
        }

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

    this.profileRows = profileRows;
  }

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

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

    return rows;
  }

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

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

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

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

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

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

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

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

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

    return close >= open;
  }

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

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

    return this.getFixedRangeVolumeProfileGeometry();
  }

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

    const { width, height } = 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;
}