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


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

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

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

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

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

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

type FibonacciRetracementParams = BaseDrawingParams;

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

interface FibonacciDragSnapshot {
  startAnchor: Anchor | null;
  endAnchor: Anchor | null;
}

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

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

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

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

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

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

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

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

  private activeDragTarget: FibonacciRetracementHandle = null;
  private dragPointerId: number | null = null;
  private dragStartPoint: Point | null = null;
  private dragAnchorSnapshot: FibonacciDragSnapshot | null = null;
  private dragGeometrySnapshot: FibonacciRetracementGeometry | null = null;

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

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

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

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

    this.paneView = new FibonacciRetracementPaneView(this);
    this.timeAxisPaneView = this.createTimeAxisPaneView();
    this.priceAxisPaneView = this.createPriceAxisPaneView();
    this.startTimeAxisView = this.createTimeAxisView('start');
    this.endTimeAxisView = this.createTimeAxisView('end');
    this.topPriceAxisView = this.createPriceAxisView('top');
    this.bottomPriceAxisView = this.createPriceAxisView('bottom');

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

  public getState(): FibonacciRetracementState {
    return {
      hidden: this.hidden,
      mode: this.mode,
      startTime: this.startAnchor?.time ?? null,
      endTime: this.endAnchor?.time ?? null,
      startPrice: this.startAnchor?.price ?? null,
      endPrice: this.endAnchor?.price ?? null,
      settings: cloneFibonacciRetracementSettings(this.settings),
    };
  }

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

    const next = state as Partial<FibonacciRetracementState>;

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

    this.restoreStartAnchor(next);
    this.restoreEndAnchor(next);

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

    this.render();
  }

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

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

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

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

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

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

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

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

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

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

    if (!geometry) {
      return null;
    }

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

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

    if (!geometry) {
      return [];
    }

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

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

    const point = { x, y };

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

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

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

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

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

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

    const labelKind = kind as TimeLabelKind;

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

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

    const labelKind = kind as PriceLabelKind;

    return this.createAxisLabel(
      this.getPriceCoordinate(labelKind),
      this.getPriceText(labelKind),
    );
  }

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

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

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

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

    this.openSettings?.();
  };

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

    const point = this.getEventPoint(event);

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

      this.startDrawing(point);
      return;
    }

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

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

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

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

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

      this.select();
      return;
    }

    const dragTarget = this.getDragTarget(point);

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

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

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

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

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

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

    event.preventDefault();

    if (this.activeDragTarget === 'body') {
      this.moveWhole(point);
    } else {
      this.resize(point);
    }

    this.render();
  };

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

    this.mode = 'ready';
    this.resolveReady?.();
    this.clearInteractionState();
    this.render();
  };

  protected getGeometry(): FibonacciRetracementGeometry | null {
    const baseGeometry = this.getTwoPointGeometry();

    if (!baseGeometry) {
      return null;
    }

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

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

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

    if (!anchor) {
      return;
    }

    this.setAnchors(anchor, anchor);
    this.mode = 'drawing';
    this.render();
  }

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

    if (!this.setAnchorFromPoint('end', clampedPoint)) {
      return;
    }

    this.render();
  }

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

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

      this.resetToIdle();
      return;
    }

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

  private startDragging(
    point: Point,
    pointerId: number,
    dragTarget: Exclude<FibonacciRetracementHandle, null>,
  ): void {
    this.mode = 'dragging';
    this.activeDragTarget = dragTarget;
    this.dragPointerId = pointerId;
    this.dragStartPoint = point;
    this.dragAnchorSnapshot = {
      startAnchor: this.startAnchor,
      endAnchor: this.endAnchor,
    };
    this.dragGeometrySnapshot = this.getGeometry();

    this.render();
  }

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

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

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

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

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

    if (!snapshot || !geometry) {
      return;
    }

    this.moveAnchorsWithinContainer(
      snapshot.startAnchor,
      snapshot.endAnchor,
      this.dragStartPoint,
      point,
      geometry,
    );
  }

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

    this.setAnchorFromPoint(
      this.activeDragTarget,
      this.clampPointToContainer(point),
    );
  }

  private getLevels(): FibonacciRetracementLevelRenderData[] {
    if (!this.startAnchor || !this.endAnchor) {
      return [];
    }

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

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

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

        return result;
      },
      [],
    );
  }

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

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

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

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

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

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

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

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

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

    return parts.join(' ');
  }

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

    if (!geometry) {
      return null;
    }

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

  private getTimeText(kind: TimeLabelKind): string {
    const 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,
    );
  }

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

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

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

    if (!geometry || !this.startAnchor || !this.endAnchor) {
      return null;
    }

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

    if (edgeLevel) {
      return edgeLevel.price;
    }

    return kind === 'top'
      ? Math.max(this.startAnchor.price, this.endAnchor.price)
      : Math.min(this.startAnchor.price, this.endAnchor.price);
  }

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

    if (!geometry) {
      return false;
    }

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

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

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

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

  private restoreStartAnchor(state: Partial<FibonacciRetracementState>): void {
    const time = state.startTime ?? this.startAnchor?.time ?? null;
    const price = state.startPrice ?? this.startAnchor?.price ?? null;

    if (time !== null && price !== null) {
      this.startAnchor = { time, price };
    }
  }

  private restoreEndAnchor(state: Partial<FibonacciRetracementState>): void {
    const time = state.endTime ?? this.endAnchor?.time ?? null;
    const price = state.endPrice ?? this.endAnchor?.price ?? null;

    if (time !== null && price !== null) {
      this.endAnchor = { time, price };
    }
  }
}

















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

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

import { t } from '@src/translations';
import { Defaults } from '@src/types/defaults';
import {
  formatPercent,
  formatPrice,
  formatSignedNumber,
  formatVolume,
} from '@src/utils';
import { formatDate } from '@src/utils/formatter';

import { DiapsonPaneView } from './paneView';
import {
  createDefaultSettings,
  DiapsonSettings,
  DiapsonStyle,
  DiapsonTextStyle,
  getDiapsonSettingsTabs,
} from './settings';

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

export type DiapsonRangeMode = 'date' | 'price';

type DiapsonMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type DiapsonHandle = 'body' | 'start' | 'end' | null;
type DiapsonHandleKey = Exclude<DiapsonHandle, 'body' | null>;
type TimeLabelKind = 'left' | 'right';
type PriceLabelKind = 'top' | 'bottom';

interface DiapsonParams extends BaseDrawingParams {
  rangeMode: DiapsonRangeMode;
  stepSize?: number;
  stepLabel?: string;
}

export interface DiapsonState {
  hidden: boolean;
  mode: DiapsonMode;
  rangeMode: DiapsonRangeMode;
  startTime: Time | null;
  endTime: Time | null;
  startPrice: number | null;
  endPrice: number | null;
  settings: DiapsonSettings;
}

interface DiapsonGeometry {
  left: number;
  right: number;
  top: number;
  bottom: number;
  width: number;
  height: number;
  startPoint: Point;
  endPoint: Point;
}

export interface DiapsonRenderData
  extends DiapsonGeometry,
    DiapsonStyle,
    DiapsonTextStyle {
  rangeMode: DiapsonRangeMode;
  showFill: boolean;
  labelLines: string[];
}

interface DateMetrics {
  barsCount: number;
  elapsedText: string;
  volumeText: string;
}

interface PriceMetrics {
  delta: number;
  percent: number;
  steps: number;
}

const BODY_HIT_TOLERANCE = 6;
const MIN_RECTANGLE_WIDTH = 6;
const MIN_RECTANGLE_HEIGHT = 6;

export class Diapson
  extends TwoPointDrawingBase<DiapsonSettings, DiapsonHandleKey>
  implements ISeriesDrawing
{
  private removeSelf?: () => void;
  private openSettings?: () => void;

  protected settings: DiapsonSettings = createDefaultSettings();
  protected mode: DiapsonMode = 'idle';

  private rangeMode: DiapsonRangeMode;
  private activeDragTarget: DiapsonHandle = null;
  private dragPointerId: number | null = null;
  private dragStartPoint: Point | null = null;
  private dragGeometrySnapshot: DiapsonGeometry | null = null;

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

  private readonly stepSize: number;
  private readonly stepLabel: string;
  private readonly paneView: DiapsonPaneView;
  private readonly timeAxisPaneView: CustomTimeAxisPaneView;
  private readonly priceAxisPaneView: CustomPriceAxisPaneView;
  private readonly leftTimeAxisView: CustomTimeAxisView;
  private readonly rightTimeAxisView: CustomTimeAxisView;
  private readonly topPriceAxisView: CustomPriceAxisView;
  private readonly bottomPriceAxisView: CustomPriceAxisView;

  constructor({
    chart,
    series,
    container,
    interaction,
    rangeMode,
    formatObservable,
    removeSelf,
    openSettings,
    stepSize = 1,
    stepLabel = '',
    initialEvent,
  }: DiapsonParams) {
    super({ chart, series, container, interaction });

    this.rangeMode = rangeMode;
    this.removeSelf = removeSelf;
    this.openSettings = openSettings;
    this.stepSize = stepSize > 0 ? stepSize : 1;
    this.stepLabel = stepLabel;

    this.paneView = new DiapsonPaneView(this);
    this.timeAxisPaneView = this.createTimeAxisPaneView();
    this.priceAxisPaneView = this.createPriceAxisPaneView();
    this.leftTimeAxisView = this.createTimeAxisView('left');
    this.rightTimeAxisView = this.createTimeAxisView('right');
    this.topPriceAxisView = this.createPriceAxisView('top');
    this.bottomPriceAxisView = this.createPriceAxisView('bottom');

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

  public setRangeMode(nextMode: DiapsonRangeMode): void {
    if (this.rangeMode === nextMode) {
      return;
    }

    this.rangeMode = nextMode;
    this.resetToIdle();
  }

  public getState(): DiapsonState {
    return {
      hidden: this.hidden,
      mode: this.mode,
      rangeMode: this.rangeMode,
      startTime: this.startAnchor?.time ?? null,
      endTime: this.endAnchor?.time ?? null,
      startPrice: this.startAnchor?.price ?? null,
      endPrice: this.endAnchor?.price ?? null,
      settings: { ...this.settings },
    };
  }

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

    const nextState = state as Partial<DiapsonState>;

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

    this.restoreStartAnchor(nextState);
    this.restoreEndAnchor(nextState);

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

    this.render();
  }

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

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

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

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

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

  public timeAxisViews() {
    return [this.leftTimeAxisView, this.rightTimeAxisView];
  }

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      ...geometry,
      rangeMode: this.rangeMode,
      showFill: true,
      labelLines: this.getLabelLines(),
      ...this.settings,
    };
  }

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

    if (!geometry) {
      return [];
    }

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

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

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

    const labelKind = kind as TimeLabelKind;

    return this.createAxisLabel(
      this.getTimeCoordinate(labelKind),
      this.getTimeText(labelKind),
    );
  }

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

    const labelKind = kind as PriceLabelKind;

    return this.createAxisLabel(
      this.getPriceCoordinate(labelKind),
      this.getPriceText(labelKind),
    );
  }

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

    const point = { x, y };

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

      return {
        cursorStyle: 'move',
        externalId: `diapson-${this.rangeMode}`,
        zOrder: 'top',
      };
    }

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

    if (handleTarget) {
      return {
        cursorStyle: this.getCursorStyle(handleTarget),
        externalId: `diapson-${this.rangeMode}`,
        zOrder: 'top',
      };
    }

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

    return {
      cursorStyle: 'move',
      externalId: `diapson-${this.rangeMode}`,
      zOrder: 'top',
    };
  }

  protected getGeometry(): DiapsonGeometry | null {
    const rawStartPoint = this.getPointFromAnchor(this.startAnchor);
    const rawEndPoint = this.getPointFromAnchor(this.endAnchor);

    if (!rawStartPoint || !rawEndPoint) {
      return null;
    }

    const { width, height } = this.getContainerSize();

    const startPoint = {
      x: clamp(Math.round(rawStartPoint.x), 0, width),
      y: clamp(Math.round(rawStartPoint.y), 0, height),
    };

    const endPoint = {
      x: clamp(Math.round(rawEndPoint.x), 0, width),
      y: clamp(Math.round(rawEndPoint.y), 0, height),
    };

    const left = Math.min(startPoint.x, endPoint.x);
    const right = Math.max(startPoint.x, endPoint.x);
    const top = Math.min(startPoint.y, endPoint.y);
    const bottom = Math.max(startPoint.y, endPoint.y);

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

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

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

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

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

    this.openSettings?.();
  };

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

    const point = this.getEventPoint(event);

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

      this.startDrawing(point);
      return;
    }

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

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

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

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

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

      this.select();
      return;
    }

    const dragTarget = this.getDragTarget(point);

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

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

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

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

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

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

    event.preventDefault();

    if (this.activeDragTarget === 'body') {
      this.moveWhole(point);
      this.render();
      return;
    }

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

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

    this.finishDragging();
  };

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

    if (!anchor) {
      return;
    }

    this.setAnchors(anchor, anchor);
    this.mode = 'drawing';
    this.render();
  }

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

    if (!this.setAnchorFromPoint('end', nextPoint)) {
      return;
    }

    this.render();
  }

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

    if (!geometry) {
      this.resetToIdle();
      return;
    }

    if (
      geometry.width < MIN_RECTANGLE_WIDTH ||
      geometry.height < MIN_RECTANGLE_HEIGHT
    ) {
      if (this.removeSelf) {
        this.removeSelf();
        return;
      }

      this.resetToIdle();
      return;
    }

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

  private startDragging(
    point: Point,
    pointerId: number,
    dragTarget: Exclude<DiapsonHandle, null>,
  ): void {
    this.mode = 'dragging';
    this.activeDragTarget = dragTarget;
    this.dragPointerId = pointerId;
    this.dragStartPoint = point;
    this.dragGeometrySnapshot = this.getGeometry();

    this.render();
  }

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

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

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

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

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

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

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

    if (!geometry || !dragStartPoint) {
      return;
    }

    const { width, height } = this.getContainerSize();
    const rawOffsetX = point.x - dragStartPoint.x;
    const rawOffsetY = point.y - dragStartPoint.y;

    const offsetX = clamp(rawOffsetX, -geometry.left, width - geometry.right);
    const offsetY = clamp(rawOffsetY, -geometry.top, height - geometry.bottom);

    const nextStartPoint = this.clampPointToContainer({
      x: geometry.startPoint.x + offsetX,
      y: geometry.startPoint.y + offsetY,
    });

    const nextEndPoint = this.clampPointToContainer({
      x: geometry.endPoint.x + offsetX,
      y: geometry.endPoint.y + offsetY,
    });

    this.setAnchorsFromPoints(nextStartPoint, nextEndPoint);
  }

  private resizeRectangle(point: Point): void {
    const geometry = this.dragGeometrySnapshot;

    if (!geometry || !this.activeDragTarget || this.activeDragTarget === 'body') {
      return;
    }

    const nextPoint = this.clampPointToContainer(point);

    if (this.activeDragTarget === 'start') {
      this.setAnchorsFromPoints(nextPoint, geometry.endPoint);
      return;
    }

    this.setAnchorsFromPoints(geometry.startPoint, nextPoint);
  }

  private getLabelLines(): string[] {
    if (this.rangeMode === 'date') {
      const metrics = this.getDateMetrics();

      if (!metrics) {
        return [];
      }

      const firstLine = metrics.elapsedText
        ? `${metrics.barsCount} ${t('bars')}, ${metrics.elapsedText}`
        : `${metrics.barsCount} ${t('bars')}`;

      if (!metrics.volumeText) {
        return [firstLine];
      }

      return [firstLine, `${t('Vol')} ${metrics.volumeText}`];
    }

    const metrics = this.getPriceMetrics();

    if (!metrics) {
      return [];
    }

    const percentText =
      metrics.percent < 0
        ? `-${formatPercent(Math.abs(metrics.percent))}`
        : formatPercent(Math.abs(metrics.percent));

    const absSteps = Math.abs(metrics.steps);
    let stepsText = '';

    if (Number.isInteger(absSteps)) {
      stepsText = absSteps.toString();
    } else if (absSteps >= 1000) {
      stepsText = absSteps.toFixed(0);
    } else if (absSteps >= 100) {
      stepsText = absSteps.toFixed(1);
    } else {
      stepsText = absSteps.toFixed(2);
    }

    if (metrics.steps < 0) {
      stepsText = `-${stepsText}`;
    }

    const stepSuffix = this.stepLabel ? ` ${this.stepLabel}` : '';

    return [
      `${formatSignedNumber(metrics.delta)} (${percentText}) ${stepsText}${stepSuffix}`,
    ];
  }

  private getDateMetrics(): DateMetrics | null {
    const leftTime = this.getLeftTimeValue();
    const rightTime = this.getRightTimeValue();

    if (leftTime === null || rightTime === null) {
      return null;
    }

    const barsCount = this.getBarsCount();
    const durationSeconds = Math.max(
      0,
      Math.floor(Math.abs(Number(rightTime) - Number(leftTime))),
    );
    const days = Math.floor(durationSeconds / 86400);
    const hours = Math.floor((durationSeconds % 86400) / 3600);
    const minutes = Math.floor((durationSeconds % 3600) / 60);
    const seconds = durationSeconds % 60;

    const elapsedParts: string[] = [];

    if (days > 0) {
      elapsedParts.push(`${days}d`);
    }

    if (hours > 0) {
      elapsedParts.push(`${hours}h`);
    }

    if (minutes > 0) {
      elapsedParts.push(`${minutes}m`);
    }

    if (elapsedParts.length === 0) {
      elapsedParts.push(`${seconds}s`);
    }

    const volume = this.getVolumeInRange();

    return {
      barsCount,
      elapsedText: elapsedParts.slice(0, 2).join(' '),
      volumeText: volume > 0 ? formatVolume(volume) : '',
    };
  }

  private getPriceMetrics(): PriceMetrics | null {
    if (!this.startAnchor || !this.endAnchor) {
      return null;
    }

    const delta = this.endAnchor.price - this.startAnchor.price;

    return {
      delta,
      percent:
        this.startAnchor.price !== 0
          ? (delta / Math.abs(this.startAnchor.price)) * 100
          : 0,
      steps: delta / this.stepSize,
    };
  }

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

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

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

    return Math.abs(endIndex - startIndex);
  }

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

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

    return data.findIndex((item) => Number(item.time) === Number(time));
  }

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

    if (!geometry) {
      return null;
    }

    return kind === 'left' ? geometry.left : geometry.right;
  }

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

    if (!geometry) {
      return null;
    }

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

  private getTimeText(kind: TimeLabelKind): string {
    const time = kind === 'left' ? this.getLeftTimeValue() : this.getRightTimeValue();

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

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

  private getPriceText(kind: PriceLabelKind): string {
    const price = kind === 'top' ? this.getTopPriceValue() : this.getBottomPriceValue();

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

  private getLeftTimeValue(): Time | null {
    if (!this.startAnchor || !this.endAnchor) {
      return null;
    }

    const geometry = this.getTwoPointGeometry();

    if (!geometry) {
      return this.startAnchor.time;
    }

    return geometry.startPoint.x <= geometry.endPoint.x
      ? this.startAnchor.time
      : this.endAnchor.time;
  }

  private getRightTimeValue(): Time | null {
    if (!this.startAnchor || !this.endAnchor) {
      return null;
    }

    const geometry = this.getTwoPointGeometry();

    if (!geometry) {
      return this.endAnchor.time;
    }

    return geometry.startPoint.x <= geometry.endPoint.x
      ? this.endAnchor.time
      : this.startAnchor.time;
  }

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

    const geometry = this.getTwoPointGeometry();

    if (!geometry) {
      return Math.max(this.startAnchor.price, this.endAnchor.price);
    }

    return geometry.startPoint.y <= geometry.endPoint.y
      ? this.startAnchor.price
      : this.endAnchor.price;
  }

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

    const geometry = this.getTwoPointGeometry();

    if (!geometry) {
      return Math.min(this.startAnchor.price, this.endAnchor.price);
    }

    return geometry.startPoint.y <= geometry.endPoint.y
      ? this.endAnchor.price
      : this.startAnchor.price;
  }

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

    return geometry !== null && isPointInBounds(point, geometry, BODY_HIT_TOLERANCE);
  }

  private getCursorStyle(
    handle: Exclude<DiapsonHandle, null>,
  ): PrimitiveHoveredItem['cursorStyle'] {
    if (handle === 'body') {
      return 'move';
    }

    const geometry = this.getGeometry();

    if (!geometry) {
      return 'default';
    }

    const sameDirection =
      (geometry.endPoint.x - geometry.startPoint.x >= 0 &&
        geometry.endPoint.y - geometry.startPoint.y >= 0) ||
      (geometry.endPoint.x - geometry.startPoint.x < 0 &&
        geometry.endPoint.y - geometry.startPoint.y < 0);

    return sameDirection ? 'nwse-resize' : 'nesw-resize';
  }

  private restoreStartAnchor(state: Partial<DiapsonState>): void {
    if (!('startTime' in state) && !('startPrice' in state)) {
      return;
    }

    const time =
      'startTime' in state
        ? state.startTime ?? null
        : this.startAnchor?.time ?? null;

    const price =
      'startPrice' in state
        ? state.startPrice ?? null
        : this.startAnchor?.price ?? null;

    this.startAnchor =
      time !== null && price !== null
        ? { time, price }
        : null;
  }

  private restoreEndAnchor(state: Partial<DiapsonState>): void {
    if (!('endTime' in state) && !('endPrice' in state)) {
      return;
    }

    const time =
      'endTime' in state
        ? state.endTime ?? null
        : this.endAnchor?.time ?? null;

    const price =
      'endPrice' in state
        ? state.endPrice ?? null
        : this.endAnchor?.price ?? null;

    this.endAnchor =
      time !== null && price !== null
        ? { time, price }
        : null;
  }
}