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


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

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

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

import { ParallelChannelPaneView } from './paneView';
import {
  createDefaultSettings,
  getParallelChannelSettingsTabs,
  ParallelChannelSettings,
  ParallelChannelStyle,
  ParallelChannelTextStyle,
} from './settings';

import type { DrawingInteraction, ISeriesDrawing } from '@core/Drawings/common';
import type { Anchor, AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
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 TimeLabelKind = 'start' | 'end';

type PriceLabelKind = 'main-start' | 'main-end' | 'parallel-start' | 'parallel-end';

interface ParallelChannelParams {
  container: HTMLElement;
  interaction: DrawingInteraction;
  formatObservable?: Observable<ChartOptionsModel>;
  openSettings?: () => void;
}

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 {
  showHandles: boolean;
}

const HANDLE_HIT_TOLERANCE = 8;
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> 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(
    chart: IChartApi,
    series: SeriesApi,
    { container, interaction, formatObservable, openSettings }: 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);
  }

  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,
      showHandles: this.shouldShowHandles(),
    };
  }

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

    const point = { x, y };
    const pointTarget = this.getPointTarget(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.getPointTarget(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.getPointTarget(point);
    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 getPointTarget(point: Point): ParallelChannelDragTarget | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    const targets: [ParallelChannelDragTarget, Point][] = [
      ['main-start', geometry.startPoint],
      ['main-middle', geometry.mainMiddlePoint],
      ['main-end', geometry.endPoint],
      ['parallel-start', geometry.parallelStartPoint],
      ['parallel-middle', geometry.parallelMiddlePoint],
      ['parallel-end', geometry.parallelEndPoint],
    ];

    for (const [target, targetPoint] of targets) {
      if (isNearPoint(point, targetPoint.x, targetPoint.y, HANDLE_HIT_TOLERANCE)) {
        return target;
      }
    }

    return null;
  }

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



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

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

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

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

const UI = {
  lineWidth: 2,
  middleLineWidth: 1,
  middleLineDash: 6,
  middleLineGap: 4,
  handleRadius: 5,
  handleBorderWidth: 2,
  textLineHeightMultiplier: 1.2,
  textOffset: 5,
  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,
        });
      }

      if (data.showHandles) {
        const { colors } = getThemeStore();

        context.fillStyle = colors.chartBackground;

        context.strokeStyle = colors.chartLineColor;

        context.lineWidth = UI.handleBorderWidth * pixelRatio;

        const radius = UI.handleRadius * pixelRatio;

        drawHandle(context, startPoint, radius);
        drawHandle(context, mainMiddlePoint, radius);
        drawHandle(context, endPoint, radius);
        drawHandle(context, parallelStartPoint, radius);
        drawHandle(context, parallelMiddlePoint, radius);
        drawHandle(context, parallelEndPoint, radius);
      }

      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 drawHandle(context: CanvasRenderingContext2D, point: Point, radius: number): void {
  context.beginPath();
  context.arc(point.x, point.y, radius, 0, Math.PI * 2);
  context.fill();
  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.handleRadius * 2 + UI.textStartGap) * 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 { ParallelChannelPaneRenderer } from './paneRenderer';
import { ParallelChannelPaneView } from './paneView';
import { ParallelChannel } from './parallelChannel';

export { ParallelChannel, ParallelChannelPaneRenderer, ParallelChannelPaneView };

export type { ParallelChannelRenderData } from './parallelChannel';