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


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,
  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 { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';

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

import type { 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, SettingsValues } from '@src/types';
import type { IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';

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

type FibonacciRetracementParams = BaseDrawingParams;

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

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

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

interface FibonacciRetracementGeometry {
  startPoint: Point;
  endPoint: Point;
  left: number;
  right: number;
  top: number;
  bottom: number;
  width: number;
  height: number;
  levels: FibonacciRetracementLevelRenderData[];
  areas: FibonacciRetracementAreaRenderData[];
}

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

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

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

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

  protected settings: FibonacciRetracementSettings = createDefaultSettings();
  protected mode: FibonacciRetracementMode = 'idle';

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

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

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

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

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

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

    this.paneView = new FibonacciRetracementPaneView(this);

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

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

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

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

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

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

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

    this.series.attachPrimitive(this);

    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(): FibonacciRetracementState {
    return {
      hidden: this.hidden,
      mode: this.mode,
      startTime: this.startTime,
      endTime: this.endTime,
      startPrice: this.startPrice,
      endPrice: this.endPrice,
      settings: cloneFibonacciRetracementSettings(this.settings),
    };
  }

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

    const next = state as Partial<FibonacciRetracementState>;

    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.startPrice = next.startPrice ?? this.startPrice;
    this.endPrice = next.endPrice ?? this.endPrice;

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

    this.render();
  }

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

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

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

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

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

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

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

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

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

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

    if (!geometry) {
      return null;
    }

    return {
      ...geometry,
      showBackground: this.settings.showBackground,
      backgroundOpacity: this.settings.backgroundOpacity / PERCENT_DIVIDER,
      reverse: this.settings.reverse,
      labelsPosition: this.settings.labelsPosition,
      showPrices: this.settings.showPrices,
      showLevelValues: this.settings.showLevelValues,
      fontSize: this.settings.fontSize,
      isBold: this.settings.isBold,
      isItalic: this.settings.isItalic,
    };
  }

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

    if (!geometry) {
      return [];
    }

    return [
      {
        id: 'end',
        ...geometry.endPoint,
      },
      {
        id: 'start',
        ...geometry.startPoint,
      },
    ];
  }

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

    const point = { x, y };

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

    if (this.isSelected() && this.getDrawingHandleAtPoint(point)) {
      return {
        cursorStyle: 'pointer',
        externalId: 'fibonacci-retracement-position',
        zOrder: 'top',
      };
    }

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

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

  protected getTimeAxisSegments(): AxisSegment[] {
    const bounds = this.isSelected() || this.isCreationPending() ? this.getTimeBounds() : null;

    if (!bounds) {
      return [];
    }

    const { colors } = getThemeStore();

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

  protected getPriceAxisSegments(): AxisSegment[] {
    const bounds = this.isSelected() || this.isCreationPending() ? this.getPriceBounds() : null;

    if (!bounds) {
      return [];
    }

    const { colors } = getThemeStore();

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

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

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

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

    const { colors } = getThemeStore();

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

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

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

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

    const { colors } = getThemeStore();

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

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

    const 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);
    } else {
      this.resize(point);
    }

    this.render();
  };

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

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

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

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

    if (!anchor) {
      return;
    }

    this.startTime = anchor.time;
    this.endTime = anchor.time;
    this.startPrice = anchor.price;
    this.endPrice = anchor.price;
    this.mode = 'drawing';

    this.render();
  }

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

    if (!anchor) {
      return;
    }

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

    this.render();
  }

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

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

      this.resetToIdle();
      return;
    }

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

    this.render();
  }

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

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

    this.render();
  }

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

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

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

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

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

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

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

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

    const containerSize = this.getContainerSize();

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

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

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

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

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

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

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

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

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

    if (!anchor) {
      return;
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

      return result;
    }, []);
  }

  private getLevelPrice(value: number): number {
    const startPrice = this.startPrice ?? 0;
    const endPrice = this.endPrice ?? 0;

    return this.settings.reverse
      ? startPrice + (endPrice - startPrice) * value
      : endPrice + (startPrice - endPrice) * value;
  }

  private getAreas(levels: FibonacciRetracementLevelRenderData[]): FibonacciRetracementAreaRenderData[] {
    if (!this.settings.showBackground || levels.length < 2) {
      return [];
    }

    const orderedLevels = [...levels].sort((a, b) => a.value - b.value);

    return orderedLevels.slice(0, -1).map((level, index) => {
      const nextLevel = orderedLevels[index + 1];

      return {
        top: Math.min(level.y, nextLevel.y),
        bottom: Math.max(level.y, nextLevel.y),
        color: nextLevel.color,
      };
    });
  }

  private getLevelText(value: number, price: number): string {
    const parts: string[] = [];

    if (this.settings.showLevelValues) {
      parts.push(formatLevelLabel(value));
    }

    if (this.settings.showPrices) {
      parts.push(`(${formatPrice(price) ?? String(price)})`);
    }

    return parts.join(' ');
  }

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

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

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

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

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

    if (!geometry) {
      return null;
    }

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

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

    if (!geometry) {
      return null;
    }

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

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

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

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

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

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

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

    if (!geometry) {
      return null;
    }

    const targetY = kind === 'top' ? geometry.top : geometry.bottom;
    const edgeLevel = geometry.levels.find((level) => level.y === targetY);

    if (edgeLevel) {
      return edgeLevel.price;
    }

    return kind === 'top'
      ? Math.max(this.startPrice ?? 0, this.endPrice ?? 0)
      : Math.min(this.startPrice ?? 0, this.endPrice ?? 0);
  }

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

    if (!geometry) {
      return false;
    }

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

    if (this.settings.showBackground && isPointInBounds(point, bounds, BODY_HIT_TOLERANCE)) {
      return true;
    }

    const xInRange = point.x >= geometry.left - LINE_HIT_TOLERANCE && point.x <= geometry.right + LINE_HIT_TOLERANCE;

    return xInRange && geometry.levels.some((level) => Math.abs(point.y - level.y) <= LINE_HIT_TOLERANCE);
  }

  private shiftTime(time: Time, offsetX: number): Time | null {
    return shiftTimeByPixels(this.chart, time, offsetX, this.series);
  }

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

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

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



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

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

import { FibonacciRetracement } from './fibonacciRetracement';

const UI = {
  lineWidth: 1,
  textOffset: 4,
  controlLineWidth: 1,
};

export class FibonacciRetracementPaneRenderer implements IPrimitivePaneRenderer {
  private readonly fibonacciRetracement: FibonacciRetracement;

  constructor(fibonacciRetracement: FibonacciRetracement) {
    this.fibonacciRetracement = fibonacciRetracement;
  }

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

    if (!data) {
      return;
    }

    const { colors } = getThemeStore();

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

      const left = data.left * horizontalPixelRatio;
      const right = data.right * horizontalPixelRatio;

      context.save();

      if (data.showBackground) {
        data.areas.forEach((area) => {
          drawArea(context, {
            left,
            right,
            top: area.top * verticalPixelRatio,
            bottom: area.bottom * verticalPixelRatio,
            color: area.color,
            opacity: data.backgroundOpacity,
          });
        });
      }

      data.levels.forEach((level) => {
        const y = level.y * verticalPixelRatio;

        drawHorizontalLine(context, {
          left,
          right,
          y,
          color: level.color,
          pixelRatio,
        });

        if (level.text) {
          drawText(context, {
            text: level.text,
            x:
              data.labelsPosition === 'left'
                ? left - UI.textOffset * horizontalPixelRatio
                : right + UI.textOffset * horizontalPixelRatio,
            y,
            color: level.color,
            fontSize: data.fontSize * pixelRatio,
            isBold: data.isBold,
            isItalic: data.isItalic,
            align: data.labelsPosition === 'left' ? 'right' : 'left',
          });
        }
      });

      drawControlLine(context, {
        startX: data.startPoint.x * horizontalPixelRatio,
        startY: data.startPoint.y * verticalPixelRatio,
        endX: data.endPoint.x * horizontalPixelRatio,
        endY: data.endPoint.y * verticalPixelRatio,
        color: colors.chartCrosshairLine,
        pixelRatio,
      });

      context.restore();
    });
  }
}

function drawArea(
  context: CanvasRenderingContext2D,
  params: {
    left: number;
    right: number;
    top: number;
    bottom: number;
    color: string;
    opacity: number;
  },
): void {
  const { left, right, top, bottom, color, opacity } = params;

  if (bottom <= top) {
    return;
  }

  context.save();

  context.globalAlpha = opacity;
  context.fillStyle = color;
  context.fillRect(left, top, right - left, bottom - top);

  context.restore();
}

function drawHorizontalLine(
  context: CanvasRenderingContext2D,
  params: {
    left: number;
    right: number;
    y: number;
    color: string;
    pixelRatio: number;
  },
): void {
  const { left, right, y, color, pixelRatio } = params;

  context.save();

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

  context.beginPath();
  context.moveTo(left, y);
  context.lineTo(right, y);
  context.stroke();

  context.restore();
}

function drawControlLine(
  context: CanvasRenderingContext2D,
  params: {
    startX: number;
    startY: number;
    endX: number;
    endY: number;
    color: string;
    pixelRatio: number;
  },
): void {
  const { startX, startY, endX, endY, color, pixelRatio } = params;

  context.save();

  context.strokeStyle = color;
  context.lineWidth = UI.controlLineWidth * pixelRatio;
  context.setLineDash([6 * pixelRatio, 6 * pixelRatio]);

  context.beginPath();
  context.moveTo(startX, startY);
  context.lineTo(endX, endY);
  context.stroke();

  context.restore();
}

function drawText(
  context: CanvasRenderingContext2D,
  params: {
    text: string;
    x: number;
    y: number;
    color: string;
    fontSize: number;
    isBold: boolean;
    isItalic: boolean;
    align: CanvasTextAlign;
  },
): void {
  const { text, x, y, color, fontSize, isBold, isItalic, align } = params;

  const fontWeight = isBold ? '700 ' : '';
  const fontStyle = isItalic ? 'italic ' : '';

  context.save();

  context.font = `${fontStyle}${fontWeight}${fontSize}px Inter, sans-serif`;
  context.fillStyle = color;
  context.textAlign = align;
  context.textBaseline = 'middle';

  context.fillText(text, x, y);

  context.restore();
}



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

import { FibonacciRetracement } from './fibonacciRetracement';
import { FibonacciRetracementPaneRenderer } from './paneRenderer';

export class FibonacciRetracementPaneView implements IPrimitivePaneView {
  private readonly fibonacciRetracement: FibonacciRetracement;

  constructor(fibonacciRetracement: FibonacciRetracement) {
    this.fibonacciRetracement = fibonacciRetracement;
  }

  public update(): void {}

  public renderer(): IPrimitivePaneRenderer {
    return new FibonacciRetracementPaneRenderer(this.fibonacciRetracement);
  }

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



import cloneDeep from 'lodash-es/cloneDeep';

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

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

export type FibonacciRetracementLabelPosition = 'left' | 'right';

export interface FibonacciRetracementLevelSettings {
  id: string;
  value: number;
  visible: boolean;
  color: string;
}

export interface FibonacciRetracementSettings {
  levels: FibonacciRetracementLevelSettings[];

  showBackground: boolean;
  backgroundOpacity: number;
  reverse: boolean;

  labelsPosition: FibonacciRetracementLabelPosition;
  showPrices: boolean;
  showLevelValues: boolean;
  fontSize: number;
  isBold: boolean;
  isItalic: boolean;
}

const FIBONACCI_LEVEL_COLORS = {
  gray: '#787B86',
  red: '#F23645',
  orange: '#FF9800',
  green: '#4CAF50',
  teal: '#089981',
  cyan: '#00BCD4',
  blue: '#2962FF',
  pink: '#E91E63',
  purple: '#9C27B0',
};

const DEFAULT_LEVELS: FibonacciRetracementLevelSettings[] = [
  { id: '0', value: 0, visible: true, color: FIBONACCI_LEVEL_COLORS.gray },
  { id: '0.236', value: 0.236, visible: true, color: FIBONACCI_LEVEL_COLORS.red },
  { id: '0.382', value: 0.382, visible: true, color: FIBONACCI_LEVEL_COLORS.orange },
  { id: '0.5', value: 0.5, visible: true, color: FIBONACCI_LEVEL_COLORS.green },
  { id: '0.618', value: 0.618, visible: true, color: FIBONACCI_LEVEL_COLORS.teal },
  { id: '0.786', value: 0.786, visible: true, color: FIBONACCI_LEVEL_COLORS.cyan },
  { id: '1', value: 1, visible: true, color: FIBONACCI_LEVEL_COLORS.gray },
  { id: '1.618', value: 1.618, visible: true, color: FIBONACCI_LEVEL_COLORS.blue },
  { id: '2.618', value: 2.618, visible: true, color: FIBONACCI_LEVEL_COLORS.pink },
  { id: '3.618', value: 3.618, visible: true, color: FIBONACCI_LEVEL_COLORS.purple },
  { id: '4.236', value: 4.236, visible: true, color: FIBONACCI_LEVEL_COLORS.red },
];

export function createDefaultSettings(): FibonacciRetracementSettings {
  return {
    levels: cloneDeep(DEFAULT_LEVELS),

    showBackground: true,
    backgroundOpacity: 18,
    reverse: false,

    labelsPosition: 'left',
    showPrices: true,
    showLevelValues: true,
    fontSize: 12,
    isBold: false,
    isItalic: false,
  };
}

export function cloneFibonacciRetracementSettings(
  settings: FibonacciRetracementSettings,
): FibonacciRetracementSettings {
  return cloneDeep(settings);
}

export function mergeFibonacciRetracementSettings(
  current: FibonacciRetracementSettings,
  next: Partial<FibonacciRetracementSettings> | SettingsValues,
): FibonacciRetracementSettings {
  const patch = next as Partial<FibonacciRetracementSettings>;
  const values = next as SettingsValues;

  const settings: FibonacciRetracementSettings = {
    ...current,

    showBackground: patch.showBackground ?? current.showBackground,
    backgroundOpacity: patch.backgroundOpacity ?? current.backgroundOpacity,
    reverse: patch.reverse ?? current.reverse,

    labelsPosition: patch.labelsPosition ?? current.labelsPosition,
    showPrices: patch.showPrices ?? current.showPrices,
    showLevelValues: patch.showLevelValues ?? current.showLevelValues,
    fontSize: patch.fontSize ?? current.fontSize,
    isBold: patch.isBold ?? current.isBold,
    isItalic: patch.isItalic ?? current.isItalic,

    levels: mergeLevels(current.levels, patch.levels),
  };

  settings.levels = settings.levels.map((level) => ({
    ...level,
    visible: (values[getLevelVisibleFieldKey(level.id)] as boolean | undefined) ?? level.visible,
    color: (values[getLevelColorFieldKey(level.id)] as string | undefined) ?? level.color,
  }));

  return settings;
}

export function getFibonacciRetracementSettingsValues(settings: FibonacciRetracementSettings): SettingsValues {
  return {
    showBackground: settings.showBackground,
    backgroundOpacity: settings.backgroundOpacity,
    reverse: settings.reverse,

    labelsPosition: settings.labelsPosition,
    showPrices: settings.showPrices,
    showLevelValues: settings.showLevelValues,
    fontSize: settings.fontSize,
    isBold: settings.isBold,
    isItalic: settings.isItalic,

    ...settings.levels.reduce<SettingsValues>((result, level) => {
      result[getLevelVisibleFieldKey(level.id)] = level.visible;
      result[getLevelColorFieldKey(level.id)] = level.color;

      return result;
    }, {}),
  };
}

export function getFibonacciRetracementSettingsTabs(settings: FibonacciRetracementSettings): SettingsTab[] {
  const styleFields: SettingField[] = [
    {
      key: 'backgroundOpacity',
      label: t('Background transparency'),
      type: 'range',
      defaultValue: settings.backgroundOpacity,
      min: 0,
      max: 100,
      step: 1,
      suffix: '%',
    },
    {
      key: 'showBackground',
      label: t('Background'),
      type: 'boolean',
      defaultValue: settings.showBackground,
    },
    {
      key: 'reverse',
      label: t('Reverse'),
      type: 'boolean',
      defaultValue: settings.reverse,
    },
  ];

  const textFields: SettingField[] = [
    {
      key: 'labelsPosition',
      label: t('Labels position'),
      type: 'select',
      defaultValue: settings.labelsPosition,
      options: [
        { label: t('Left'), value: 'left' },
        { label: t('Right'), value: 'right' },
      ],
    },
    {
      key: 'showPrices',
      label: t('Show prices'),
      type: 'boolean',
      defaultValue: settings.showPrices,
    },
    {
      key: 'showLevelValues',
      label: t('Show level values'),
      type: 'boolean',
      defaultValue: settings.showLevelValues,
    },
    {
      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,
    },
  ];

  const levelFields: SettingField[] = settings.levels.flatMap((level) => [
    {
      key: getLevelVisibleFieldKey(level.id),
      label: formatLevelLabel(level.value),
      type: 'boolean',
      defaultValue: level.visible,
    },
    {
      key: getLevelColorFieldKey(level.id),
      label: `${t('Level')} ${formatLevelLabel(level.value)}`,
      type: 'color',
      defaultValue: level.color,
    },
  ]);

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

export function getVisibleFibonacciLevels(settings: FibonacciRetracementSettings): FibonacciRetracementLevelSettings[] {
  return settings.levels.filter((level) => level.visible);
}

export function formatLevelLabel(value: number): string {
  return String(Number(value.toFixed(3))).replace('.', ',');
}

function mergeLevels(
  currentLevels: FibonacciRetracementLevelSettings[],
  nextLevels?: FibonacciRetracementLevelSettings[],
): FibonacciRetracementLevelSettings[] {
  if (!nextLevels) {
    return cloneDeep(currentLevels);
  }

  const nextLevelsMap = new Map(nextLevels.map((level) => [level.id, level]));

  return currentLevels.map((level) => ({
    ...level,
    ...nextLevelsMap.get(level.id),
  }));
}

function getLevelVisibleFieldKey(id: string): string {
  return `level_${normalizeLevelId(id)}_visible`;
}

function getLevelColorFieldKey(id: string): string {
  return `level_${normalizeLevelId(id)}_color`;
}

function normalizeLevelId(id: string): string {
  return id.replace(/\./g, '_');
}



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

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

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

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

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

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

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

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

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

export interface LineDrawingRenderData extends LineDrawingGeometry, LineDrawingStyle, LineDrawingTextStyle {}

const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;

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

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

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

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

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

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

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

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

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

    this.settings = createDefaultSettings(this.defaultMarkers);

    this.paneView = new LineDrawingPaneView(this);

    this.timeAxisPaneView = 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(): LineDrawingState {
    return {
      hidden: this.hidden,
      mode: this.mode,
      startAnchor: this.startAnchor,
      endAnchor: this.endAnchor,
      settings: { ...this.settings },
    };
  }

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

    const nextState = state as Partial<LineDrawingState>;

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

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

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

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

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

    this.render();
  }

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

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

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

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

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

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

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

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

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

    if (!geometry) {
      return [];
    }

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

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

    const point = { x, y };

    if (this.getDrawingHandleAtPoint(point)) {
      return {
        cursorStyle: 'move',
        externalId: 'line-drawing',
        zOrder: 'top',
      };
    }

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

    return {
      cursorStyle: 'grab',
      externalId: 'line-drawing',
      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 getTimeAxisLabel(kind: string): AxisLabel | null {
    if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'start' && kind !== 'end')) {
      return null;
    }

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

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

    const { colors } = getThemeStore();

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

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

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

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

    const { colors } = getThemeStore();

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

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

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

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

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

    this.openSettings?.();
  };

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

    const point = this.getEventPoint(event);

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

      this.startDrawing(point);
      return;
    }

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

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

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

    const pointTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
    const isNearLine = this.isPointNearLine(point);
    const isDrawingHit = pointTarget !== null || isNearLine;

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

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

      this.select();
      return;
    }

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

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

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

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

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

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

    this.deselect();
  };

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

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

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

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

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

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

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

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

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

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

    if (!anchor) {
      return;
    }

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

    this.render();
  }

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

    if (!anchor) {
      return;
    }

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

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

    if (!geometry) {
      return;
    }

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

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

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

    this.render();
  }

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

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

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

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

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

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

    if (!anchor) {
      return;
    }

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

    if (this.mode === 'dragging-end') {
      this.endAnchor = anchor;
    }
  }

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

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

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

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

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

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

    this.endAnchor = {
      time: nextEndTime,
      price: snapshot.endAnchor.price + priceOffset,
    };
  }

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

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

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

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

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

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

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

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

    if (!geometry) {
      return false;
    }

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

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

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

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

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

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

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

    if (!anchor) {
      return null;
    }

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

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

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

    if (!anchor) {
      return null;
    }

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

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

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

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

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

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

    if (!anchor) {
      return '';
    }

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



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

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

import { LineDrawing } from './lineDrawing';

const UI = {
  lineWidth: 2,
  arrowLength: 12,
  arrowAngle: Math.PI / 6,
  textLineHeightMultiplier: 1.2,
  textOffset: 4,
};

export class LineDrawingPaneRenderer implements IPrimitivePaneRenderer {
  private readonly lineDrawing: LineDrawing;

  constructor(lineDrawing: LineDrawing) {
    this.lineDrawing = lineDrawing;
  }

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

    if (!data) {
      return;
    }

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

      const startX = data.startPoint.x * horizontalPixelRatio;
      const startY = data.startPoint.y * verticalPixelRatio;
      const endX = data.endPoint.x * horizontalPixelRatio;
      const endY = data.endPoint.y * verticalPixelRatio;

      context.save();

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

      context.beginPath();
      context.moveTo(startX, startY);
      context.lineTo(endX, endY);
      context.stroke();

      if (data.startMarker === LineMarker.arrow) {
        drawArrowHead(context, {
          fromX: endX,
          fromY: endY,
          toX: startX,
          toY: startY,
          pixelRatio,
        });
      }

      if (data.endMarker === LineMarker.arrow) {
        drawArrowHead(context, {
          fromX: startX,
          fromY: startY,
          toX: endX,
          toY: endY,
          pixelRatio,
        });
      }

      if (data.text.trim()) {
        drawTextAlongLine(context, {
          startX,
          startY,
          endX,
          endY,
          text: data.text,
          fontSize: data.fontSize,
          isBold: data.isBold,
          isItalic: data.isItalic,
          textColor: data.textColor,
          horizontalPixelRatio,
          verticalPixelRatio,
        });
      }

      context.restore();
    });
  }
}

function drawArrowHead(
  context: CanvasRenderingContext2D,
  {
    fromX,
    fromY,
    toX,
    toY,
    pixelRatio,
  }: {
    fromX: number;
    fromY: number;
    toX: number;
    toY: number;
    pixelRatio: number;
  },
): void {
  const angle = Math.atan2(toY - fromY, toX - fromX);
  const length = UI.arrowLength * pixelRatio;

  const leftX = toX - length * Math.cos(angle - UI.arrowAngle);
  const leftY = toY - length * Math.sin(angle - UI.arrowAngle);
  const rightX = toX - length * Math.cos(angle + UI.arrowAngle);
  const rightY = toY - length * Math.sin(angle + UI.arrowAngle);

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

function drawTextAlongLine(
  context: CanvasRenderingContext2D,
  params: {
    startX: number;
    startY: number;
    endX: number;
    endY: number;
    text: string;
    fontSize: number;
    isBold: boolean;
    isItalic: boolean;
    textColor: string;
    horizontalPixelRatio: number;
    verticalPixelRatio: number;
  },
): void {
  const { startX, startY, endX, endY, text, fontSize, isBold, isItalic, textColor, verticalPixelRatio } = params;

  const lines = text.split('\n');
  const safeFontSize = Math.max(1, fontSize);
  const fontSizePx = safeFontSize * verticalPixelRatio;
  const lineHeight = safeFontSize * UI.textLineHeightMultiplier * verticalPixelRatio;

  const dx = endX - startX;
  const dy = endY - startY;

  let angle = Math.atan2(dy, dx);

  if (angle > Math.PI / 2 || angle < -Math.PI / 2) {
    angle += Math.PI;
  }

  const centerX = (startX + endX) / 2;
  const centerY = (startY + endY) / 2;

  const fontWeight = isBold ? '700 ' : '';
  const fontStyle = isItalic ? 'italic ' : '';

  context.save();
  context.translate(centerX, centerY);
  context.rotate(angle);

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

  const blockHeight = lines.length * lineHeight;
  const textOffset = UI.textOffset * verticalPixelRatio;
  const textCenterY = -(blockHeight / 2 + textOffset);
  const startLineY = textCenterY - blockHeight / 2 + lineHeight / 2;

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

  context.restore();
}



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

import { LineDrawing } from './lineDrawing';
import { LineDrawingPaneRenderer } from './paneRenderer';

export class LineDrawingPaneView implements IPrimitivePaneView {
  private readonly lineDrawing: LineDrawing;

  constructor(lineDrawing: LineDrawing) {
    this.lineDrawing = lineDrawing;
  }

  public update(): void {}

  public renderer(): IPrimitivePaneRenderer {
    return new LineDrawingPaneRenderer(this.lineDrawing);
  }

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



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

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

export interface LineDrawingMarkers {
  startMarker: LineMarker;
  endMarker: LineMarker;
}

export interface LineDrawingStyle extends LineDrawingMarkers {
  lineColor: string;
}

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

export type LineDrawingSettings = LineDrawingStyle & LineDrawingTextStyle & SettingsValues;

const DEFAULT_MARKERS: LineDrawingMarkers = {
  startMarker: LineMarker.normal,
  endMarker: LineMarker.normal,
};

export function createDefaultSettings(markers: Partial<LineDrawingMarkers> = {}): LineDrawingSettings {
  const { colors } = getThemeStore();

  return {
    ...DEFAULT_MARKERS,
    ...markers,
    lineColor: colors.chartLineColor,
    fontSize: 14,
    text: '',
    isBold: false,
    isItalic: false,
    textColor: colors.chartLineColor,
  };
}

export function getLineDrawingSettingsTabs(settings: LineDrawingSettings): SettingsTab[] {
  const markerOptions = [
    {
      label: t('Normal'),
      value: LineMarker.normal,
    },
    {
      label: t('Arrow'),
      value: LineMarker.arrow,
    },
  ];

  const styleFields: SettingField[] = [
    {
      key: 'lineColor',
      label: t('Line'),
      type: 'color',
      defaultValue: settings.lineColor,
      toolbar: {
        control: 'color',
        role: 'line',
      },
    },
    {
      key: 'startMarker',
      label: t('Line start'),
      type: 'select',
      defaultValue: settings.startMarker,
      options: markerOptions,
    },
    {
      key: 'endMarker',
      label: t('Line end'),
      type: 'select',
      defaultValue: settings.endMarker,
      options: markerOptions,
    },
  ];

  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 { IPrimitivePaneView, PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
  getAnchorFromPoint,
  getPriceDelta as getPriceDeltaFromCoordinates,
  getPriceFromYCoordinate,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  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 { ParallelChannelPaneView } from './paneView';
import {
  createDefaultSettings,
  getParallelChannelSettingsTabs,
  ParallelChannelSettings,
  ParallelChannelStyle,
  ParallelChannelTextStyle,
} from './settings';

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

type ParallelChannelMode = 'idle' | 'drawing-line' | 'drawing-channel' | 'ready' | 'dragging';

type ParallelChannelDragTarget =
  | 'main-start'
  | 'main-middle'
  | 'main-end'
  | 'parallel-start'
  | 'parallel-middle'
  | 'parallel-end'
  | 'body';

type ParallelChannelHandleKey = Exclude<ParallelChannelDragTarget, 'body'>;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'main-start' | 'main-end' | 'parallel-start' | 'parallel-end';

type ParallelChannelParams = BaseDrawingParams;

interface ParallelChannelState {
  hidden: boolean;
  mode: ParallelChannelMode;
  startAnchor: Anchor | null;
  endAnchor: Anchor | null;
  priceOffset: number | null;
  settings: ParallelChannelSettings;
}

interface ParallelChannelGeometry {
  startPoint: Point;
  mainMiddlePoint: Point;
  endPoint: Point;
  parallelStartPoint: Point;
  parallelMiddlePoint: Point;
  parallelEndPoint: Point;
  middleStartPoint: Point;
  middleEndPoint: Point;
  left: number;
  right: number;
  top: number;
  bottom: number;
}

export interface ParallelChannelRenderData
  extends ParallelChannelGeometry,
    ParallelChannelStyle,
    ParallelChannelTextStyle {}

const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;
const MIN_CHANNEL_WIDTH = 4;
const VERTICAL_LINE_TOLERANCE = 0.001;

export class ParallelChannel
  extends SeriesDrawingBase<ParallelChannelSettings, ParallelChannelHandleKey>
  implements ISeriesDrawing
{
  private openSettings?: () => void;

  protected settings: ParallelChannelSettings = createDefaultSettings();
  protected mode: ParallelChannelMode = 'idle';

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

  private activeDragTarget: ParallelChannelDragTarget | null = null;
  private dragPointerId: number | null = null;
  private dragStartPoint: Point | null = null;
  private dragStateSnapshot: ParallelChannelState | null = null;

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

  private paneView: ParallelChannelPaneView;
  private timeAxisPaneView: CustomTimeAxisPaneView;
  private priceAxisPaneView: CustomPriceAxisPaneView;

  private startTimeAxisView: CustomTimeAxisView;
  private endTimeAxisView: CustomTimeAxisView;

  private mainStartPriceAxisView: CustomPriceAxisView;
  private mainEndPriceAxisView: CustomPriceAxisView;
  private parallelStartPriceAxisView: CustomPriceAxisView;
  private parallelEndPriceAxisView: CustomPriceAxisView;

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

    this.openSettings = openSettings;

    this.paneView = new ParallelChannelPaneView(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.mainStartPriceAxisView = new CustomPriceAxisView({
      getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
      labelKind: 'main-start',
    });

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

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

    this.parallelEndPriceAxisView = new CustomPriceAxisView({
      getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
      labelKind: 'parallel-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-line' || this.mode === 'drawing-channel';
  }

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

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

    const nextState = state as Partial<ParallelChannelState>;

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

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

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

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

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

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

    this.render();
  }

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

  public updateAllViews(): void {
    updateViews([
      this.paneView,
      this.timeAxisPaneView,
      this.priceAxisPaneView,
      this.startTimeAxisView,
      this.endTimeAxisView,
      this.mainStartPriceAxisView,
      this.mainEndPriceAxisView,
      this.parallelStartPriceAxisView,
      this.parallelEndPriceAxisView,
    ]);
  }

  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.mainStartPriceAxisView,
      this.mainEndPriceAxisView,
      this.parallelStartPriceAxisView,
      this.parallelEndPriceAxisView,
    ];
  }

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

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

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

    if (!geometry) {
      return [];
    }

    return [
      {
        id: 'parallel-end',
        ...geometry.parallelEndPoint,
        shape: 'circle',
        borderWidth: 2,
      },
      {
        id: 'parallel-middle',
        ...geometry.parallelMiddlePoint,
        shape: 'circle',
        borderWidth: 2,
      },
      {
        id: 'parallel-start',
        ...geometry.parallelStartPoint,
        shape: 'circle',
        borderWidth: 2,
      },
      {
        id: 'main-end',
        ...geometry.endPoint,
        shape: 'circle',
        borderWidth: 2,
      },
      {
        id: 'main-middle',
        ...geometry.mainMiddlePoint,
        shape: 'circle',
        borderWidth: 2,
      },
      {
        id: 'main-start',
        ...geometry.startPoint,
        shape: 'circle',
        borderWidth: 2,
      },
    ];
  }

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

    const point = { x, y };
    const pointTarget = this.getDrawingHandleAtPoint(point);
    const isChannelHit = this.isPointOnChannel(point);

    if (!pointTarget && !isChannelHit) {
      return null;
    }

    if (!this.isSelected()) {
      return {
        cursorStyle: 'pointer',
        externalId: 'parallel-channel',
        zOrder: 'top',
      };
    }

    return {
      cursorStyle: pointTarget ? 'move' : 'grab',
      externalId: 'parallel-channel',
      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 getTimeAxisLabel(kind: string): AxisLabel | 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.isSelected() && !this.isCreationPending()) || !isPriceLabelKind(kind)) {
      return null;
    }

    const price = this.getPriceLabelValue(kind);

    if (price === null) {
      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,
    };
  }

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

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

    if (!this.isPointOnChannel(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-line') {
      event.preventDefault();
      event.stopPropagation();

      this.setEndAnchor(point);

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

      this.priceOffset = 0;
      this.mode = 'drawing-channel';

      this.render();
      return;
    }

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

      this.setPriceOffset(point);

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

      this.finishDrawing();
      return;
    }

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

    const pointTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
    const isChannelHit = this.isPointOnChannel(point);
    const isDrawingHit = pointTarget !== null || isChannelHit;

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

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

      this.select();
      return;
    }

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

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

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

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

    this.deselect();
  };

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

    if (this.mode === 'drawing-line') {
      this.setEndAnchor(point);
      this.render();
      return;
    }

    if (this.mode === 'drawing-channel') {
      this.setPriceOffset(point);
      this.render();
      return;
    }

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

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

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

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

    this.finishDragging();
  };

  protected getGeometry(): ParallelChannelGeometry | null {
    if (!this.startAnchor || !this.endAnchor || this.priceOffset === null) {
      return null;
    }

    const startPoint = this.getAnchorPoint(this.startAnchor);
    const endPoint = this.getAnchorPoint(this.endAnchor);

    const parallelStartPoint = this.getAnchorPoint({
      time: this.startAnchor.time,
      price: this.startAnchor.price + this.priceOffset,
    });

    const parallelEndPoint = this.getAnchorPoint({
      time: this.endAnchor.time,
      price: this.endAnchor.price + this.priceOffset,
    });

    if (!startPoint || !endPoint || !parallelStartPoint || !parallelEndPoint) {
      return null;
    }

    const mainMiddlePoint = getMiddlePoint(startPoint, endPoint);
    const parallelMiddlePoint = getMiddlePoint(parallelStartPoint, parallelEndPoint);
    const middleStartPoint = getMiddlePoint(startPoint, parallelStartPoint);
    const middleEndPoint = getMiddlePoint(endPoint, parallelEndPoint);
    const points = [startPoint, endPoint, parallelStartPoint, parallelEndPoint];

    return {
      startPoint,
      mainMiddlePoint,
      endPoint,
      parallelStartPoint,
      parallelMiddlePoint,
      parallelEndPoint,
      middleStartPoint,
      middleEndPoint,
      left: Math.min(...points.map((point) => point.x)),
      right: Math.max(...points.map((point) => point.x)),
      top: Math.min(...points.map((point) => point.y)),
      bottom: Math.max(...points.map((point) => point.y)),
    };
  }

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

    if (!anchor) {
      return;
    }

    this.startAnchor = anchor;
    this.endAnchor = anchor;
    this.priceOffset = 0;
    this.mode = 'drawing-line';

    this.render();
  }

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

    this.render();
  }

  private startDragging(target: ParallelChannelDragTarget, point: Point, pointerId: number): void {
    this.mode = 'dragging';
    this.activeDragTarget = target;
    this.dragPointerId = pointerId;
    this.dragStartPoint = point;
    this.dragStateSnapshot = this.getState();

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

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

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

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

  private applyDrag(point: Point): void {
    switch (this.activeDragTarget) {
      case 'main-start':
        this.moveMainEdge('start', point);
        break;

      case 'main-middle':
        this.moveMainMiddle(point);
        break;

      case 'main-end':
        this.moveMainEdge('end', point);
        break;

      case 'parallel-start':
        this.moveParallelEdge('start', point);
        break;

      case 'parallel-middle':
        this.moveParallelMiddle(point);
        break;

      case 'parallel-end':
        this.moveParallelEdge('end', point);
        break;

      case 'body':
        this.moveBody(point);
        break;

      default:
        break;
    }
  }

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

    if (!anchor) {
      return;
    }

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

    if (kind === 'start') {
      this.startAnchor = anchor;
    } else {
      this.endAnchor = anchor;
    }

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

    if (kind === 'start') {
      this.startAnchor = previousAnchor;
    } else {
      this.endAnchor = previousAnchor;
    }
  }

  private moveParallelEdge(kind: TimeLabelKind, point: Point): void {
    const snapshot = this.dragStateSnapshot;
    const anchor = this.createAnchor(point);

    if (!snapshot || snapshot.priceOffset === null || !anchor) {
      return;
    }

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

    const baseAnchor: Anchor = {
      time: anchor.time,
      price: anchor.price - snapshot.priceOffset,
    };

    if (kind === 'start') {
      this.startAnchor = baseAnchor;
    } else {
      this.endAnchor = baseAnchor;
    }

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

    if (kind === 'start') {
      this.startAnchor = previousAnchor;
    } else {
      this.endAnchor = previousAnchor;
    }
  }

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

    if (!snapshot?.startAnchor || !snapshot.endAnchor || snapshot.priceOffset === null) {
      return;
    }

    const pointerPrice = getPriceFromYCoordinate(this.series, point.y);
    const linePrice = this.getLinePriceAtX(snapshot.startAnchor, snapshot.endAnchor, point.x);

    if (pointerPrice === null || linePrice === null) {
      return;
    }

    const priceDelta = pointerPrice - linePrice;

    this.startAnchor = {
      ...snapshot.startAnchor,
      price: snapshot.startAnchor.price + priceDelta,
    };

    this.endAnchor = {
      ...snapshot.endAnchor,
      price: snapshot.endAnchor.price + priceDelta,
    };

    this.priceOffset = snapshot.priceOffset - priceDelta;

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

    this.startAnchor = snapshot.startAnchor;
    this.endAnchor = snapshot.endAnchor;
    this.priceOffset = snapshot.priceOffset;
  }

  private moveParallelMiddle(point: Point): void {
    const previousOffset = this.priceOffset;

    this.setPriceOffset(point);

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

    this.priceOffset = previousOffset;
  }

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

    if (!snapshot?.startAnchor || !snapshot.endAnchor || snapshot.priceOffset === null || !this.dragStartPoint) {
      return;
    }

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

    const startTime = shiftTimeByPixels(this.chart, snapshot.startAnchor.time, offsetX, this.series);
    const endTime = shiftTimeByPixels(this.chart, snapshot.endAnchor.time, offsetX, this.series);

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

    this.startAnchor = {
      time: startTime,
      price: snapshot.startAnchor.price + priceDelta,
    };

    this.endAnchor = {
      time: endTime,
      price: snapshot.endAnchor.price + priceDelta,
    };

    this.priceOffset = snapshot.priceOffset;
  }

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

    if (!anchor) {
      return;
    }

    this.endAnchor = anchor;
  }

  private setPriceOffset(point: Point): void {
    if (!this.startAnchor || !this.endAnchor) {
      return;
    }

    const pointerPrice = getPriceFromYCoordinate(this.series, point.y);
    const linePrice = this.getLinePriceAtX(this.startAnchor, this.endAnchor, point.x);

    if (pointerPrice === null || linePrice === null) {
      return;
    }

    this.priceOffset = pointerPrice - linePrice;
  }

  private getLinePriceAtX(startAnchor: Anchor, endAnchor: Anchor, x: number): number | null {
    const startPoint = this.getAnchorPoint(startAnchor);
    const endPoint = this.getAnchorPoint(endAnchor);

    if (!startPoint || !endPoint) {
      return null;
    }

    const deltaX = endPoint.x - startPoint.x;

    if (Math.abs(deltaX) <= VERTICAL_LINE_TOLERANCE) {
      return getPriceFromYCoordinate(this.series, (startPoint.y + endPoint.y) / 2);
    }

    const ratio = (x - startPoint.x) / deltaX;
    const y = startPoint.y + (endPoint.y - startPoint.y) * ratio;

    return getPriceFromYCoordinate(this.series, y);
  }

  private hasValidMainLine(): boolean {
    const geometry = this.getGeometry();

    if (!geometry) {
      return false;
    }

    return getDistance(geometry.startPoint, geometry.endPoint) >= MIN_LINE_SIZE;
  }

  private hasValidChannelWidth(): boolean {
    const geometry = this.getGeometry();

    if (!geometry) {
      return false;
    }

    return getDistance(geometry.startPoint, geometry.parallelStartPoint) >= MIN_CHANNEL_WIDTH;
  }

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

    if (!geometry) {
      return false;
    }

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

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

    if (
      this.settings.showMiddleLine &&
      getDistanceToSegment(point, geometry.middleStartPoint, geometry.middleEndPoint) <= LINE_HIT_TOLERANCE
    ) {
      return true;
    }

    return isPointInPolygon(point, [
      geometry.startPoint,
      geometry.endPoint,
      geometry.parallelEndPoint,
      geometry.parallelStartPoint,
    ]);
  }

  private getPriceLabelValue(kind: PriceLabelKind): number | null {
    if (!this.startAnchor || !this.endAnchor || this.priceOffset === null) {
      return null;
    }

    switch (kind) {
      case 'main-start':
        return this.startAnchor.price;

      case 'main-end':
        return this.endAnchor.price;

      case 'parallel-start':
        return this.startAnchor.price + this.priceOffset;

      case 'parallel-end':
        return this.endAnchor.price + this.priceOffset;

      default:
        return null;
    }
  }

  private getAnchorPoint(anchor: Anchor): Point | null {
    const x = getXCoordinateFromTime(this.chart, anchor.time, this.series);
    const y = getYCoordinateFromPrice(this.series, anchor.price);

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

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

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

function isPriceLabelKind(kind: string): kind is PriceLabelKind {
  return kind === 'main-start' || kind === 'main-end' || kind === 'parallel-start' || kind === 'parallel-end';
}

function getMiddlePoint(startPoint: Point, endPoint: Point): Point {
  return {
    x: (startPoint.x + endPoint.x) / 2,
    y: (startPoint.y + endPoint.y) / 2,
  };
}

function getDistance(startPoint: Point, endPoint: Point): number {
  return Math.hypot(endPoint.x - startPoint.x, endPoint.y - startPoint.y);
}

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

  if (deltaX === 0 && deltaY === 0) {
    return getDistance(point, startPoint);
  }

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

  const projectionX = startPoint.x + ratio * deltaX;
  const projectionY = startPoint.y + ratio * deltaY;

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

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

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

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

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

  return isInside;
}



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

import type { ParallelChannel } from './parallelChannel';

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

const UI = {
  lineWidth: 2,
  middleLineWidth: 1,
  middleLineDash: 6,
  middleLineGap: 4,
  textLineHeightMultiplier: 1.2,
  textOffset: 5,
  textStartPadding: 15,
  textStartGap: 5,
};

export class ParallelChannelPaneRenderer implements IPrimitivePaneRenderer {
  private parallelChannel: ParallelChannel;

  constructor(parallelChannel: ParallelChannel) {
    this.parallelChannel = parallelChannel;
  }

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

    if (!data) {
      return;
    }

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

      const startPoint = scalePoint(data.startPoint, horizontalPixelRatio, verticalPixelRatio);
      const mainMiddlePoint = scalePoint(data.mainMiddlePoint, horizontalPixelRatio, verticalPixelRatio);
      const endPoint = scalePoint(data.endPoint, horizontalPixelRatio, verticalPixelRatio);

      const parallelStartPoint = scalePoint(data.parallelStartPoint, horizontalPixelRatio, verticalPixelRatio);
      const parallelMiddlePoint = scalePoint(data.parallelMiddlePoint, horizontalPixelRatio, verticalPixelRatio);
      const parallelEndPoint = scalePoint(data.parallelEndPoint, horizontalPixelRatio, verticalPixelRatio);

      const middleStartPoint = scalePoint(data.middleStartPoint, horizontalPixelRatio, verticalPixelRatio);
      const middleEndPoint = scalePoint(data.middleEndPoint, horizontalPixelRatio, verticalPixelRatio);

      context.save();

      drawChannelFill(context, [startPoint, endPoint, parallelEndPoint, parallelStartPoint], data.backgroundColor);

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

      drawLine(context, startPoint, endPoint);
      drawLine(context, parallelStartPoint, parallelEndPoint);

      if (data.showMiddleLine) {
        context.save();

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

        drawLine(context, middleStartPoint, middleEndPoint);

        context.restore();
      }

      if (data.text.trim()) {
        const channelCenterPoint = getMiddlePoint(mainMiddlePoint, parallelMiddlePoint);
        const isMainLineAbove = mainMiddlePoint.y <= parallelMiddlePoint.y;

        drawTextAlongLine(context, {
          startPoint: isMainLineAbove ? startPoint : parallelStartPoint,
          endPoint: isMainLineAbove ? endPoint : parallelEndPoint,
          channelCenterPoint,
          text: data.text,
          fontSize: data.fontSize,
          isBold: data.isBold,
          isItalic: data.isItalic,
          textColor: data.textColor,
          pixelRatio,
          verticalPixelRatio,
        });
      }

      context.restore();
    });
  }
}

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

function drawChannelFill(context: CanvasRenderingContext2D, points: Point[], color: string): void {
  const [startPoint, endPoint, parallelEndPoint, parallelStartPoint] = points;

  context.save();
  context.fillStyle = color;

  context.beginPath();
  context.moveTo(startPoint.x, startPoint.y);
  context.lineTo(endPoint.x, endPoint.y);
  context.lineTo(parallelEndPoint.x, parallelEndPoint.y);
  context.lineTo(parallelStartPoint.x, parallelStartPoint.y);
  context.closePath();
  context.fill();

  context.restore();
}

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

function drawTextAlongLine(
  context: CanvasRenderingContext2D,
  params: {
    startPoint: Point;
    endPoint: Point;
    channelCenterPoint: Point;
    text: string;
    fontSize: number;
    isBold: boolean;
    isItalic: boolean;
    textColor: string;
    pixelRatio: number;
    verticalPixelRatio: number;
  },
): void {
  const {
    startPoint,
    endPoint,
    channelCenterPoint,
    text,
    fontSize,
    isBold,
    isItalic,
    textColor,
    pixelRatio,
    verticalPixelRatio,
  } = params;

  let renderStartPoint = startPoint;
  let renderEndPoint = endPoint;

  let deltaX = renderEndPoint.x - renderStartPoint.x;
  let deltaY = renderEndPoint.y - renderStartPoint.y;
  let angle = Math.atan2(deltaY, deltaX);

  if (angle > Math.PI / 2 || angle < -Math.PI / 2) {
    renderStartPoint = endPoint;
    renderEndPoint = startPoint;

    deltaX = renderEndPoint.x - renderStartPoint.x;
    deltaY = renderEndPoint.y - renderStartPoint.y;
    angle = Math.atan2(deltaY, deltaX);
  }

  const lineLength = Math.hypot(deltaX, deltaY);

  if (!lineLength) {
    return;
  }

  const directionX = deltaX / lineLength;
  const directionY = deltaY / lineLength;
  const lineMiddlePoint = getMiddlePoint(startPoint, endPoint);
  const outwardX = lineMiddlePoint.x - channelCenterPoint.x;
  const outwardY = lineMiddlePoint.y - channelCenterPoint.y;
  const outwardLength = Math.hypot(outwardX, outwardY);
  const normalX = outwardLength ? outwardX / outwardLength : 0;
  const normalY = outwardLength ? outwardY / outwardLength : -1;

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

  context.save();
  context.font = `${fontStyle}${fontWeight}${fontSizePx}px Inter, sans-serif`;

  const textWidth = lines.reduce((maxWidth, line) => {
    return Math.max(maxWidth, context.measureText(line || ' ').width);
  }, 0);

  const blockHeight = lines.length * lineHeight;
  const startPadding = UI.textStartPadding * pixelRatio;
  const desiredDistance = startPadding + textWidth / 2;
  const availableDistance = lineLength - textWidth / 2 - UI.textStartGap * pixelRatio;
  const distanceAlongLine = availableDistance >= desiredDistance ? desiredDistance : lineLength / 2;
  const outwardDistance = blockHeight / 2 + UI.textOffset * verticalPixelRatio;

  const textCenterX = renderStartPoint.x + directionX * distanceAlongLine + normalX * outwardDistance;
  const textCenterY = renderStartPoint.y + directionY * distanceAlongLine + normalY * outwardDistance;

  context.translate(textCenterX, textCenterY);
  context.rotate(angle);

  context.fillStyle = textColor;
  context.textAlign = 'center';
  context.textBaseline = 'middle';

  const firstLineY = (-(lines.length - 1) * lineHeight) / 2;

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

  context.restore();
}

function getMiddlePoint(startPoint: Point, endPoint: Point): Point {
  return {
    x: (startPoint.x + endPoint.x) / 2,
    y: (startPoint.y + endPoint.y) / 2,
  };
}



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

import { ParallelChannelPaneRenderer } from './paneRenderer';

import type { ParallelChannel } from './parallelChannel';

export class ParallelChannelPaneView implements IPrimitivePaneView {
  private paneRenderer: ParallelChannelPaneRenderer;

  constructor(parallelChannel: ParallelChannel) {
    this.paneRenderer = new ParallelChannelPaneRenderer(parallelChannel);
  }

  public update(): void {}

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

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


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

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
  getAnchorFromPoint,
  getPriceDelta as getPriceDeltaFromCoordinates,
  getPriceFromYCoordinate,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  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 { ParallelChannelPaneView } from './paneView';
import {
  createDefaultSettings,
  getParallelChannelSettingsTabs,
  ParallelChannelSettings,
  ParallelChannelStyle,
  ParallelChannelTextStyle,
} from './settings';

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

type ParallelChannelMode = 'idle' | 'drawing-line' | 'drawing-channel' | 'ready' | 'dragging';

type ParallelChannelDragTarget =
  | 'main-start'
  | 'main-middle'
  | 'main-end'
  | 'parallel-start'
  | 'parallel-middle'
  | 'parallel-end'
  | 'body';

type ParallelChannelHandleKey = Exclude<ParallelChannelDragTarget, 'body'>;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'main-start' | 'main-end' | 'parallel-start' | 'parallel-end';

type ParallelChannelParams = BaseDrawingParams;

interface ParallelChannelState {
  hidden: boolean;
  mode: ParallelChannelMode;
  startAnchor: Anchor | null;
  endAnchor: Anchor | null;
  priceOffset: number | null;
  settings: ParallelChannelSettings;
}

interface ParallelChannelGeometry {
  startPoint: Point;
  mainMiddlePoint: Point;
  endPoint: Point;
  parallelStartPoint: Point;
  parallelMiddlePoint: Point;
  parallelEndPoint: Point;
  middleStartPoint: Point;
  middleEndPoint: Point;
  left: number;
  right: number;
  top: number;
  bottom: number;
}

export interface ParallelChannelRenderData
  extends ParallelChannelGeometry,
    ParallelChannelStyle,
    ParallelChannelTextStyle {}

const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;
const MIN_CHANNEL_WIDTH = 4;
const VERTICAL_LINE_TOLERANCE = 0.001;

export class ParallelChannel
  extends SeriesDrawingBase<ParallelChannelSettings, ParallelChannelHandleKey>
  implements ISeriesDrawing
{
  private openSettings?: () => void;

  protected settings: ParallelChannelSettings = createDefaultSettings();
  protected mode: ParallelChannelMode = 'idle';

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

  private activeDragTarget: ParallelChannelDragTarget | null = null;
  private dragPointerId: number | null = null;
  private dragStartPoint: Point | null = null;
  private dragStateSnapshot: ParallelChannelState | null = null;

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

  private paneView: ParallelChannelPaneView;
  private timeAxisPaneView: CustomTimeAxisPaneView;
  private priceAxisPaneView: CustomPriceAxisPaneView;

  private startTimeAxisView: CustomTimeAxisView;
  private endTimeAxisView: CustomTimeAxisView;

  private mainStartPriceAxisView: CustomPriceAxisView;
  private mainEndPriceAxisView: CustomPriceAxisView;
  private parallelStartPriceAxisView: CustomPriceAxisView;
  private parallelEndPriceAxisView: CustomPriceAxisView;

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

    this.openSettings = openSettings;

    this.paneView = new ParallelChannelPaneView(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.mainStartPriceAxisView = new CustomPriceAxisView({
      getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
      labelKind: 'main-start',
    });

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

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

    this.parallelEndPriceAxisView = new CustomPriceAxisView({
      getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
      labelKind: 'parallel-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-line' || this.mode === 'drawing-channel';
  }

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

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

    const nextState = state as Partial<ParallelChannelState>;

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

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

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

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

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

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

    this.render();
  }

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

  public updateAllViews(): void {
    updateViews([
      this.paneView,
      this.timeAxisPaneView,
      this.priceAxisPaneView,
      this.startTimeAxisView,
      this.endTimeAxisView,
      this.mainStartPriceAxisView,
      this.mainEndPriceAxisView,
      this.parallelStartPriceAxisView,
      this.parallelEndPriceAxisView,
    ]);
  }

  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.mainStartPriceAxisView,
      this.mainEndPriceAxisView,
      this.parallelStartPriceAxisView,
      this.parallelEndPriceAxisView,
    ];
  }

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

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

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

    if (!geometry) {
      return [];
    }

    return [
      {
        id: 'parallel-end',
        ...geometry.parallelEndPoint,
        shape: 'circle',
        borderWidth: 2,
      },
      {
        id: 'parallel-middle',
        ...geometry.parallelMiddlePoint,
        shape: 'circle',
        borderWidth: 2,
      },
      {
        id: 'parallel-start',
        ...geometry.parallelStartPoint,
        shape: 'circle',
        borderWidth: 2,
      },
      {
        id: 'main-end',
        ...geometry.endPoint,
        shape: 'circle',
        borderWidth: 2,
      },
      {
        id: 'main-middle',
        ...geometry.mainMiddlePoint,
        shape: 'circle',
        borderWidth: 2,
      },
      {
        id: 'main-start',
        ...geometry.startPoint,
        shape: 'circle',
        borderWidth: 2,
      },
    ];
  }

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

    const point = { x, y };
    const pointTarget = this.getDrawingHandleAtPoint(point);
    const isChannelHit = this.isPointOnChannel(point);

    if (!pointTarget && !isChannelHit) {
      return null;
    }

    if (!this.isSelected()) {
      return {
        cursorStyle: 'pointer',
        externalId: 'parallel-channel',
        zOrder: 'top',
      };
    }

    return {
      cursorStyle: pointTarget ? 'move' : 'grab',
      externalId: 'parallel-channel',
      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 getTimeAxisLabel(kind: string): AxisLabel | 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.isSelected() && !this.isCreationPending()) || !isPriceLabelKind(kind)) {
      return null;
    }

    const price = this.getPriceLabelValue(kind);

    if (price === null) {
      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,
    };
  }

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

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

    if (!this.isPointOnChannel(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-line') {
      event.preventDefault();
      event.stopPropagation();

      this.setEndAnchor(point);

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

      this.priceOffset = 0;
      this.mode = 'drawing-channel';

      this.render();
      return;
    }

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

      this.setPriceOffset(point);

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

      this.finishDrawing();
      return;
    }

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

    const pointTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
    const isChannelHit = this.isPointOnChannel(point);
    const isDrawingHit = pointTarget !== null || isChannelHit;

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

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

      this.select();
      return;
    }

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

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

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

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

    this.deselect();
  };

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

    if (this.mode === 'drawing-line') {
      this.setEndAnchor(point);
      this.render();
      return;
    }

    if (this.mode === 'drawing-channel') {
      this.setPriceOffset(point);
      this.render();
      return;
    }

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

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

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

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

    this.finishDragging();
  };

  protected getGeometry(): ParallelChannelGeometry | null {
    if (!this.startAnchor || !this.endAnchor || this.priceOffset === null) {
      return null;
    }

    const startPoint = this.getAnchorPoint(this.startAnchor);
    const endPoint = this.getAnchorPoint(this.endAnchor);

    const parallelStartPoint = this.getAnchorPoint({
      time: this.startAnchor.time,
      price: this.startAnchor.price + this.priceOffset,
    });

    const parallelEndPoint = this.getAnchorPoint({
      time: this.endAnchor.time,
      price: this.endAnchor.price + this.priceOffset,
    });

    if (!startPoint || !endPoint || !parallelStartPoint || !parallelEndPoint) {
      return null;
    }

    const mainMiddlePoint = getMiddlePoint(startPoint, endPoint);
    const parallelMiddlePoint = getMiddlePoint(parallelStartPoint, parallelEndPoint);
    const middleStartPoint = getMiddlePoint(startPoint, parallelStartPoint);
    const middleEndPoint = getMiddlePoint(endPoint, parallelEndPoint);
    const points = [startPoint, endPoint, parallelStartPoint, parallelEndPoint];

    return {
      startPoint,
      mainMiddlePoint,
      endPoint,
      parallelStartPoint,
      parallelMiddlePoint,
      parallelEndPoint,
      middleStartPoint,
      middleEndPoint,
      left: Math.min(...points.map((point) => point.x)),
      right: Math.max(...points.map((point) => point.x)),
      top: Math.min(...points.map((point) => point.y)),
      bottom: Math.max(...points.map((point) => point.y)),
    };
  }

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

    if (!anchor) {
      return;
    }

    this.startAnchor = anchor;
    this.endAnchor = anchor;
    this.priceOffset = 0;
    this.mode = 'drawing-line';

    this.render();
  }

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

    this.render();
  }

  private startDragging(target: ParallelChannelDragTarget, point: Point, pointerId: number): void {
    this.mode = 'dragging';
    this.activeDragTarget = target;
    this.dragPointerId = pointerId;
    this.dragStartPoint = point;
    this.dragStateSnapshot = this.getState();

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

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

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

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

  private applyDrag(point: Point): void {
    switch (this.activeDragTarget) {
      case 'main-start':
        this.moveMainEdge('start', point);
        break;

      case 'main-middle':
        this.moveMainMiddle(point);
        break;

      case 'main-end':
        this.moveMainEdge('end', point);
        break;

      case 'parallel-start':
        this.moveParallelEdge('start', point);
        break;

      case 'parallel-middle':
        this.moveParallelMiddle(point);
        break;

      case 'parallel-end':
        this.moveParallelEdge('end', point);
        break;

      case 'body':
        this.moveBody(point);
        break;

      default:
        break;
    }
  }

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

    if (!anchor) {
      return;
    }

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

    if (kind === 'start') {
      this.startAnchor = anchor;
    } else {
      this.endAnchor = anchor;
    }

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

    if (kind === 'start') {
      this.startAnchor = previousAnchor;
    } else {
      this.endAnchor = previousAnchor;
    }
  }

  private moveParallelEdge(kind: TimeLabelKind, point: Point): void {
    const snapshot = this.dragStateSnapshot;
    const anchor = this.createAnchor(point);

    if (!snapshot || snapshot.priceOffset === null || !anchor) {
      return;
    }

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

    const baseAnchor: Anchor = {
      time: anchor.time,
      price: anchor.price - snapshot.priceOffset,
    };

    if (kind === 'start') {
      this.startAnchor = baseAnchor;
    } else {
      this.endAnchor = baseAnchor;
    }

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

    if (kind === 'start') {
      this.startAnchor = previousAnchor;
    } else {
      this.endAnchor = previousAnchor;
    }
  }

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

    if (!snapshot?.startAnchor || !snapshot.endAnchor || snapshot.priceOffset === null) {
      return;
    }

    const pointerPrice = getPriceFromYCoordinate(this.series, point.y);
    const linePrice = this.getLinePriceAtX(snapshot.startAnchor, snapshot.endAnchor, point.x);

    if (pointerPrice === null || linePrice === null) {
      return;
    }

    const priceDelta = pointerPrice - linePrice;

    this.startAnchor = {
      ...snapshot.startAnchor,
      price: snapshot.startAnchor.price + priceDelta,
    };

    this.endAnchor = {
      ...snapshot.endAnchor,
      price: snapshot.endAnchor.price + priceDelta,
    };

    this.priceOffset = snapshot.priceOffset - priceDelta;

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

    this.startAnchor = snapshot.startAnchor;
    this.endAnchor = snapshot.endAnchor;
    this.priceOffset = snapshot.priceOffset;
  }

  private moveParallelMiddle(point: Point): void {
    const previousOffset = this.priceOffset;

    this.setPriceOffset(point);

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

    this.priceOffset = previousOffset;
  }

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

    if (!snapshot?.startAnchor || !snapshot.endAnchor || snapshot.priceOffset === null || !this.dragStartPoint) {
      return;
    }

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

    const startTime = shiftTimeByPixels(this.chart, snapshot.startAnchor.time, offsetX, this.series);
    const endTime = shiftTimeByPixels(this.chart, snapshot.endAnchor.time, offsetX, this.series);

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

    this.startAnchor = {
      time: startTime,
      price: snapshot.startAnchor.price + priceDelta,
    };

    this.endAnchor = {
      time: endTime,
      price: snapshot.endAnchor.price + priceDelta,
    };

    this.priceOffset = snapshot.priceOffset;
  }

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

    if (!anchor) {
      return;
    }

    this.endAnchor = anchor;
  }

  private setPriceOffset(point: Point): void {
    if (!this.startAnchor || !this.endAnchor) {
      return;
    }

    const pointerPrice = getPriceFromYCoordinate(this.series, point.y);
    const linePrice = this.getLinePriceAtX(this.startAnchor, this.endAnchor, point.x);

    if (pointerPrice === null || linePrice === null) {
      return;
    }

    this.priceOffset = pointerPrice - linePrice;
  }

  private getLinePriceAtX(startAnchor: Anchor, endAnchor: Anchor, x: number): number | null {
    const startPoint = this.getAnchorPoint(startAnchor);
    const endPoint = this.getAnchorPoint(endAnchor);

    if (!startPoint || !endPoint) {
      return null;
    }

    const deltaX = endPoint.x - startPoint.x;

    if (Math.abs(deltaX) <= VERTICAL_LINE_TOLERANCE) {
      return getPriceFromYCoordinate(this.series, (startPoint.y + endPoint.y) / 2);
    }

    const ratio = (x - startPoint.x) / deltaX;
    const y = startPoint.y + (endPoint.y - startPoint.y) * ratio;

    return getPriceFromYCoordinate(this.series, y);
  }

  private hasValidMainLine(): boolean {
    const geometry = this.getGeometry();

    if (!geometry) {
      return false;
    }

    return getDistance(geometry.startPoint, geometry.endPoint) >= MIN_LINE_SIZE;
  }

  private hasValidChannelWidth(): boolean {
    const geometry = this.getGeometry();

    if (!geometry) {
      return false;
    }

    return getDistance(geometry.startPoint, geometry.parallelStartPoint) >= MIN_CHANNEL_WIDTH;
  }

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

    if (!geometry) {
      return false;
    }

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

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

    if (
      this.settings.showMiddleLine &&
      getDistanceToSegment(point, geometry.middleStartPoint, geometry.middleEndPoint) <= LINE_HIT_TOLERANCE
    ) {
      return true;
    }

    return isPointInPolygon(point, [
      geometry.startPoint,
      geometry.endPoint,
      geometry.parallelEndPoint,
      geometry.parallelStartPoint,
    ]);
  }

  private getPriceLabelValue(kind: PriceLabelKind): number | null {
    if (!this.startAnchor || !this.endAnchor || this.priceOffset === null) {
      return null;
    }

    switch (kind) {
      case 'main-start':
        return this.startAnchor.price;

      case 'main-end':
        return this.endAnchor.price;

      case 'parallel-start':
        return this.startAnchor.price + this.priceOffset;

      case 'parallel-end':
        return this.endAnchor.price + this.priceOffset;

      default:
        return null;
    }
  }

  private getAnchorPoint(anchor: Anchor): Point | null {
    const x = getXCoordinateFromTime(this.chart, anchor.time, this.series);
    const y = getYCoordinateFromPrice(this.series, anchor.price);

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

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

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

function isPriceLabelKind(kind: string): kind is PriceLabelKind {
  return kind === 'main-start' || kind === 'main-end' || kind === 'parallel-start' || kind === 'parallel-end';
}

function getMiddlePoint(startPoint: Point, endPoint: Point): Point {
  return {
    x: (startPoint.x + endPoint.x) / 2,
    y: (startPoint.y + endPoint.y) / 2,
  };
}

function getDistance(startPoint: Point, endPoint: Point): number {
  return Math.hypot(endPoint.x - startPoint.x, endPoint.y - startPoint.y);
}

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

  if (deltaX === 0 && deltaY === 0) {
    return getDistance(point, startPoint);
  }

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

  const projectionX = startPoint.x + ratio * deltaX;
  const projectionY = startPoint.y + ratio * deltaY;

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

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

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

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

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

  return isInside;
}



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

import { Ray } from './ray';

const UI = {
  lineWidth: 2,
  textLineHeightMultiplier: 1.2,
  textOffset: 4,
};

export class RayPaneRenderer implements IPrimitivePaneRenderer {
  private readonly ray: Ray;

  constructor(ray: Ray) {
    this.ray = ray;
  }

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

    if (!data) {
      return;
    }

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

      const startX = data.startPoint.x * horizontalPixelRatio;
      const startY = data.startPoint.y * verticalPixelRatio;
      const directionX = data.directionPoint.x * horizontalPixelRatio;
      const directionY = data.directionPoint.y * verticalPixelRatio;
      const endX = data.rayEndPoint.x * horizontalPixelRatio;
      const endY = data.rayEndPoint.y * verticalPixelRatio;

      context.save();

      context.lineWidth = UI.lineWidth * pixelRatio;
      context.strokeStyle = data.lineColor;
      context.beginPath();
      context.moveTo(startX, startY);
      context.lineTo(endX, endY);
      context.stroke();

      drawRayText(context, {
        startX,
        startY,
        directionX,
        directionY,
        text: data.text,
        fontSize: data.fontSize,
        isBold: data.isBold,
        isItalic: data.isItalic,
        textColor: data.textColor,
        pixelRatio,
      });

      context.restore();
    });
  }
}

function drawRayText(
  context: CanvasRenderingContext2D,
  params: {
    startX: number;
    startY: number;
    directionX: number;
    directionY: number;
    text: string;
    fontSize: number;
    isBold: boolean;
    isItalic: boolean;
    textColor: string;
    pixelRatio: number;
  },
): void {
  const { startX, startY, directionX, directionY, text, fontSize, isBold, isItalic, textColor, pixelRatio } = params;

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

  const dx = directionX - startX;
  const dy = directionY - startY;

  if (dx === 0 && dy === 0) {
    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 angle = Math.atan2(dy, dx);
  const textX = (startX + directionX) / 2;
  const textY = (startY + directionY) / 2;
  const textOffset = UI.textOffset * pixelRatio;
  const blockHeight = lines.length * lineHeight;

  context.save();
  context.translate(textX, textY);
  context.rotate(angle);

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

  const textCenterY = -(blockHeight / 2 + textOffset);
  const startLineY = textCenterY - blockHeight / 2 + lineHeight / 2;

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

  context.restore();
}



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

import { RayPaneRenderer } from './paneRenderer';
import { Ray } from './ray';

export class RayPaneView implements IPrimitivePaneView {
  private readonly ray: Ray;

  constructor(ray: Ray) {
    this.ray = ray;
  }

  public update(): void {}

  public renderer(): IPrimitivePaneRenderer {
    return new RayPaneRenderer(this.ray);
  }

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



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

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
  getAnchorFromPoint,
  getPriceDelta as getPriceDeltaFromCoordinates,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  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 { RayPaneView } from './paneView';

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

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

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

type RayParams = BaseDrawingParams;

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

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

export interface RayRenderData extends RayGeometry, RayStyle, RayTextStyle {}

const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;

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

  protected settings: RaySettings = createDefaultSettings();
  protected mode: RayMode = 'idle';

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

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

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

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

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

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

    this.paneView = new RayPaneView(this);

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

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

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

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

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

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

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

    this.series.attachPrimitive(this);

    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(): RayState {
    return {
      hidden: this.hidden,
      mode: this.mode,
      startAnchor: this.startAnchor,
      directionAnchor: this.directionAnchor,
      settings: { ...this.settings },
    };
  }

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

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

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

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

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

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

    this.render();
  }

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

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

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

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

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

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

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

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

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

    if (!geometry) {
      return [];
    }

    return [
      {
        id: 'start',
        ...geometry.startPoint,
        shape: 'circle',
        borderWidth: 2,
        strokeColor: this.settings.lineColor,
      },
      {
        id: 'direction',
        ...geometry.directionPoint,
        shape: 'circle',
        borderWidth: 2,
        strokeColor: this.settings.lineColor,
      },
    ];
  }

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

    const point = { x, y };

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

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

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

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

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

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

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

    const { colors } = getThemeStore();

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

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

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

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

    const { colors } = getThemeStore();

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

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

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

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

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

    this.openSettings?.();
  };

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

    const point = this.getEventPoint(event);

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

      this.startDrawing(point);
      return;
    }

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

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

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

    const pointTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
    const isNearRay = this.isPointNearRay(point);
    const isDrawingHit = pointTarget !== null || isNearRay;

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

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

      this.select();
      return;
    }

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

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

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

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

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

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

    this.deselect();
  };

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

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

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

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

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

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

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

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

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

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

    if (!anchor) {
      return;
    }

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

    this.render();
  }

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

    if (!anchor) {
      return;
    }

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

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

    if (!geometry) {
      return;
    }

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

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

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

    this.render();
  }

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

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

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

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

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

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

    if (!anchor) {
      return;
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    if (!rayEndPoint) {
      return null;
    }

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

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

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

    const { width, height } = this.container.getBoundingClientRect();
    const candidates: Point[] = [];

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

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

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

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

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

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

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

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

    return candidates[0] ?? directionPoint;
  }

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

    if (!geometry) {
      return false;
    }

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

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

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

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

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

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

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

    if (!anchor) {
      return null;
    }

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

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

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

    if (!anchor) {
      return null;
    }

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

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

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

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

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

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

    if (!anchor) {
      return '';
    }

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


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

export interface RayStyle {
  lineColor: string;
}

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

export type RaySettings = RayStyle & RayTextStyle & SettingsValues;

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

  return {
    lineColor: colors.chartLineColor,
    text: '',
    fontSize: 14,
    isBold: false,
    isItalic: false,
    textColor: colors.chartLineColor,
  };
}

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

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

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

export interface ParallelChannelStyle {
  lineColor: string;
  backgroundColor: string;
  showMiddleLine: boolean;
}

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

export type ParallelChannelSettings = ParallelChannelStyle & ParallelChannelTextStyle & SettingsValues;

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

  return {
    lineColor: colors.chartLineColor,
    backgroundColor: colors.axisMarkerAreaFill,
    showMiddleLine: true,
    fontSize: 14,
    text: '',
    isBold: false,
    isItalic: false,
    textColor: colors.chartLineColor,
  };
}

export function getParallelChannelSettingsTabs(settings: ParallelChannelSettings): SettingsTab[] {
  const styleFields: SettingField[] = [
    {
      key: 'lineColor',
      label: t('Line'),
      type: 'color',
      defaultValue: settings.lineColor,
      toolbar: {
        control: 'color',
        role: 'line',
      },
    },
    {
      key: 'backgroundColor',
      label: t('Background'),
      type: 'color',
      defaultValue: settings.backgroundColor,
      toolbar: {
        control: 'color',
        role: 'fill',
      },
    },
    {
      key: 'showMiddleLine',
      label: t('Middle line'),
      type: 'boolean',
      defaultValue: settings.showMiddleLine,
    },
  ];

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