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


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

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

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

export interface FibonacciRetracementSettings {
  levels: FibonacciRetracementLevelSettings[];

  showBackground: boolean;
  backgroundOpacity: number;

  showPrices: boolean;
  showLevels: boolean;
  showText: boolean;

  labelsPosition: 'left' | 'right';
  fontSize: number;

  lineWidth: number;
  lineStyle: 'solid' | 'dashed';

  controlLineColor: string;
}

const palette = INDICATOR_COLOR_PALETTE;

export function createDefaultSettings(): FibonacciRetracementSettings {
  return {
    levels: [
      {
        id: '0',
        value: 0,
        visible: true,
        color: '#9AA0A6',
      },
      {
        id: '0.236',
        value: 0.236,
        visible: true,
        color: palette[9] ?? '#FF6F8F',
      },
      {
        id: '0.382',
        value: 0.382,
        visible: true,
        color: palette[10] ?? '#FF8A4A',
      },
      {
        id: '0.5',
        value: 0.5,
        visible: true,
        color: palette[16] ?? '#74CF73',
      },
      {
        id: '0.618',
        value: 0.618,
        visible: true,
        color: palette[18] ?? '#67D4BF',
      },
      {
        id: '0.786',
        value: 0.786,
        visible: true,
        color: palette[19] ?? '#79D8E2',
      },
      {
        id: '1',
        value: 1,
        visible: true,
        color: '#9AA0A6',
      },
      {
        id: '1.618',
        value: 1.618,
        visible: true,
        color: palette[0] ?? '#56A8FF',
      },
      {
        id: '2.618',
        value: 2.618,
        visible: true,
        color: palette[8] ?? '#FF67BB',
      },
      {
        id: '3.618',
        value: 3.618,
        visible: true,
        color: palette[4] ?? '#8B5FFF',
      },
      {
        id: '4.236',
        value: 4.236,
        visible: true,
        color: palette[7] ?? '#F062DB',
      },
    ],

    showBackground: true,
    backgroundOpacity: 0.18,

    showPrices: true,
    showLevels: true,
    showText: true,

    labelsPosition: 'left',
    fontSize: 12,

    lineWidth: 1,
    lineStyle: 'solid',

    controlLineColor: '#9AA0A6',
  };
}

export function mergeFibonacciRetracementSettings(
  current: FibonacciRetracementSettings,
  next: Partial<FibonacciRetracementSettings>,
): FibonacciRetracementSettings {
  return {
    ...current,
    ...next,
    levels: mergeLevels(current.levels, next.levels),
  };
}

export function cloneFibonacciRetracementSettings(
  settings: FibonacciRetracementSettings,
): FibonacciRetracementSettings {
  return {
    ...settings,
    levels: settings.levels.map((level) => ({ ...level })),
  };
}

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

export function getFibonacciRetracementSettingsTabs(): SettingsTab[] {
  return [
    {
      key: 'style',
      label: 'Стиль',
      fields: [],
    },
    {
      key: 'levels',
      label: 'Уровни',
      fields: [],
    },
  ] as SettingsTab[];
}

function mergeLevels(
  currentLevels: FibonacciRetracementLevelSettings[],
  nextLevels?: FibonacciRetracementLevelSettings[],
): FibonacciRetracementLevelSettings[] {
  if (!nextLevels) {
    return currentLevels.map((level) => ({ ...level }));
  }

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

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




import {
  clamp,
  clampPointToContainer as clampPointToContainerInElement,
  getAnchorFromPoint,
  getContainerSize as getElementContainerSize,
  getPointerPoint as getPointerPointFromEvent,
  getPriceDelta as getPriceDeltaFromCoordinates,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  isNearPoint,
  shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';

import { formatPrice } from '@src/utils';

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

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

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

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

interface FibonacciRetracementState {
  hidden: boolean;
  isActive: boolean;
  mode: FibonacciRetracementMode;
  startAnchor: Anchor | null;
  endAnchor: Anchor | null;
  settings: FibonacciRetracementSettings;
}

export interface FibonacciRetracementLevelRenderData {
  id: string;
  value: number;
  price: number;
  y: number;
  text: string;
  color: 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>;
}

export interface FibonacciRetracementRenderData extends FibonacciRetracementGeometry {
  showHandles: boolean;
  settings: FibonacciRetracementSettings;
}

const HANDLE_HIT_TOLERANCE = 8;
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 requestUpdate: (() => void) | null = null;
  private isBound = false;

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

  private startAnchor: Anchor | null = null;
  private endAnchor: Anchor | null = null;

  private settings: FibonacciRetracementSettings = createDefaultSettings();

  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 readonly paneView: FibonacciRetracementPaneView;

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

    this.paneView = new FibonacciRetracementPaneView(this);

    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.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,
      startAnchor: this.startAnchor ? { ...this.startAnchor } : null,
      endAnchor: this.endAnchor ? { ...this.endAnchor } : null,
      settings: cloneFibonacciRetracementSettings(this.settings),
    };
  }

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

    if ('hidden' in nextState && typeof nextState.hidden === 'boolean') {
      this.hidden = nextState.hidden;
    }

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

    if ('mode' in nextState && nextState.mode) {
      this.mode = nextState.mode;
    }

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

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

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

    this.render();
  }

  public getSettings(): SettingsValues {
    return cloneFibonacciRetracementSettings(this.settings) as unknown as SettingsValues;
  }

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

  public updateSettings(settings: SettingsValues): void {
    this.settings = mergeFibonacciRetracementSettings(
      this.settings,
      settings as unknown as Partial<FibonacciRetracementSettings>,
    );

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

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

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

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

  public timeAxisViews() {
    return [];
  }

  public priceAxisViews() {
    return [];
  }

  public autoscaleInfo(_start: Logical, _end: Logical): AutoscaleInfo | null {
    return null;
  }

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      ...geometry,
      showHandles: this.isActive,
      settings: this.settings,
    };
  }

  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) {
      if (!this.containsPoint(point)) {
        return null;
      }

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

    const handleTarget = this.getHandleTarget(point);

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

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

    return {
      cursorStyle: 'grab',
      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 => {
    if (this.hidden || this.mode !== 'ready') {
      return;
    }

    const point = this.getMousePoint(event);

    if (!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) {
      if (!this.containsPoint(point)) {
        return;
      }

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

      this.isActive = true;
      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);
      this.render();
      return;
    }

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

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

    this.finishDragging();
  };

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

    if (!anchor) {
      return;
    }

    this.startAnchor = anchor;
    this.endAnchor = anchor;
    this.isActive = true;
    this.mode = 'drawing';

    this.render();
  }

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

    if (!anchor) {
      return;
    }

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

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

    if (!geometry || Math.max(geometry.width, geometry.height) < MIN_DISTANCE) {
      if (this.removeSelf) {
        this.removeSelf();
        return;
      }

      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 finishDragging(): void {
    this.mode = 'ready';

    this.clearInteractionState();
    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.startAnchor = null;
    this.endAnchor = null;
    this.clearInteractionState();
    this.render();
  }

  private getDragTarget(point: Point): Exclude<FibonacciRetracementHandle, null> | null {
    const handleTarget = this.getHandleTarget(point);

    if (handleTarget) {
      return handleTarget;
    }

    if (this.containsPoint(point)) {
      return 'body';
    }

    return null;
  }

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

    if (!snapshot?.startAnchor || !snapshot.endAnchor || !geometry || !this.dragStartPoint) {
      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.startAnchor.time, clampedOffsetX);
    const nextEndTime = this.shiftTime(snapshot.endAnchor.time, clampedOffsetX);

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

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

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

    this.endAnchor = {
      time: nextEndTime,
      price: snapshot.endAnchor.price + 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.startAnchor = anchor;
      return;
    }

    this.endAnchor = anchor;
  }

  private createAnchor(point: Point): Anchor | null {
    return getAnchorFromPoint(this.chart, this.series, point);
  }

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

    const startX = getXCoordinateFromTime(this.chart, this.startAnchor.time);
    const endX = getXCoordinateFromTime(this.chart, this.endAnchor.time);
    const startY = getYCoordinateFromPrice(this.series, this.startAnchor.price);
    const endY = getYCoordinateFromPrice(this.series, this.endAnchor.price);

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

    const levels = this.getLevels();

    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.startAnchor || !this.endAnchor) {
      return [];
    }

    return getVisibleFibonacciLevels(this.settings).reduce<FibonacciRetracementLevelRenderData[]>((result, level) => {
      const price = this.startAnchor!.price + (this.endAnchor!.price - this.startAnchor!.price) * 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)),
        text: this.getLevelText(level.value, price),
        color: level.color,
      });

      return result;
    }, []);
  }

  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(level: number, price: number): string {
    const parts: string[] = [];

    if (this.settings.showLevels) {
      parts.push(this.formatLevel(level));
    }

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

    return parts.join(' ');
  }

  private formatLevel(level: number): string {
    return String(Number(level.toFixed(3))).replace('.', ',');
  }

  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 xInRange = point.x >= geometry.left - LINE_HIT_TOLERANCE && point.x <= geometry.right + LINE_HIT_TOLERANCE;

    if (!xInRange) {
      return false;
    }

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

  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 = {
  handleSize: 8,
  handleBorderWidth: 1,
  textOffset: 4,
};

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.settings.showBackground) {
        data.areas.forEach((area) => {
          drawArea(context, {
            left,
            right,
            top: area.top * verticalPixelRatio,
            bottom: area.bottom * verticalPixelRatio,
            color: area.color,
            opacity: data.settings.backgroundOpacity,
          });
        });
      }

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

        drawHorizontalLine(context, {
          left,
          right,
          y,
          color: level.color,
          lineWidth: data.settings.lineWidth * pixelRatio,
          lineStyle: data.settings.lineStyle,
          pixelRatio,
        });

        if (data.settings.showText && level.text) {
          drawText(context, {
            text: level.text,
            x: data.settings.labelsPosition === 'left' ? left - UI.textOffset * horizontalPixelRatio : right + UI.textOffset * horizontalPixelRatio,
            y,
            color: level.color,
            fontSize: data.settings.fontSize * pixelRatio,
            align: data.settings.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: data.settings.controlLineColor,
        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;

  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;
    lineWidth: number;
    lineStyle: 'solid' | 'dashed';
    pixelRatio: number;
  },
): void {
  const { left, right, y, color, lineWidth, lineStyle, pixelRatio } = params;

  context.save();

  context.strokeStyle = color;
  context.lineWidth = lineWidth;

  if (lineStyle === 'dashed') {
    context.setLineDash([4 * pixelRatio, 4 * pixelRatio]);
  }

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

  context.restore();
}

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

  context.save();

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

  context.fillText(text, x, y);

  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 = pixelRatio;
  context.setLineDash([6 * pixelRatio, 6 * pixelRatio]);

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

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