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


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

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import { getPriceFromYCoordinate } from '@core/Drawings/helpers';
import { TwoPointDrawingBase } from '@core/Drawings/TwoPointDrawingBase';
import { updateViews } from '@core/Drawings/utils';

import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { ChartOptionsModel, Direction } from '@src/types';
import { Defaults } from '@src/types/defaults';
import { SettingsTab, SettingsValues } from '@src/types/settings';
import { formatPrice, formatVolume } from '@src/utils';
import { formatDate } from '@src/utils/formatter';

import { RulerPaneView } from './paneView';

import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { TwoPointGeometry } from '@core/Drawings/TwoPointDrawingBase';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type {
  AutoscaleInfo,
  Coordinate,
  IPrimitivePaneView,
  ISeriesPrimitiveAxisView,
  Logical,
  MouseEventHandler,
  MouseEventParams,
  Time,
  UTCTimestamp,
} from 'lightweight-charts';

type RulerMode = 'idle' | 'placingEnd' | 'ready';
type RulerAnchorKind = 'start' | 'end';

interface RulerState {
  hidden: boolean;
  mode: RulerMode;
  startAnchor: Anchor | null;
  endAnchor: Anchor | null;
}

interface RulerParams extends BaseDrawingParams {
  resetTriggers?: Observable<unknown>[];
}

export interface RulerRenderData {
  hidden: boolean;
  startPoint: Point | null;
  endPoint: Point | null;
  lineColor: string;
  fillColor: string;
  textColor: string;
  infoLines: string[];
  horizontalArrowSide: Direction.Left | Direction.Right | null;
  verticalArrowSide: Direction.Top | Direction.Bottom | null;
}

export class Ruler extends TwoPointDrawingBase<SettingsValues> implements ISeriesDrawing {
  private removeSelf?: () => void;

  protected settings: SettingsValues = {};
  protected mode: RulerMode = 'idle';

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

  private readonly clickHandler: MouseEventHandler<Time>;
  private readonly moveHandler: MouseEventHandler<Time>;
  private readonly paneView: RulerPaneView;
  private readonly timeAxisPaneView: CustomTimeAxisPaneView;
  private readonly priceAxisPaneView: CustomPriceAxisPaneView;
  private readonly startTimeAxisView: CustomTimeAxisView;
  private readonly endTimeAxisView: CustomTimeAxisView;
  private readonly startPriceAxisView: CustomPriceAxisView;
  private readonly endPriceAxisView: CustomPriceAxisView;

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

    this.removeSelf = removeSelf;
    this.clickHandler = (params) => this.handleChartClick(params);
    this.moveHandler = (params) => this.handleMove(params);

    this.paneView = new RulerPaneView(this);
    this.timeAxisPaneView = this.createTimeAxisPaneView();
    this.priceAxisPaneView = this.createPriceAxisPaneView();
    this.startTimeAxisView = this.createTimeAxisView('start');
    this.endTimeAxisView = this.createTimeAxisView('end');
    this.startPriceAxisView = this.createPriceAxisView('start');
    this.endPriceAxisView = this.createPriceAxisView('end');

    this.subscribeFormat(formatObservable, (format) => {
      this.displayFormat = format;
    });

    resetTriggers.forEach((trigger) => {
      this.subscriptions.add(
        trigger.pipe(skip(1)).subscribe(() => {
          this.removeSelf?.();
        }),
      );
    });

    this.series.attachPrimitive(this);

    if (initialEvent?.sourceEvent) {
      this.startDrawing(this.getEventPoint(initialEvent.sourceEvent));
    }
  }

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

  public getSettingsTabs(): SettingsTab[] {
    return [];
  }

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

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

    const nextState = state as Partial<RulerState>;

    this.hidden = nextState.hidden ?? this.hidden;
    this.mode = nextState.mode ?? this.mode;

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

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

    this.render();
  }

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

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

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

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

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

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

  public autoscaleInfo(startTimePoint: Logical, endTimePoint: Logical): AutoscaleInfo | null {
    if (this.hidden || this.mode === 'placingEnd' || !this.startAnchor || !this.endAnchor) {
      return null;
    }

    const startCoordinate = this.getAnchorTimeCoordinate('start');
    const endCoordinate = this.getAnchorTimeCoordinate('end');

    if (startCoordinate === null || endCoordinate === null) {
      return null;
    }

    const startLogical = this.chart.timeScale().coordinateToLogical(startCoordinate as Coordinate);
    const endLogical = this.chart.timeScale().coordinateToLogical(endCoordinate as Coordinate);

    if (startLogical === null || endLogical === null) {
      return null;
    }

    const leftLogical = Math.min(Number(startLogical), Number(endLogical));
    const rightLogical = Math.max(Number(startLogical), Number(endLogical));

    if (endTimePoint < leftLogical || startTimePoint > rightLogical) {
      return null;
    }

    return {
      priceRange: {
        minValue: Math.min(this.startAnchor.price, this.endAnchor.price),
        maxValue: Math.max(this.startAnchor.price, this.endAnchor.price),
      },
    };
  }

  public getRenderData(): RulerRenderData {
    const startPoint = this.getPointFromAnchor(this.startAnchor);
    const endPoint = this.getPointFromAnchor(this.endAnchor);

    const startPrice = this.startAnchor?.price ?? 0;
    const endPrice = this.endAnchor?.price ?? 0;
    const priceDiff = endPrice - startPrice;
    const percentDiff = startPrice !== 0 ? (priceDiff / startPrice) * 100 : null;

    const startIndex = this.startAnchor ? this.findIndexByTime(this.startAnchor.time) : -1;
    const endIndex = this.endAnchor ? this.findIndexByTime(this.endAnchor.time) : -1;
    const barsCount = startIndex >= 0 && endIndex >= 0 ? Math.abs(endIndex - startIndex) : 0;

    const volume = this.getVolumeInRange();
    const isLong = priceDiff >= 0;

    const horizontalArrowSide =
      startPoint && endPoint && startPoint.x !== endPoint.x
        ? endPoint.x > startPoint.x
          ? Direction.Right
          : Direction.Left
        : null;

    const verticalArrowSide =
      startPoint && endPoint && startPoint.y !== endPoint.y
        ? endPoint.y > startPoint.y
          ? Direction.Bottom
          : Direction.Top
        : null;

    const { colors } = getThemeStore();

    return {
      hidden: this.hidden,
      startPoint,
      endPoint,
      lineColor: isLong ? colors.chartLineColor : colors.chartLineColorAlternative,
      fillColor: isLong ? colors.rulerPositiveFill : colors.rulerNegativeFill,
      textColor: colors.chartPriceLineText,
      infoLines: [
        `${formatPrice(Math.abs(priceDiff))} (${
          percentDiff === null ? '-' : `${formatPrice(Math.abs(percentDiff))}%`
        })`,
        `${barsCount} ${t('bars')},`,
        `${t('Vol')} ${formatVolume(volume)}`,
      ],
      horizontalArrowSide,
      verticalArrowSide,
    };
  }

  public getTimeCoordinate(kind: RulerAnchorKind): Coordinate | null {
    const coordinate = this.getAnchorTimeCoordinate(kind);

    return coordinate === null ? null : (coordinate as Coordinate);
  }

  public getPriceCoordinate(kind: RulerAnchorKind): Coordinate | null {
    const coordinate = this.getAnchorPriceCoordinate(kind);

    return coordinate === null ? null : (coordinate as Coordinate);
  }

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

    if (!geometry) {
      return null;
    }

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

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

    if (!geometry) {
      return null;
    }

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

  public getTimeText(kind: RulerAnchorKind): string {
    const anchor = this.getAnchor(kind);

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

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

  public getPriceText(kind: RulerAnchorKind): string {
    const anchor = this.getAnchor(kind);

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

  public shouldShowInObjectTree(): boolean {
    return false;
  }

  protected getHoveredItem(_x: number, _y: number): PrimitiveHoveredItem | null {
    return null;
  }

  protected getGeometry(): TwoPointGeometry | null {
    return this.getTwoPointGeometry();
  }

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

    return bounds ? [this.createAxisSegment(bounds.left, bounds.right)] : [];
  }

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

    return bounds ? [this.createAxisSegment(bounds.top, bounds.bottom)] : [];
  }

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

    return this.createAxisLabel(
      this.getAnchorTimeCoordinate(kind),
      this.getTimeText(kind),
    );
  }

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

    return this.createAxisLabel(
      this.getAnchorPriceCoordinate(kind),
      this.getPriceText(kind),
    );
  }

  protected bindEvents(): void {
    // todo: попробовать привести к виду базового класса
    if (this.isBound) {
      return;
    }

    this.isBound = true;
    this.chart.subscribeClick(this.clickHandler);
    this.chart.subscribeCrosshairMove(this.moveHandler);
  }

  protected unbindEvents(): void {
    // todo: попробовать привести к виду базового класса
    if (!this.isBound) {
      return;
    }

    this.isBound = false;
    this.chart.unsubscribeClick(this.clickHandler);
    this.chart.unsubscribeCrosshairMove(this.moveHandler);
  }

  private handleChartClick(params: MouseEventParams<Time>): void {
    if (this.hidden || !params.point || !params.sourceEvent) {
      return;
    }

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

    const anchor = this.createAnchorFromParams(params);

    if (!anchor) {
      return;
    }

    if (this.mode === 'idle') {
      this.startDrawing(this.getEventPoint(params.sourceEvent));
      return;
    }

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

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

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

    this.setAnchors(anchor, anchor);
    this.mode = 'placingEnd';

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

  private handleMove(params: MouseEventParams<Time>): void {
    if (this.hidden || !params.point || this.mode !== 'placingEnd') {
      return;
    }

    const anchor = this.createAnchorFromParams(params);

    if (!anchor) {
      return;
    }

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

  private createAnchorFromParams({ time, point }: MouseEventParams<Time>): Anchor | null {
    if (!point || time === undefined) {
      return null;
    }

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

    return price === null ? null : { time, price };
  }

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

    return data.findIndex((item) => {
      return typeof item.time === 'number' && typeof time === 'number' && item.time === time;
    });
  }

  private getVolumeInRange(): number {
    if (!this.startAnchor || !this.endAnchor) {
      return 0;
    }

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

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

    const startIndex = this.findIndexByTime(this.startAnchor.time);
    const endIndex = this.findIndexByTime(this.endAnchor.time);

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

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

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

      if (!item) {
        continue;
      }

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

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

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

    return volume;
  }
}


















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

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
  getPriceFromYCoordinate,
  getYCoordinateFromPrice,
} from '@core/Drawings/helpers';
import { TwoPointDrawingBase } from '@core/Drawings/TwoPointDrawingBase';
import { getDistanceToSegment, updateViews } from '@core/Drawings/utils';

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 '@core/Drawings/DrawingBase';
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 TwoPointDrawingBase<ParallelChannelSettings, ParallelChannelHandleKey>
  implements ISeriesDrawing
{
  private openSettings?: () => void;

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

  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 readonly paneView: ParallelChannelPaneView;
  private readonly timeAxisPaneView: CustomTimeAxisPaneView;
  private readonly priceAxisPaneView: CustomPriceAxisPaneView;
  private readonly startTimeAxisView: CustomTimeAxisView;
  private readonly endTimeAxisView: CustomTimeAxisView;
  private readonly mainStartPriceAxisView: CustomPriceAxisView;
  private readonly mainEndPriceAxisView: CustomPriceAxisView;
  private readonly parallelStartPriceAxisView: CustomPriceAxisView;
  private readonly 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 = this.createTimeAxisPaneView();
    this.priceAxisPaneView = this.createPriceAxisPaneView();

    this.startTimeAxisView = this.createTimeAxisView('start');
    this.endTimeAxisView = this.createTimeAxisView('end');

    this.mainStartPriceAxisView = this.createPriceAxisView('main-start');
    this.mainEndPriceAxisView = this.createPriceAxisView('main-end');
    this.parallelStartPriceAxisView = this.createPriceAxisView('parallel-start');
    this.parallelEndPriceAxisView = this.createPriceAxisView('parallel-end');

    this.subscribeFormat(formatObservable, (format) => {
      this.displayFormat = format;
    });

    this.series.attachPrimitive(this);

    if (initialEvent?.sourceEvent) {
      this.startDrawing(this.getEventPoint(initialEvent.sourceEvent));
    }
  }

  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',
      },
      {
        id: 'parallel-middle',
        ...geometry.parallelMiddlePoint,
        shape: 'circle',
      },
      {
        id: 'parallel-start',
        ...geometry.parallelStartPoint,
        shape: 'circle',
      },
      {
        id: 'main-end',
        ...geometry.endPoint,
        shape: 'circle',
      },
      {
        id: 'main-middle',
        ...geometry.mainMiddlePoint,
        shape: 'circle',
      },
      {
        id: 'main-start',
        ...geometry.startPoint,
        shape: 'circle',
      },
    ];
  }

  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.shouldShowInteractiveAxis()) {
      return [];
    }

    const geometry = this.getGeometry();

    return geometry ? [this.createAxisSegment(geometry.left, geometry.right)] : [];
  }

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

    const geometry = this.getGeometry();

    return geometry ? [this.createAxisSegment(geometry.top, geometry.bottom)] : [];
  }

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

    const anchor = this.getAnchor(kind);

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

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

  protected getPriceAxisLabel(kind: string): AxisLabel | null {
    if (!this.shouldShowInteractiveAxis() || !isPriceLabelKind(kind)) {
      return null;
    }

    const price = this.getPriceLabelValue(kind);

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

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

    return this.createAxisLabel(
      coordinate === null ? null : Number(coordinate),
      formatPrice(price) ?? '',
    );
  }

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

    switch (this.mode) {
      case 'idle':
        event.preventDefault();
        event.stopPropagation();
        this.startDrawing(point);
        return;

      case '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;

      case 'drawing-channel':
        event.preventDefault();
        event.stopPropagation();
        this.setPriceOffset(point);

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

        this.finishDrawing();
        return;

      case 'ready':
        break;

      default:
        return;
    }

    const pointTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
    const isChannelHit = this.isPointOnChannel(point);
    const isSelected = this.isSelected();

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

    if (!isSelected) {
      event.preventDefault();
      event.stopPropagation();
      this.select();
      return;
    }

    if (pointTarget || isChannelHit) {
      event.preventDefault();
      event.stopPropagation();
      this.startDragging(pointTarget ?? '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 mainGeometry = this.getTwoPointGeometry();

    if (!mainGeometry) {
      return null;
    }

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

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

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

    const startPoint = mainGeometry.startPoint;
    const endPoint = mainGeometry.endPoint;
    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.setAnchors(anchor, 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 = this.getAnchor(kind);
    this.setAnchor(kind, anchor);

    if (!this.hasValidMainLine()) {
      this.setAnchor(kind, 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 = this.getAnchor(kind);
    const baseAnchor: Anchor = {
      time: anchor.time,
      price: anchor.price - snapshot.priceOffset,
    };

    this.setAnchor(kind, baseAnchor);

    if (!this.hasValidMainLine()) {
      this.setAnchor(kind, 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()) {
      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()) {
      this.priceOffset = previousOffset;
    }
  }

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

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

    this.moveAnchorsByPixels(
      snapshot.startAnchor,
      snapshot.endAnchor,
      this.dragStartPoint,
      point,
    );

    this.priceOffset = snapshot.priceOffset;
  }

  private setEndAnchor(point: Point): void {
    this.setAnchorFromPoint('end', point);
  }

  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.getPointFromAnchor(startAnchor);
    const endPoint = this.getPointFromAnchor(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();

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

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

    return (
      geometry !== null &&
      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;
    }
  }
}

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