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


import cloneDeep from 'lodash-es/cloneDeep';

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

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

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

export interface FibonacciRetracementSettings {
  levels: FibonacciRetracementLevelSettings[];

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

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

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

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

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

    showBackground: true,
    backgroundOpacity: 0.18,
    reverse: false,

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

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

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

  const settings: FibonacciRetracementSettings = {
    ...current,

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

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

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

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

  return settings;
}

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

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

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

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

export function getFibonacciRetracementSettingsTabs(settings: FibonacciRetracementSettings): SettingsTab[] {
  const styleFields: SettingField[] = [
    {
      key: 'backgroundOpacity',
      label: 'Прозрачность фона',
      type: 'range',
      defaultValue: settings.backgroundOpacity,
      min: 0,
      max: 1,
    },
    {
      key: 'showBackground',
      label: 'Фон',
      type: 'boolean',
      defaultValue: settings.showBackground,
    },
    {
      key: 'reverse',
      label: 'Переворот',
      type: 'boolean',
      defaultValue: settings.reverse,
    },
  ];

  const textFields: SettingField[] = [
    {
      key: 'labelsPosition',
      label: 'Положение меток',
      type: 'select',
      defaultValue: settings.labelsPosition,
      options: [
        { label: 'Слева', value: 'left' },
        { label: 'Справа', value: 'right' },
      ],
    },
    {
      key: 'showPrices',
      label: 'Отображать цену',
      type: 'boolean',
      defaultValue: settings.showPrices,
    },
    {
      key: 'showLevelValues',
      label: 'Отображать уровень',
      type: 'boolean',
      defaultValue: settings.showLevelValues,
    },
    {
      key: 'fontSize',
      label: 'Размер текста',
      type: 'number',
      defaultValue: settings.fontSize,
      min: 8,
      max: 24,
    },
    {
      key: 'isBold',
      label: 'Жирный',
      type: 'boolean',
      defaultValue: settings.isBold,
    },
    {
      key: 'isItalic',
      label: 'Курсив',
      type: 'boolean',
      defaultValue: settings.isItalic,
    },
  ];

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

  return [
    {
      key: 'style',
      label: 'Стиль',
      fields: styleFields,
    },
    {
      key: 'text',
      label: 'Текст',
      fields: textFields,
    },
    {
      key: 'levels',
      label: 'Уровни',
      fields: levelFields,
    },
  ];
}

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

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

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

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

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

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

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

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



import { Observable, Subscription } from 'rxjs';

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
  clamp,
  clampPointToContainer as clampPointToContainerInElement,
  getAnchorFromPoint,
  getContainerSize as getElementContainerSize,
  getPointerPoint as getPointerPointFromEvent,
  getPriceDelta as getPriceDeltaFromCoordinates,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  isNearPoint,
  isPointInBounds,
  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 { FibonacciRetracementPaneView } from './paneView';
import {
  cloneFibonacciRetracementSettings,
  createDefaultSettings,
  formatLevelLabel,
  getFibonacciRetracementSettingsTabs,
  getFibonacciRetracementSettingsValues,
  getVisibleFibonacciLevels,
  mergeFibonacciRetracementSettings,
  FibonacciRetracementSettings,
} from './settings';

import type { ISeriesDrawing } from '@core/Drawings/common';
import type { AxisLabel, AxisSegment, Bounds, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab, SettingsValues } from '@src/types';
import type {
  AutoscaleInfo,
  IChartApi,
  IPrimitivePaneView,
  PrimitiveHoveredItem,
  SeriesAttachedParameter,
  SeriesOptionsMap,
  Time,
  UTCTimestamp,
} from 'lightweight-charts';

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

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

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

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

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

interface FibonacciRetracementGeometry {
  startPoint: Point;
  endPoint: Point;
  left: number;
  right: number;
  top: number;
  bottom: number;
  width: number;
  height: number;
  levels: FibonacciRetracementLevelRenderData[];
  areas: FibonacciRetracementAreaRenderData[];
  handles: Record<FibonacciRetracementHandleKey, Point>;
}

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

export interface FibonacciRetracementRenderData
  extends FibonacciRetracementGeometry,
    FibonacciRetracementRenderSettings {
  showHandles: boolean;
}

const HANDLE_HIT_TOLERANCE = 8;
const BODY_HIT_TOLERANCE = 6;
const LINE_HIT_TOLERANCE = 6;
const MIN_DISTANCE = 6;

export class FibonacciRetracement implements ISeriesDrawing {
  private chart: IChartApi;
  private series: SeriesApi;
  private container: HTMLElement;
  private removeSelf?: () => void;
  private openSettings?: () => void;

  private settings: FibonacciRetracementSettings = createDefaultSettings();

  private requestUpdate: (() => void) | null = null;
  private isBound = false;
  private subscriptions = new Subscription();

  private hidden = false;
  private isActive = false;
  private mode: FibonacciRetracementMode = 'idle';

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

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

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

  private readonly paneView: FibonacciRetracementPaneView;
  private readonly timeAxisPaneView: CustomTimeAxisPaneView;
  private readonly priceAxisPaneView: CustomPriceAxisPaneView;
  private readonly startTimeAxisView: CustomTimeAxisView;
  private readonly endTimeAxisView: CustomTimeAxisView;
  private readonly priceAxisViewsList: CustomPriceAxisView[];

  constructor(
    chart: IChartApi,
    series: SeriesApi,
    { container, formatObservable, removeSelf, openSettings }: FibonacciRetracementParams,
  ) {
    this.chart = chart;
    this.series = series;
    this.container = container;
    this.removeSelf = removeSelf;
    this.openSettings = openSettings;

    this.paneView = new FibonacciRetracementPaneView(this);

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

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

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

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

    this.priceAxisViewsList = createDefaultSettings().levels.map(
      (level) =>
        new CustomPriceAxisView({
          getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
          labelKind: level.id,
        }),
    );

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

    this.series.attachPrimitive(this);
  }

  public show(): void {
    this.hidden = false;
    this.render();
  }

  public hide(): void {
    this.hidden = true;
    this.render();
  }

  public destroy(): void {
    this.unbindEvents();
    this.subscriptions.unsubscribe();
    this.series.detachPrimitive(this);
    this.requestUpdate = null;
  }

  public rebind(series: SeriesApi): void {
    if (this.series === series) {
      return;
    }

    this.unbindEvents();
    this.series.detachPrimitive(this);

    this.series = series;
    this.requestUpdate = null;

    this.series.attachPrimitive(this);
    this.render();
  }

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

  public shouldShowInObjectTree(): boolean {
    return this.mode !== 'idle';
  }

  public getState(): FibonacciRetracementState {
    return {
      hidden: this.hidden,
      isActive: this.isActive,
      mode: this.mode,
      startTime: this.startTime,
      endTime: this.endTime,
      startPrice: this.startPrice,
      endPrice: this.endPrice,
      settings: cloneFibonacciRetracementSettings(this.settings),
    };
  }

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

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

    this.startTime = next.startTime ?? this.startTime;
    this.endTime = next.endTime ?? this.endTime;
    this.startPrice = next.startPrice ?? this.startPrice;
    this.endPrice = next.endPrice ?? this.endPrice;

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

    this.render();
  }

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

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

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

  public attached(param: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
    this.requestUpdate = param.requestUpdate;
    this.bindEvents();
  }

  public detached(): void {
    this.unbindEvents();
    this.requestUpdate = null;
  }

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

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

  public autoscaleInfo(): AutoscaleInfo | null {
    return null;
  }

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

    if (!geometry) {
      return null;
    }

    return {
      ...geometry,
      showHandles: this.isActive,

      showBackground: this.settings.showBackground,
      backgroundOpacity: this.settings.backgroundOpacity,
      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,
    };
  }

  public getTimeAxisSegments(): AxisSegment[] {
    const bounds = this.isActive ? this.getTimeBounds() : null;

    if (!bounds) {
      return [];
    }

    const { colors } = getThemeStore();

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

  public getPriceAxisSegments(): AxisSegment[] {
    const bounds = this.isActive ? this.getPriceBounds() : null;

    if (!bounds) {
      return [];
    }

    const { colors } = getThemeStore();

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

  public getTimeAxisLabel(kind: string): AxisLabel | null {
    const coordinate = this.isActive ? this.getTimeCoordinate(kind as TimeLabelKind) : null;
    const text = this.getTimeText(kind as TimeLabelKind);

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

    const { colors } = getThemeStore();

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

  public getPriceAxisLabel(kind: string): AxisLabel | null {
    const level = this.isActive && this.settings.showPrices ? this.getLevelById(kind) : null;

    if (!level) {
      return null;
    }

    const { colors } = getThemeStore();

    return {
      coordinate: level.y,
      text: formatPrice(level.price) ?? '',
      textColor: colors.chartPriceLineText,
      backgroundColor: level.color,
    };
  }

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

    const point = { x, y };

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

    const handleTarget = this.isActive ? this.getHandleTarget(point) : null;

    if (handleTarget) {
      return {
        cursorStyle: 'pointer',
        externalId: 'fibonacci-retracement-position',
        zOrder: 'top',
      };
    }

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

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

  private bindEvents(): void {
    if (this.isBound) {
      return;
    }

    this.isBound = true;

    this.container.addEventListener('dblclick', this.handleDoubleClick);
    this.container.addEventListener('pointerdown', this.handlePointerDown);
    window.addEventListener('pointermove', this.handlePointerMove);
    window.addEventListener('pointerup', this.handlePointerUp);
    window.addEventListener('pointercancel', this.handlePointerUp);
  }

  private unbindEvents(): void {
    if (!this.isBound) {
      return;
    }

    this.isBound = false;

    this.container.removeEventListener('dblclick', this.handleDoubleClick);
    this.container.removeEventListener('pointerdown', this.handlePointerDown);
    window.removeEventListener('pointermove', this.handlePointerMove);
    window.removeEventListener('pointerup', this.handlePointerUp);
    window.removeEventListener('pointercancel', this.handlePointerUp);
  }

  private handleDoubleClick = (event: MouseEvent): void => {
    const point = this.getMousePoint(event);

    if (this.hidden || this.mode !== 'ready' || (!this.containsPoint(point) && !this.getHandleTarget(point))) {
      return;
    }

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

    this.openSettings?.();
  };

  private 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.isActive) {
      this.isActive = this.containsPoint(point);
      this.render();
      return;
    }

    const dragTarget = this.getDragTarget(point);

    if (!dragTarget) {
      this.isActive = false;
      this.render();
      return;
    }

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

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

  private 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();
  };

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

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

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

    if (!anchor) {
      return;
    }

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

    this.isActive = true;
    this.mode = 'drawing';

    this.render();
  }

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

    if (!anchor) {
      return;
    }

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

    this.render();
  }

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

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

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

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

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

    this.render();
  }

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

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

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

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

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

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

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

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

    const containerSize = this.getContainerSize();

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

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

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

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

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

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

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

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

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

    if (!anchor) {
      return;
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

      return result;
    }, []);
  }

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

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

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

    const sortedLevels = [...levels].sort((a, b) => a.y - b.y);

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

      return {
        top: level.y,
        bottom: nextLevel.y,
        color: nextLevel.color,
      };
    });
  }

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

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

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

    return parts.join(' ');
  }

  private getLevelById(id: string): FibonacciRetracementLevelRenderData | null {
    return this.getGeometry()?.levels.find((level) => level.id === id) ?? null;
  }

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

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

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

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

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

    if (!geometry) {
      return null;
    }

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

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

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

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

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

    if (!geometry) {
      return null;
    }

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

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

    return null;
  }

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

    if (!geometry) {
      return false;
    }

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

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

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

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

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

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

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

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

  private getEventPoint(event: PointerEvent): Point {
    return getPointerPointFromEvent(this.container, event);
  }

  private getMousePoint(event: MouseEvent): Point {
    const rect = this.container.getBoundingClientRect();

    return this.clampPointToContainer({
      x: event.clientX - rect.left,
      y: event.clientY - rect.top,
    });
  }

  private render(): void {
    this.updateAllViews();
    this.requestUpdate?.();
  }
}


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

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

import { FibonacciRetracement } from './fibonacciRetracement';

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

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

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

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

    if (!data) {
      return;
    }

    const { colors } = getThemeStore();

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

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

      context.save();

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

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

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

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

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

      if (data.showHandles) {
        drawHandle(
          context,
          data.startPoint.x * horizontalPixelRatio,
          data.startPoint.y * verticalPixelRatio,
          horizontalPixelRatio,
          verticalPixelRatio,
          colors.chartLineColor,
          colors.chartBackground,
        );

        drawHandle(
          context,
          data.endPoint.x * horizontalPixelRatio,
          data.endPoint.y * verticalPixelRatio,
          horizontalPixelRatio,
          verticalPixelRatio,
          colors.chartLineColor,
          colors.chartBackground,
        );
      }

      context.restore();
    });
  }
}

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

  if (bottom <= top) {
    return;
  }

  context.save();

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

  context.restore();
}

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

  context.save();

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

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

  context.restore();
}

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

  context.save();

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

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

  context.restore();
}

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

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

  context.save();

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

  context.fillText(text, x, y);

  context.restore();
}

function drawHandle(
  context: CanvasRenderingContext2D,
  x: number,
  y: number,
  horizontalPixelRatio: number,
  verticalPixelRatio: number,
  strokeColor: string,
  fillColor: string,
): void {
  const width = UI.handleSize * horizontalPixelRatio;
  const height = UI.handleSize * verticalPixelRatio;
  const left = x - width / 2;
  const top = y - height / 2;

  context.save();

  context.fillStyle = fillColor;
  context.strokeStyle = strokeColor;
  context.lineWidth = UI.handleBorderWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);

  context.beginPath();
  context.rect(left, top, width, height);
  context.fill();
  context.stroke();

  context.restore();
}