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


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

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

export interface ParallelChannelStyle {
  lineColor: string;
  backgroundColor: string;
  lineWidth: number;
  showMiddleLine: boolean;
}

export type ParallelChannelSettings = SettingsValues & ParallelChannelStyle;

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

  return {
    lineColor: colors.chartLineColor,
    backgroundColor: colors.axisMarkerAreaFill,
    lineWidth: 2,
    showMiddleLine: true,
  };
}

export function getParallelChannelSettingsTabs(settings: ParallelChannelSettings): SettingsTab[] {
  const fields: SettingField[] = [
    {
      key: 'lineColor',
      label: t('Line color'),
      type: 'color',
      defaultValue: settings.lineColor,
      toolbar: {
        control: 'color',
        role: 'line',
      },
    },
    {
      key: 'backgroundColor',
      label: t('Background color'),
      type: 'color',
      defaultValue: settings.backgroundColor,
      toolbar: {
        control: 'color',
        role: 'fill',
      },
    },
    {
      key: 'lineWidth',
      label: t('Line width'),
      type: 'number',
      defaultValue: settings.lineWidth,
      min: 1,
      max: 4,
    },
    {
      key: 'showMiddleLine',
      label: t('Middle line'),
      type: 'boolean',
      defaultValue: settings.showMiddleLine,
    },
  ];

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


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

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

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

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

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

type ParallelChannelMode =
  | 'idle'
  | 'drawing-line'
  | 'drawing-channel'
  | 'ready'
  | 'dragging-start'
  | 'dragging-end'
  | 'dragging-offset'
  | 'dragging-body';

type ParallelChannelDragMode =
  | 'dragging-start'
  | 'dragging-end'
  | 'dragging-offset'
  | 'dragging-body';

type AnchorKind = 'start' | 'end' | 'offset';

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

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

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

export interface ParallelChannelRenderData extends ParallelChannelGeometry, ParallelChannelStyle {
  showHandles: boolean;
}

const HANDLE_HIT_TOLERANCE = 8;
const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;
const MIN_CHANNEL_WIDTH = 4;

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

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

  private startAnchor: Anchor | null = null;
  private endAnchor: Anchor | null = null;
  private offsetAnchor: Anchor | 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 readonly paneView: ParallelChannelPaneView;
  private readonly timeAxisPaneView: CustomTimeAxisPaneView;
  private readonly priceAxisPaneView: CustomPriceAxisPaneView;

  private readonly startTimeAxisView: CustomTimeAxisView;
  private readonly endTimeAxisView: CustomTimeAxisView;
  private readonly offsetTimeAxisView: CustomTimeAxisView;

  private readonly startPriceAxisView: CustomPriceAxisView;
  private readonly endPriceAxisView: CustomPriceAxisView;
  private readonly offsetPriceAxisView: CustomPriceAxisView;

  constructor(
    chart: IChartApi,
    series: SeriesApi,
    {
      container,
      formatObservable,
      removeSelf,
      openSettings,
    }: ParallelChannelParams,
  ) {
    super({ chart, series, container });

    this.removeSelf = removeSelf;
    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.offsetTimeAxisView = new CustomTimeAxisView({
      getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
      labelKind: 'offset',
    });

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

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

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

    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,
      isActive: this.isActive.value,
      mode: this.mode,
      startAnchor: this.startAnchor,
      endAnchor: this.endAnchor,
      offsetAnchor: this.offsetAnchor,
      settings: { ...this.settings },
    };
  }

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

    const nextState = state as Partial<ParallelChannelState>;

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

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

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

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

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

    if ('offsetAnchor' in nextState) {
      this.offsetAnchor = nextState.offsetAnchor ?? 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.offsetTimeAxisView,
      this.startPriceAxisView,
      this.endPriceAxisView,
      this.offsetPriceAxisView,
    ]);
  }

  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,
      this.offsetTimeAxisView,
    ];
  }

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

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

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

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

    return {
      cursorStyle: 'grab',
      externalId: 'parallel-channel',
      zOrder: 'top',
    };
  }

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

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

  protected getTimeAxisLabel(kind: string): AxisLabel | null {
    if (!this.isActive.value || !isAnchorKind(kind)) {
      return null;
    }

    const anchor = this.getAnchor(kind);

    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.isActive.value || !isAnchorKind(kind)) {
      return null;
    }

    const anchor = this.getAnchor(kind);

    if (!anchor) {
      return null;
    }

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

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

    const { colors } = getThemeStore();

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

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

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

    if (!this.isPointOnChannel(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.offsetAnchor = this.endAnchor;
      this.mode = 'drawing-channel';
      this.render();
      return;
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

      this.moveAnchor(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 (isDraggingMode(this.mode)) {
      this.finishDragging();
    }
  };

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

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

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

    const dx = endPoint.x - startPoint.x;
    const dy = endPoint.y - startPoint.y;
    const lengthSquared = dx * dx + dy * dy;

    if (lengthSquared === 0) {
      return {
        startPoint,
        endPoint,
        offsetPoint,
        parallelStartPoint: startPoint,
        parallelEndPoint: endPoint,
        middleStartPoint: startPoint,
        middleEndPoint: endPoint,
        left: startPoint.x,
        right: startPoint.x,
        top: startPoint.y,
        bottom: startPoint.y,
      };
    }

    const offsetDx = offsetPoint.x - startPoint.x;
    const offsetDy = offsetPoint.y - startPoint.y;

    const projectionRatio =
      (offsetDx * dx + offsetDy * dy) / lengthSquared;

    const projectionPoint = {
      x: startPoint.x + dx * projectionRatio,
      y: startPoint.y + dy * projectionRatio,
    };

    const channelOffset = {
      x: offsetPoint.x - projectionPoint.x,
      y: offsetPoint.y - projectionPoint.y,
    };

    const parallelStartPoint = {
      x: startPoint.x + channelOffset.x,
      y: startPoint.y + channelOffset.y,
    };

    const parallelEndPoint = {
      x: endPoint.x + channelOffset.x,
      y: endPoint.y + channelOffset.y,
    };

    const middleStartPoint = {
      x: (startPoint.x + parallelStartPoint.x) / 2,
      y: (startPoint.y + parallelStartPoint.y) / 2,
    };

    const middleEndPoint = {
      x: (endPoint.x + parallelEndPoint.x) / 2,
      y: (endPoint.y + parallelEndPoint.y) / 2,
    };

    const points = [
      startPoint,
      endPoint,
      offsetPoint,
      parallelStartPoint,
      parallelEndPoint,
    ];

    return {
      startPoint,
      endPoint,
      offsetPoint,
      parallelStartPoint,
      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.offsetAnchor = anchor;

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

    this.render();
  }

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

  private startDragging(
    mode: ParallelChannelDragMode,
    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.dragPointerId = null;
    this.dragStartPoint = null;
    this.dragStateSnapshot = null;

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

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

    if (!anchor) {
      return;
    }

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

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

    if (this.mode === 'dragging-offset') {
      this.offsetAnchor = anchor;
    }
  }

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

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

    const offsetX = point.x - this.dragStartPoint.x;
    const priceOffset = 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,
    );

    const offsetTime = shiftTimeByPixels(
      this.chart,
      snapshot.offsetAnchor.time,
      offsetX,
      this.series,
    );

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

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

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

    this.offsetAnchor = {
      time: offsetTime,
      price: snapshot.offsetAnchor.price + priceOffset,
    };
  }

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

    if (anchor) {
      this.endAnchor = anchor;
    }
  }

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

    if (anchor) {
      this.offsetAnchor = anchor;
    }
  }

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

    if (!geometry) {
      return false;
    }

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

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

    if (!geometry) {
      return false;
    }

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

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

    if (!geometry) {
      return null;
    }

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

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

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

    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 getAnchor(kind: AnchorKind): Anchor | null {
    if (kind === 'start') {
      return this.startAnchor;
    }

    if (kind === 'end') {
      return this.endAnchor;
    }

    return this.offsetAnchor;
  }

  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 isAnchorKind(kind: string): kind is AnchorKind {
  return (
    kind === 'start' ||
    kind === 'end' ||
    kind === 'offset'
  );
}

function isDraggingMode(
  mode: ParallelChannelMode,
): mode is ParallelChannelDragMode {
  return (
    mode === 'dragging-start' ||
    mode === 'dragging-end' ||
    mode === 'dragging-offset' ||
    mode === 'dragging-body'
  );
}

function getDistanceToSegmentLength(
  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 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 ratio = Math.max(
    0,
    Math.min(
      1,
      ((point.x - startPoint.x) * dx +
        (point.y - startPoint.y) * dy) /
        (dx * dx + dy * dy),
    ),
  );

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

  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 { getThemeStore } from '@src/theme';

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

const UI = {
  handleRadius: 5,
  handleBorderWidth: 2,
  middleLineWidth: 1,
};

export class ParallelChannelPaneRenderer
  implements IPrimitivePaneRenderer
{
  private readonly 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 endPoint = scalePoint(
          data.endPoint,
          horizontalPixelRatio,
          verticalPixelRatio,
        );

        const offsetPoint = scalePoint(
          data.offsetPoint,
          horizontalPixelRatio,
          verticalPixelRatio,
        );

        const parallelStartPoint = scalePoint(
          data.parallelStartPoint,
          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 = data.lineWidth * pixelRatio;

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

        if (data.showMiddleLine) {
          context.save();
          context.lineWidth =
            UI.middleLineWidth * pixelRatio;

          drawLine(
            context,
            middleStartPoint,
            middleEndPoint,
          );

          context.restore();
        }

        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, endPoint, radius);
          drawHandle(context, offsetPoint, 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();
}


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

import { ParallelChannelPaneRenderer } from './paneRenderer';

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

export class ParallelChannelPaneView
  implements IPrimitivePaneView
{
  private readonly 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 { ParallelChannelPaneRenderer } from './paneRenderer';
import { ParallelChannelPaneView } from './paneView';
import { ParallelChannel } from './parallelChannel';

export {
  ParallelChannel,
  ParallelChannelPaneRenderer,
  ParallelChannelPaneView,
};

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


[DrawingsNames.parallelChannel]: {
  construct: ({
    chart,
    series,
    container,
    eventManager,
    removeSelf,
    openSettings,
  }) => {
    return new ParallelChannel(chart, series, {
      container,
      formatObservable:
        eventManager.getChartOptionsModel(),
      removeSelf,
      openSettings,
    });
  },
},


'Parallel channel': 'Параллельный канал',
'Middle line': 'Средняя линия',
'Line width': 'Толщина линии',


{
  id: DrawingsNames.parallelChannel,
  label: t('Parallel channel'),
  Icon: ParallelChannelIcon,
}