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


import { AxisLine } from '@src/core/Drawings/axisLine';
import { Diapson } from '@src/core/Drawings/diapson';
import { FibonacciRetracement } from '@src/core/Drawings/fibonacciRetracement';
import { ParallelChannel } from '@src/core/Drawings/parallelChannel';
import { Ray } from '@src/core/Drawings/ray';
import { Rectangle } from '@src/core/Drawings/rectangle';
import { Ruler } from '@src/core/Drawings/ruler';
import { SliderPosition } from '@src/core/Drawings/sliderPosition';
import { Text } from '@src/core/Drawings/text';
import { Traectory } from '@src/core/Drawings/traectory';
import { TrendLine } from '@src/core/Drawings/trendLine';
import { VolumeProfile } from '@src/core/Drawings/volumeProfile';
import { t } from '@src/translations';
import { DrawingConfig } from '@src/types';

export enum DrawingsNames {
  'trendLine' = 'trendLine',
  'parallelChannel' = 'parallelChannel',
  'regressionTrend' = 'regressionTrend',
  'ray' = 'ray',
  'horizontalLine' = 'horizontalLine',
  'horizontalRay' = 'horizontalRay',
  'verticalLine' = 'verticalLine',
  'ruler' = 'ruler',

  'fibonacciRetracement' = 'fibonacciRetracement',

  'sliderLong' = 'sliderLong',
  'sliderShort' = 'sliderShort',
  'diapsonDates' = 'diapsonDates',
  'diapsonPrices' = 'diapsonPrices',
  'fixedRangeProfile' = 'fixedRangeProfile',
  'visibleRangeProfile' = 'visibleRangeProfile',

  'rectangle' = 'rectangle',
  'traectory' = 'traectory',

  'text' = 'text',
}

export const drawingLabelById = (): Record<DrawingsNames, string> => ({
  [DrawingsNames.trendLine]: t('Trend line'),
  [DrawingsNames.parallelChannel]: t('Parallel channel'),
  [DrawingsNames.regressionTrend]: t('Regression trend'),
  [DrawingsNames.ray]: t('Ray'),
  [DrawingsNames.horizontalLine]: t('Horizontal line'),
  [DrawingsNames.horizontalRay]: t('Horizontal ray'),
  [DrawingsNames.verticalLine]: t('Vertical line'),
  [DrawingsNames.fibonacciRetracement]: t('Fibonacci retracement'),
  [DrawingsNames.ruler]: t('Ruler'),
  [DrawingsNames.sliderLong]: t('Long position'),
  [DrawingsNames.sliderShort]: t('Short position'),
  [DrawingsNames.diapsonDates]: t('Dates range'),
  [DrawingsNames.diapsonPrices]: t('Prices range'),
  [DrawingsNames.fixedRangeProfile]: t('Fixed range volume profile'),
  [DrawingsNames.visibleRangeProfile]: t('Anchored volume profile'),
  [DrawingsNames.rectangle]: t('Rectangle'),
  [DrawingsNames.traectory]: t('Traectory'),
  [DrawingsNames.text]: t('Text'),
});

export const drawingsMap: Record<DrawingsNames, DrawingConfig> = {
  [DrawingsNames.trendLine]: {
    construct: ({ chart, series, container, eventManager, interaction, removeSelf, openSettings }) => {
      return new TrendLine(chart, series, {
        container,
        interaction,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.parallelChannel]: {
    construct: ({ chart, series, container, eventManager, interaction, openSettings }) => {
      return new ParallelChannel(chart, series, {
        container,
        interaction,
        formatObservable: eventManager.getChartOptionsModel(),
        openSettings,
      });
    },
  },
  [DrawingsNames.regressionTrend]: {
    construct: ({ chart, series, container, eventManager, interaction, removeSelf, openSettings }) => {
      return new TrendLine(chart, series, {
        container,
        interaction,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.ray]: {
    construct: ({ chart, series, container, eventManager, interaction, removeSelf, openSettings }) => {
      return new Ray(chart, series, {
        container,
        interaction,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.horizontalLine]: {
    construct: ({ chart, series, eventManager, container, interaction, removeSelf, openSettings }) => {
      return new AxisLine(chart, series, {
        direction: 'horizontal',
        container,
        interaction,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.horizontalRay]: {
    construct: ({ chart, series, container, eventManager, interaction, removeSelf, openSettings }) => {
      return new TrendLine(chart, series, {
        container,
        interaction,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.verticalLine]: {
    construct: ({ chart, series, eventManager, container, interaction, removeSelf, openSettings }) => {
      return new AxisLine(chart, series, {
        direction: 'vertical',
        container,
        interaction,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.sliderLong]: {
    construct: ({ chart, series, eventManager, container, interaction, removeSelf, openSettings }) => {
      return new SliderPosition(chart, series, {
        side: 'long',
        container,
        interaction,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.fibonacciRetracement]: {
    construct: ({ chart, series, eventManager, container, interaction, removeSelf, openSettings }) => {
      return new FibonacciRetracement(chart, series, {
        container,
        interaction,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.sliderShort]: {
    construct: ({ chart, series, eventManager, container, interaction, removeSelf, openSettings }) => {
      return new SliderPosition(chart, series, {
        side: 'short',
        container,
        interaction,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.diapsonDates]: {
    construct: ({ chart, series, container, eventManager, interaction, removeSelf, openSettings }) => {
      return new Diapson(chart, series, {
        rangeMode: 'date',
        container,
        interaction,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.diapsonPrices]: {
    construct: ({ chart, series, container, eventManager, interaction, removeSelf, openSettings }) => {
      return new Diapson(chart, series, {
        rangeMode: 'price',
        container,
        interaction,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.fixedRangeProfile]: {
    construct: ({ chart, series, container, eventManager, interaction, removeSelf, openSettings }) => {
      return new VolumeProfile(chart, series, {
        profileKind: 'fixedRange',
        container,
        interaction,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.visibleRangeProfile]: {
    singleInstance: true,
    construct: ({ chart, series, container, eventManager, interaction, removeSelf, openSettings }) => {
      return new VolumeProfile(chart, series, {
        profileKind: 'visibleRange',
        container,
        interaction,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.rectangle]: {
    construct: ({ chart, series, eventManager, container, interaction, removeSelf, openSettings }) => {
      return new Rectangle(chart, series, {
        container,
        interaction,
        formatObservable: eventManager.getChartOptionsModel(),
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.ruler]: {
    singleInstance: true,
    construct: ({ chart, series, eventManager, container, interaction, removeSelf }) => {
      return new Ruler(chart, series, {
        formatObservable: eventManager.getChartOptionsModel(),
        container,
        interaction,
        resetTriggers: [eventManager.getTimeframeObs(), eventManager.getInterval()],
        removeSelf,
      });
    },
  },
  [DrawingsNames.traectory]: {
    construct: ({ chart, series, eventManager, container, interaction, removeSelf, openSettings }) => {
      return new Traectory(chart, series, {
        formatObservable: eventManager.getChartOptionsModel(),
        container,
        interaction,
        removeSelf,
        openSettings,
      });
    },
  },
  [DrawingsNames.text]: {
    construct: ({ chart, series, eventManager, container, interaction, removeSelf, openSettings }) => {
      return new Text(chart, series, {
        formatObservable: eventManager.getChartOptionsModel(),
        container,
        interaction,
        removeSelf,
        openSettings,
      });
    },
  },
};


import {
  AutoscaleInfo,
  CrosshairMode,
  IChartApi,
  IPrimitivePaneView,
  ISeriesApi,
  ISeriesPrimitive,
  ISeriesPrimitiveAxisView,
  Logical,
  PrimitiveHoveredItem,
  SeriesAttachedParameter,
  SeriesOptionsMap,
  SeriesType,
  Time,
} from 'lightweight-charts';
import { Observable, Subject, Subscription } from 'rxjs';

import { getPointerPoint as getPointerPointFromEvent } from '@core/Drawings/helpers';
import { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';

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

export interface DrawingInteraction {
  selected$: Observable<boolean>;
  locked$: Observable<boolean>;

  isSelected(): boolean;
  isLocked(): boolean;

  select(): void;
  deselect(): void;
}

export interface ISeriesDrawing extends ISeriesPrimitive<Time> {
  show(): void;
  hide(): void;
  rebind(series: ISeriesApi<SeriesType>): void;
  destroy(): void;

  waitTillReady(): Promise<void>;
  isCreationPending(): boolean;
  shouldShowInObjectTree(): boolean;

  getState(): unknown;
  setState(state: unknown): void;

  getSettings(): SettingsValues;
  getSettingsTabs(): SettingsTab[];
  updateSettings(settings: SettingsValues): void;

  subscribeSettings(callback: (settings: SettingsValues) => void): Subscription;

  getRenderData(): unknown;
}

interface SeriesDrawingBaseParams {
  container: HTMLElement;
  chart: IChartApi;
  series: SeriesApi;
  interaction: DrawingInteraction;
}

export abstract class SeriesDrawingBase<TSettings extends SettingsValues = SettingsValues> implements ISeriesDrawing {
  protected hidden = false;
  protected chart: IChartApi;
  protected series: SeriesApi;
  protected subscriptions = new Subscription();
  protected abstract mode: unknown; // todo: хочется иметь единый mode
  protected abstract settings: TSettings;
  protected readonly container: HTMLElement;
  protected isBound = false;

  private readonly interaction: DrawingInteraction;
  private readonly settingsSubject = new Subject<SettingsValues>();
  private isInteractionBound = false;

  protected readyPromise: Promise<void> | null = null;
  protected resolveReady: (() => void) | null = null;
  protected requestUpdate: (() => void) | null = null;

  constructor({ chart, series, container, interaction }: SeriesDrawingBaseParams) {
    this.chart = chart;
    this.series = series;
    this.container = container;
    this.interaction = interaction;
  }

  public subscribeSettings(callback: (settings: SettingsValues) => void): Subscription {
    callback(this.getSettings());

    return this.settingsSubject.subscribe(callback);
  }

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

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

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

    this.showCrosshair();
    this.unbindEvents();
    this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);

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

    this.series.attachPrimitive(this as unknown as ISeriesPrimitive<Time>);
    this.render();
  }

  public destroy(): void {
    this.showCrosshair();
    this.unbindEvents();
    this.subscriptions.unsubscribe();
    this.settingsSubject.complete();
    this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);
    this.requestUpdate = null;
    this.resolveReady?.();
  }

  public waitTillReady(): Promise<void> {
    if (this.mode === 'ready') {
      return Promise.resolve();
    }

    if (!this.readyPromise) {
      this.readyPromise = new Promise((resolve) => {
        this.resolveReady = resolve;
      });
    }

    return this.readyPromise;
  }

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

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

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

    this.settingsSubject.next(this.getSettings());
    this.render();
  }

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

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

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

  public hitTest(x: number, y: number): PrimitiveHoveredItem | null {
    const hoveredItem = this.getHoveredItem(x, y);

    if (!hoveredItem || !this.isLocked()) {
      return hoveredItem;
    }

    return {
      ...hoveredItem,
      cursorStyle: 'pointer',
    };
  }

  public abstract getRenderData(): unknown; // todo: make proper type
  public abstract getState(): unknown;
  public abstract getSettingsTabs(): SettingsTab[];
  public abstract isCreationPending(): boolean;
  public abstract setState(state: unknown): void;
  public abstract updateAllViews(): void;
  public abstract paneViews(): readonly IPrimitivePaneView[];
  public abstract priceAxisPaneViews(): readonly IPrimitivePaneView[];
  public abstract priceAxisViews(): readonly ISeriesPrimitiveAxisView[];
  public abstract timeAxisPaneViews(): readonly IPrimitivePaneView[];
  public abstract timeAxisViews(): readonly ISeriesPrimitiveAxisView[];

  protected isSelected(): boolean {
    return this.interaction.isSelected();
  }

  protected isLocked(): boolean {
    return this.interaction.isLocked();
  }

  protected select(): void {
    this.interaction.select();
  }

  protected deselect(): void {
    this.interaction.deselect();
  }

  protected shouldShowHandles(): boolean {
    return !this.isLocked() && (this.isSelected() || this.isCreationPending());
  }

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

  protected hideCrosshair(): void {
    this.chart.applyOptions({
      crosshair: {
        mode: CrosshairMode.Hidden,
      },
    });
  }

  protected showCrosshair(): void {
    this.chart.applyOptions({
      crosshair: {
        mode: CrosshairMode.Normal,
      },
    });
  }

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

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

    this.isBound = true;

    this.container.addEventListener('dblclick', this.handleDoubleClick);
    this.container.addEventListener('pointerdown', this.handlePointerDownEvent);
    this.container.addEventListener('contextmenu', this.handleContextMenu);

    window.addEventListener('pointermove', this.handlePointerMove);
    window.addEventListener('pointerup', this.handlePointerUp);
    window.addEventListener('pointercancel', this.handlePointerUp);
  }

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

    this.isBound = false;

    this.container.removeEventListener('dblclick', this.handleDoubleClick);
    this.container.removeEventListener('pointerdown', this.handlePointerDownEvent);
    this.container.removeEventListener('contextmenu', this.handleContextMenu);

    window.removeEventListener('pointermove', this.handlePointerMove);
    window.removeEventListener('pointerup', this.handlePointerUp);
    window.removeEventListener('pointercancel', this.handlePointerUp);
  }

  // todo: хочется общую реализацию для каждой кнопки
  protected handleContextMenu(event: MouseEvent): void {}
  protected handleDoubleClick(event: MouseEvent): void {}
  protected handlePointerDown(event: PointerEvent): void {}
  protected handlePointerMove(event: PointerEvent): void {}
  protected handlePointerUp(event: PointerEvent): void {}

  protected abstract getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null;
  protected abstract getGeometry(): unknown; // todo: make proper type
  protected abstract getTimeAxisSegments(): AxisSegment[];
  protected abstract getPriceAxisSegments(): AxisSegment[];
  protected abstract getTimeAxisLabel(kind: string): AxisLabel | null;
  protected abstract getPriceAxisLabel(kind: string): AxisLabel | null;

  private bindInteraction(): void {
    if (this.isInteractionBound) {
      return;
    }

    this.isInteractionBound = true;

    this.subscriptions.add(
      this.interaction.selected$.subscribe(() => {
        this.render();
      }),
    );

    this.subscriptions.add(
      this.interaction.locked$.subscribe((isLocked) => {
        if (isLocked) {
          this.showCrosshair();
        }

        this.render();
      }),
    );
  }

  private handlePointerDownEvent = (event: PointerEvent): void => {
    if (!this.isLocked() || this.isCreationPending() || event.button !== 0) {
      this.handlePointerDown(event);

      return;
    }

    const point = this.getEventPoint(event);
    const isDrawingHit = this.getHoveredItem(point.x, point.y) !== null;

    if (isDrawingHit) {
      this.select();

      return;
    }

    if (this.isSelected()) {
      this.deselect();
    }
  };
}


import { DataSource } from '@core/DataSource';
import { DrawingsManagerSnapshot } from '@core/DrawingsManager';
import { ChartSeriesType, DateFormat, IndicatorsIds, Intervals, Timeframes } from '@lib';
import { Direction } from '@src/types/chart';
import { IndicatorConfig } from '@src/types/indicator';
import { SymbolInfo, SymbolInfoInput } from '@src/types/symbol';
import { TimeFormat } from '@src/types/timeScale';

import type { PriceScaleMode } from 'lightweight-charts';

export type PriceScaleSide = Direction.Left | Direction.Right;

export interface ISerializable<T extends object> {
  getSnapshot: () => T;
}

export interface InitialSnapshot extends SymbolInfoInput {
  timeframe: Timeframes; // todo: move to snap
  chartSeriesType: ChartSeriesType; // todo: move to snap
}

export interface MoexChartSnapshotInput {
  // settings: ChartSettingsSnapshot;
  charts: ChartSnapshotInput[];
}

export interface MoexChartSnapshot {
  // settings: ChartSettingsSnapshot;
  charts: ChartSnapshot[];
}

interface ChartSnapshotBase {
  timeframe: Timeframes;
  chartSeriesType: ChartSeriesType;
  timeFormat?: TimeFormat;
  dateFormat?: DateFormat;
  interval?: Intervals | null;
  panes: PaneSnapshot[];
}

export interface ChartSnapshotInput extends ChartSnapshotBase, SymbolInfoInput {}
export interface ChartSnapshot extends ChartSnapshotBase, SymbolInfo {}

export interface PriceScaleSnapshot {
  side: PriceScaleSide;
  mode: PriceScaleMode;
}

export interface PaneSnapshot {
  isMain: boolean;
  id: number;
  indicators: IndicatorSnapshot[];
  drawings: DrawingsManagerSnapshot;
  priceScales?: PriceScaleSnapshot[];
}

export interface IndicatorSnapshot extends Partial<DOMObjectSnapshot> {
  dataSource?: DataSource;
  indicatorType: IndicatorsIds | undefined; // if indicatorType is undefined, then its compareIndicator
  config?: IndicatorConfig;
}

// todo: move DrawingsManagerSnapshot here

export interface DOMObjectSnapshot {
  id: string;
  name: string;
  zIndex: number;
  hidden: boolean;
  paneId: number;
}


import { IChartApi, ISeriesApi, SeriesType } from 'lightweight-charts';

import { DrawingsNames } from '@src/constants';
import { EventManager } from '@src/core';

import type { DrawingInteraction, ISeriesDrawing } from '@src/core/Drawings/common';

export type ActiveDrawingTool = DrawingsNames | 'crosshair';

interface DrawingParams {
  chart: IChartApi;
  series: ISeriesApi<SeriesType>;
  eventManager: EventManager;
  container: HTMLElement;
  interaction: DrawingInteraction;
  removeSelf: () => void;
  openSettings: () => void;
}

export interface DrawingConfig {
  singleInstance?: boolean;
  construct: (params: DrawingParams) => ISeriesDrawing;
}


import { IChartApi, ISeriesApi, SeriesType } from 'lightweight-charts';
import { BehaviorSubject, distinctUntilChanged, map, Observable, Subscription } from 'rxjs';

import { EventManager } from '@core';
import { DOMModel } from '@core/DOMModel';
import { Drawing } from '@core/Drawings';
import { Hotkeys, Keys } from '@core/Hotkeys';

import { EntitySettingsModal } from '@src/components/EntitySettingsModal';
import { drawingLabelById, drawingsMap, DrawingsNames } from '@src/constants';
import { ModalRenderer } from '@src/core/ModalRenderer';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ActiveDrawingTool } from '@src/types';

import type { DrawingInteraction } from '@src/core/Drawings/common';

interface DrawingsManagerParams {
  eventManager: EventManager;
  mainSeries$: Observable<SeriesStrategies | null>;
  lwcChart: IChartApi;
  DOM: DOMModel;
  container: HTMLElement;
  modalRenderer: ModalRenderer;
  paneId: number;
  hotkeys: Hotkeys;
}

export interface DrawingSnapshotItem {
  id: string;
  drawingName: DrawingsNames;
  state: unknown;
  isLocked?: boolean;
}

interface CreateDrawingOptions {
  id?: string;
  state?: unknown;
  isLocked?: boolean;
  shouldUpdateDrawingsList?: boolean;
}

export type DrawingsManagerSnapshot = DrawingSnapshotItem[];

export class DrawingsManager {
  private eventManager: EventManager;
  private lwcChart: IChartApi;
  private DOM: DOMModel;
  private container: HTMLElement;
  private modalRenderer: ModalRenderer;

  private mainSeries: SeriesStrategies | null = null;
  private subscriptions = new Subscription();
  private drawings$ = new BehaviorSubject<Drawing[]>([]);
  private selectedDrawing$ = new BehaviorSubject<Drawing | null>(null);
  private activeTool$ = new BehaviorSubject<ActiveDrawingTool>('crosshair');
  private endlessMode$ = new BehaviorSubject(false);
  private recreateScheduled = false;
  private pendingSnapshot: DrawingsManagerSnapshot | null = null;
  private paneId: number;

  private hotkeys: Hotkeys;
  private copyPasteBuffer: DrawingSnapshotItem | null = null;
  private escapeUnregisterHash: string | null = null;

  constructor({
    eventManager,
    mainSeries$,
    lwcChart,
    DOM,
    container,
    modalRenderer,
    paneId,
    hotkeys,
  }: DrawingsManagerParams) {
    this.DOM = DOM;
    this.eventManager = eventManager;
    this.paneId = paneId;
    this.lwcChart = lwcChart;
    this.container = container;
    this.modalRenderer = modalRenderer;
    this.hotkeys = hotkeys;

    this.subscriptions.add(
      mainSeries$.subscribe((series) => {
        if (!series) {
          return;
        }

        this.mainSeries = series;
        this.drawings$.value.forEach((drawing) => drawing.rebind(series));

        if (this.pendingSnapshot) {
          const snapshot = this.pendingSnapshot;
          this.pendingSnapshot = null;
          this.setSnapshot(snapshot);
        }
      }),
    );

    window.addEventListener('pointerup', this.handlePointerUp);
    this.container.addEventListener('click', this.handleClick);
    this.container.addEventListener('pointerdown', this.handlePointerDown);
    // todo: implement ctrl+v
    // hotkeys.register({
    //   keys: [Keys.control, Keys.v],
    //   callback: () => {
    //     const bufferWithAppliedPosition = {
    //       ...this.copyPasteBuffer,
    //       state: {
    //         ...this.copyPasteBuffer?.state,
    //         startAnchor: {
    //           price: 73.36210252637723,
    //           time: 1783679170
    //         }
    //       }
    //     }
    //
    //     this.setSnapshot([
    //       ...this.getSnapshot(),
    //       bufferWithAppliedPosition
    //     ])
    //     // hotkeys.unregister({ // todo: unregister all else ctrl+c's
    //     //   keys: [Keys.control, Keys.c]
    //     // })
    //   }
    // })
  }

  private handlePointerDown = (): void => {
    this.DOM.refreshEntities();
  };

  private handlePointerUp = (): void => {
    this.DOM.refreshEntities();
    this.updateActiveTool();
  };

  private handleClick = (): void => {
    this.DOM.refreshEntities();
    this.updateActiveTool();
  };

  private updateActiveTool = (): void => {
    const hasPendingDrawing = this.drawings$.value.some((drawing) => drawing.isCreationPending());

    if (hasPendingDrawing) {
      return;
    }

    const activeTool = this.activeTool$.value;
    const isSingleInstanceTool = activeTool !== 'crosshair' && drawingsMap[activeTool]?.singleInstance;

    if (activeTool !== 'crosshair' && this.endlessMode$.value && !isSingleInstanceTool) {
      if (this.recreateScheduled) {
        return;
      }

      this.recreateScheduled = true;

      queueMicrotask(() => {
        this.recreateScheduled = false;

        const currentTool = this.activeTool$.value;
        const hasPendingAfterTick = this.drawings$.value.some((drawing) => drawing.isCreationPending());

        if (currentTool === 'crosshair') {
          return;
        }

        if (!this.endlessMode$.value) {
          return;
        }

        if (drawingsMap[currentTool]?.singleInstance) {
          return;
        }

        if (hasPendingAfterTick) {
          return;
        }

        this.createDrawing(currentTool);
      });

      return;
    }

    this.activeTool$.next('crosshair');
  };

  private removeDrawing = (id: string): void => {
    const drawing = this.drawings$.value.find((item) => item.id === id);

    if (!drawing) {
      return;
    }

    this.removeDrawings([drawing]);
  };

  private removeDrawingsByName(name: DrawingsNames, shouldUpdateTool = true): void {
    const drawingsToRemove = this.drawings$.value.filter((drawing) => drawing.getDrawingName() === name);

    this.removeDrawings(drawingsToRemove, shouldUpdateTool);
  }

  private removePendingDrawings(shouldUpdateTool = true): void {
    const drawingsToRemove = this.drawings$.value.filter((drawing) => drawing.isCreationPending());

    this.removeDrawings(drawingsToRemove, shouldUpdateTool);
  }

  private removeDrawings(drawingsToRemove: Drawing[], shouldUpdateTool = true): void {
    if (!drawingsToRemove.length) {
      return;
    }

    const selectedDrawing = this.selectedDrawing$.value;

    if (selectedDrawing && drawingsToRemove.includes(selectedDrawing)) {
      this.selectedDrawing$.next(null);
    }

    drawingsToRemove.forEach((drawing) => {
      drawing.destroy();
      this.DOM.removeEntity(drawing);
    });

    this.drawings$.next(this.drawings$.value.filter((drawing) => !drawingsToRemove.includes(drawing)));

    if (shouldUpdateTool) {
      this.updateActiveTool();
    }

    this.DOM.refreshEntities();
  }

  public addDrawingForce = (name: DrawingsNames): Promise<void> => {
    this.removePendingDrawings(false);

    if (drawingsMap[name].singleInstance) {
      this.removeDrawingsByName(name, false);
    }

    this.activeTool$.next(name);
    const drawing = this.createDrawing(name);
    this.DOM.refreshEntities();

    return drawing.waitForCreation();
  };

  private createDrawing(name: DrawingsNames, options: CreateDrawingOptions = {}): Drawing {
    if (!this.mainSeries) {
      throw new Error('[Drawings] main series is not defined');
    }

    const { id, state, isLocked = false, shouldUpdateDrawingsList = true } = options;
    const shouldSelectAfterCreation = state === undefined;

    if (shouldSelectAfterCreation && this.selectedDrawing$.value) {
      this.selectedDrawing$.next(null);
    }

    const config = drawingsMap[name];
    const drawingId = id ?? crypto.randomUUID();

    let createdDrawing: Drawing | null = null;

    const selected$ = this.selectedDrawing$.pipe(
      map((drawing) => drawing?.id === drawingId),
      distinctUntilChanged(),
    );

    const construct = (chart: IChartApi, series: ISeriesApi<SeriesType>, interaction: DrawingInteraction) => {
      const paneElement = series.getPane().getHTMLElement();

      if (!paneElement) {
        throw new Error('[Drawing Manager]: cannot place drawing, there is no pane');
      }

      const cells = paneElement.querySelectorAll<HTMLTableCellElement>(':scope > td');
      const canvasElement = cells.item(1);

      return config.construct({
        chart,
        series,
        eventManager: this.eventManager,
        container: canvasElement,
        interaction,
        removeSelf: () => this.removeDrawing(drawingId),
        openSettings: () => {
          if (createdDrawing) {
            this.openSettings(createdDrawing);
          }
        },
      });
    };

    const drawingFactory = (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) =>
      new Drawing({
        lwcChart: this.lwcChart,
        mainSeries: this.mainSeries as SeriesStrategies,
        id: drawingId,
        drawingName: name,
        name: drawingLabelById()[name],
        onDelete: this.removeDrawing,
        zIndex,
        moveDown,
        moveUp,
        construct,
        selected$,
        isSelected: () => this.selectedDrawing$.value?.id === drawingId,
        select: () => {
          if (!createdDrawing || createdDrawing.isCreationPending() || this.selectedDrawing$.value === createdDrawing) {
            return;
          }

          this.selectedDrawing$.next(createdDrawing);
        },
        deselect: () => {
          if (!createdDrawing || this.selectedDrawing$.value !== createdDrawing) {
            return;
          }

          this.selectedDrawing$.next(null);
        },
        isLocked,
        paneId: this.paneId,
        hotkeys: this.hotkeys,
        setCopyPasteBuffer: (copyPasteBuffer) => {
          this.copyPasteBuffer = copyPasteBuffer;
        },
        resetActiveTool: () => {
          this.activeTool$.next('crosshair');
        },
      });

    const entity = this.DOM.setEntity<Drawing>(drawingFactory);
    createdDrawing = entity;

    if (state !== undefined) {
      entity.setState(state);
    }

    if (shouldUpdateDrawingsList) {
      this.drawings$.next([...this.drawings$.value, entity]);
    }

    if (shouldSelectAfterCreation) {
      entity.waitForCreation().then(() => {
        if (!this.drawings$.value.includes(entity)) {
          return;
        }

        this.selectedDrawing$.next(entity);
        this.updateActiveTool();
        this.DOM.refreshEntities();
      });
    }

    return entity;
  }

  public getSnapshot(): DrawingsManagerSnapshot {
    return this.drawings$.value
      .filter((drawing) => !drawing.isCreationPending())
      .map((drawing) => ({
        id: drawing.id,
        drawingName: drawing.getDrawingName(),
        state: drawing.getState(),
        isLocked: drawing.isLocked(),
      }));
  }

  public setSnapshot(snapshot: DrawingsManagerSnapshot): void {
    if (!Array.isArray(snapshot)) {
      return;
    }

    if (!this.mainSeries) {
      this.pendingSnapshot = snapshot;
      return;
    }

    this.removeDrawings(this.drawings$.value, false);

    const restoredDrawings = snapshot.reduce<Drawing[]>((drawings, item) => {
      if (!drawingsMap[item.drawingName]) {
        return drawings;
      }

      drawings.push(
        this.createDrawing(item.drawingName, {
          id: item.id,
          state: item.state,
          isLocked: item.isLocked,
          shouldUpdateDrawingsList: false,
        }),
      );

      return drawings;
    }, []);

    this.drawings$.next(restoredDrawings);
    this.activeTool$.next('crosshair');
    this.DOM.refreshEntities();
  }

  public setEndlessDrawingMode = (value: boolean): void => {
    if (value) {
      this.escapeUnregisterHash = this.hotkeys.register({
        keys: [Keys.escape],
        callback: () => {
          this.setEndlessDrawingMode(false);
        },
      });
    } else {
      this.hotkeys.unregister({
        keys: [Keys.escape],
        hash: this.escapeUnregisterHash,
      });

      this.escapeUnregisterHash = null;
    }

    this.endlessMode$.next(value);
  };

  public isEndlessDrawingsMode(): Observable<boolean> {
    return this.endlessMode$.asObservable();
  }

  public getActiveTool(): Observable<ActiveDrawingTool> {
    return this.activeTool$.asObservable();
  }

  public activateCrosshair(): void {
    this.removePendingDrawings(false);
    this.activeTool$.next('crosshair');
    this.DOM.refreshEntities();
  }

  public entities(): Observable<Drawing[]> {
    return this.drawings$.asObservable();
  }

  public selectedDrawing(): Observable<Drawing | null> {
    return this.selectedDrawing$.asObservable();
  }

  public openSelectedDrawingSettings(): void {
    const drawing = this.selectedDrawing$.value;

    if (!drawing) {
      return;
    }

    this.openSettings(drawing);
  }

  public deleteSelectedDrawing(): void {
    const drawing = this.selectedDrawing$.value;

    if (!drawing) {
      return;
    }

    this.removeDrawing(drawing.id);
  }

  public toggleSelectedDrawingLock(): void {
    this.selectedDrawing$.value?.toggleLock();
  }

  private openSettings = (drawing: Drawing): void => {
    const tabs = drawing.getSettingsTabs();

    if (!tabs.length || tabs.every((tab) => tab.fields.length === 0)) {
      return;
    }

    let settings = drawing.getSettings();

    this.modalRenderer.renderComponent(
      <EntitySettingsModal
        tabs={tabs}
        values={settings}
        onChange={(nextSettings) => {
          settings = nextSettings;
        }}
        initialTabKey={tabs[0]?.key}
      />,
      {
        size: 'sm',
        title: drawing.name,
        onSave: () => drawing.updateSettings(settings),
      },
    );
  };

  public getDrawings(): Drawing[] {
    return this.drawings$.value;
  }

  public hideAll(): void {
    if (this.selectedDrawing$.value) {
      this.selectedDrawing$.next(null);
    }

    this.drawings$.value.forEach((drawing) => drawing.hide());
    this.DOM.refreshEntities();
  }

  public destroy(): void {
    this.hotkeys.unregister({
      keys: [Keys.escape],
      hash: this.escapeUnregisterHash,
    });

    window.removeEventListener('pointerup', this.handlePointerUp);
    this.container.removeEventListener('click', this.handleClick);
    this.container.removeEventListener('pointerdown', this.handlePointerDown);

    this.drawings$.value.forEach((drawing) => drawing.destroy());

    this.subscriptions.unsubscribe();
    this.drawings$.complete();
    this.selectedDrawing$.complete();
    this.activeTool$.complete();
    this.endlessMode$.complete();
  }
}


import { IChartApi, ISeriesApi, SeriesType } from 'lightweight-charts';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';

import { DOMObject, DOMObjectParams } from '@core/DOMObject';
import { DrawingSnapshotItem } from '@core/DrawingsManager';
import { Hotkeys, Keys } from '@core/Hotkeys';

import { DrawingsNames } from '@src/constants';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { SettingsTab, SettingsValues, ToolbarSettingField } from '@src/types/settings';

import type { DrawingInteraction, ISeriesDrawing } from '@core/Drawings/common';

type IDrawing = DOMObject;

interface DrawingParams extends DOMObjectParams {
  drawingName: DrawingsNames;
  lwcChart: IChartApi;
  mainSeries: SeriesStrategies;
  onDelete: (id: string) => void;
  construct: (chart: IChartApi, series: ISeriesApi<SeriesType>, interaction: DrawingInteraction) => ISeriesDrawing;
  selected$: Observable<boolean>;
  isSelected: () => boolean;
  select: () => void;
  deselect: () => void;
  isLocked?: boolean;
  hotkeys: Hotkeys;
  setCopyPasteBuffer: (copiedObject: DrawingSnapshotItem) => void;
  resetActiveTool: () => void;
}

export class Drawing extends DOMObject implements IDrawing {
  private lwcDrawing: ISeriesDrawing;
  private mainSeries: SeriesStrategies;
  private drawingName: DrawingsNames;
  private hotkeys: Hotkeys;
  private lockedSubject: BehaviorSubject<boolean>;
  private subscriptions = new Subscription();

  private escapeUnregisterHash: string | null = null;
  private deleteUnregisterHash: string | null = null;
  private copyUnregisterHash: string | null = null;

  constructor({
    lwcChart,
    name,
    mainSeries,
    drawingName,
    id,
    onDelete,
    zIndex,
    moveUp,
    moveDown,
    construct,
    selected$,
    isSelected,
    select,
    deselect,
    isLocked = false,
    paneId,
    hotkeys,
    setCopyPasteBuffer,
    resetActiveTool,
  }: DrawingParams) {
    super({ id, name, zIndex, onDelete, moveUp, moveDown, paneId });

    this.hotkeys = hotkeys;
    this.mainSeries = mainSeries;
    this.drawingName = drawingName;
    this.lockedSubject = new BehaviorSubject(isLocked);

    const interaction: DrawingInteraction = {
      selected$,
      locked$: this.lockedSubject.asObservable(),
      isSelected,
      isLocked: () => this.lockedSubject.value,
      select,
      deselect,
    };

    this.lwcDrawing = construct(lwcChart, mainSeries, interaction);
    this.onDelete = onDelete;

    this.escapeUnregisterHash = hotkeys.register({
      keys: [Keys.escape],
      callback: () => {
        this.delete();
        resetActiveTool();
      },
    });

    this.subscriptions.add(
      selected$.subscribe((isSelectedDrawing) => {
        if (isSelectedDrawing) {
          this.deleteUnregisterHash = hotkeys.register({
            keys: [Keys.delete],
            callback: () => {
              this.delete();
            },
          });

          this.copyUnregisterHash = hotkeys.register({
            keys: [Keys.control, Keys.c],
            callback: () => {
              if (!this.isCreationPending()) {
                setCopyPasteBuffer({
                  id: this.id,
                  drawingName: this.getDrawingName(),
                  state: this.getState(),
                  isLocked: this.isLocked(),
                });
              }
            },
          });

          return;
        }

        hotkeys.unregister({
          keys: [Keys.delete],
          hash: this.deleteUnregisterHash,
        });

        hotkeys.unregister({
          keys: [Keys.control, Keys.c],
          hash: this.copyUnregisterHash,
        });

        this.deleteUnregisterHash = null;
        this.copyUnregisterHash = null;
      }),
    );

    this.afterCreation(() => {
      hotkeys.unregister({
        keys: [Keys.escape],
        hash: this.escapeUnregisterHash,
      });

      this.escapeUnregisterHash = null;
    });
  }

  public delete(): void {
    this.destroy();
    super.delete();
  }

  public getDrawingName(): DrawingsNames {
    return this.drawingName;
  }

  public getLwcDrawing(): ISeriesDrawing {
    return this.lwcDrawing;
  }

  public show(): void {
    this.lwcDrawing.show();
    super.show();
  }

  public hide(): void {
    this.lwcDrawing.hide();
    super.hide();
  }

  public rebind = (nextMainSeries: SeriesStrategies): void => {
    this.lwcDrawing.rebind(nextMainSeries);
    this.mainSeries = nextMainSeries;
  };

  public isCreationPending(): boolean {
    return this.lwcDrawing.isCreationPending();
  }

  public subscribeIsLocked(callback: (isLocked: boolean) => void): Subscription {
    return this.lockedSubject.subscribe(callback);
  }

  public subscribeSettings(callback: (settings: SettingsValues) => void): Subscription {
    return this.lwcDrawing.subscribeSettings(callback);
  }

  public isLocked(): boolean {
    return this.lockedSubject.value;
  }

  public toggleLock(): void {
    this.lockedSubject.next(!this.lockedSubject.value);
  }

  public async waitForCreation(): Promise<void> {
    return this.lwcDrawing.waitTillReady();
  }

  public shouldShowInObjectTree(): boolean {
    return this.lwcDrawing.shouldShowInObjectTree();
  }

  public getState(): unknown {
    return this.lwcDrawing.getState();
  }

  public setState(state: unknown): void {
    this.lwcDrawing.setState(state);
  }

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

  public updateSettings(settings: SettingsValues): void {
    this.lwcDrawing.updateSettings(settings);
  }

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

  public getToolbarSettings(): ToolbarSettingField[] {
    return this.getSettingsTabs()
      .flatMap((tab) => tab.fields)
      .filter((field): field is ToolbarSettingField => field.toolbar !== undefined);
  }

  public hasSettings(): boolean {
    return this.getSettingsTabs().some((tab) => tab.fields.length > 0);
  }

  public destroy(): void {
    this.subscriptions.unsubscribe();

    this.hotkeys.unregister({
      keys: [Keys.delete],
      hash: this.deleteUnregisterHash,
    });

    this.hotkeys.unregister({
      keys: [Keys.escape],
      hash: this.escapeUnregisterHash,
    });

    this.hotkeys.unregister({
      keys: [Keys.control, Keys.c],
      hash: this.copyUnregisterHash,
    });

    this.lockedSubject.complete();
    this.mainSeries.detachPrimitive(this.lwcDrawing);
    this.lwcDrawing.destroy();
  }

  private async afterCreation(callback: () => void): Promise<void> {
    await this.lwcDrawing.waitTillReady();
    callback();
  }
}


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

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

import { TrendLine } from './trendLine';

const UI = {
  lineWidth: 2,
  handleRadius: 5,
  handleBorderWidth: 2,
  textLineHeightMultiplier: 1.2,
  textOffset: 4,
};

export class TrendLinePaneRenderer implements IPrimitivePaneRenderer {
  private readonly trendLine: TrendLine;

  constructor(trendLine: TrendLine) {
    this.trendLine = trendLine;
  }

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

    if (!data) {
      return;
    }

    const { colors } = getThemeStore();

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

      const startX = data.startPoint.x * horizontalPixelRatio;
      const startY = data.startPoint.y * verticalPixelRatio;
      const endX = data.endPoint.x * horizontalPixelRatio;
      const endY = data.endPoint.y * verticalPixelRatio;

      context.save();

      context.lineWidth = UI.lineWidth * pixelRatio;
      context.strokeStyle = data.lineColor;
      context.beginPath();
      context.moveTo(startX, startY);
      context.lineTo(endX, endY);
      context.stroke();

      if (data.text.trim()) {
        drawTextAlongLine(context, {
          startX,
          startY,
          endX,
          endY,
          text: data.text,
          fontSize: data.fontSize,
          isBold: data.isBold,
          isItalic: data.isItalic,
          textColor: data.textColor,
          horizontalPixelRatio,
          verticalPixelRatio,
        });
      }

      if (data.showHandles) {
        context.fillStyle = colors.chartBackground;
        context.strokeStyle = colors.chartLineColor;
        context.lineWidth = UI.handleBorderWidth * pixelRatio;

        drawHandle(context, startX, startY, UI.handleRadius * pixelRatio);
        drawHandle(context, endX, endY, UI.handleRadius * pixelRatio);
      }

      context.restore();
    });
  }
}

function drawHandle(context: CanvasRenderingContext2D, x: number, y: number, radius: number): void {
  context.beginPath();
  context.arc(x, y, radius, 0, Math.PI * 2);
  context.fill();
  context.stroke();
}

function drawTextAlongLine(
  context: CanvasRenderingContext2D,
  params: {
    startX: number;
    startY: number;
    endX: number;
    endY: number;
    text: string;
    fontSize: number;
    isBold: boolean;
    isItalic: boolean;
    textColor: string;
    horizontalPixelRatio: number;
    verticalPixelRatio: number;
  },
): void {
  const { startX, startY, endX, endY, text, fontSize, isBold, isItalic, textColor, verticalPixelRatio } = params;

  const lines = text.split('\n');
  const safeFontSize = Math.max(1, fontSize);
  const fontSizePx = safeFontSize * verticalPixelRatio;
  const lineHeight = safeFontSize * UI.textLineHeightMultiplier * verticalPixelRatio;

  const dx = endX - startX;
  const dy = endY - startY;

  let angle = Math.atan2(dy, dx);

  if (angle > Math.PI / 2 || angle < -Math.PI / 2) {
    angle += Math.PI;
  }

  const centerX = (startX + endX) / 2;
  const centerY = (startY + endY) / 2;

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

  context.save();
  context.translate(centerX, centerY);
  context.rotate(angle);

  context.font = `${fontStyle}${fontWeight}${fontSizePx}px Inter, sans-serif`;
  context.fillStyle = textColor;
  context.textAlign = 'center';
  context.textBaseline = 'middle';

  const blockHeight = lines.length * lineHeight;
  const textOffset = UI.textOffset * verticalPixelRatio;
  const textCenterY = -(blockHeight / 2 + textOffset);
  const startLineY = textCenterY - blockHeight / 2 + lineHeight / 2;

  lines.forEach((line, index) => {
    context.fillText(line, 0, startLineY + index * lineHeight);
  });

  context.restore();
}


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

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

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

import { TrendLinePaneView } from './paneView';

import {
  createDefaultSettings,
  getTrendLineSettingsTabs,
  TrendLineSettings,
  TrendLineStyle,
  TrendLineTextStyle,
} from './settings';

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

type TrendLineMode = 'idle' | 'drawing' | 'ready' | 'dragging-start' | 'dragging-end' | 'dragging-body';
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'start' | 'end';

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

interface TrendLineState {
  hidden: boolean;
  mode: TrendLineMode;
  startAnchor: Anchor | null;
  endAnchor: Anchor | null;
  settings: TrendLineSettings;
}

interface TrendLineGeometry {
  startPoint: Point;
  endPoint: Point;
  left: number;
  right: number;
  top: number;
  bottom: number;
}

export interface TrendLineRenderData extends TrendLineGeometry, TrendLineStyle, TrendLineTextStyle {
  showHandles: boolean;
}

const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;

export class TrendLine extends SeriesDrawingBase<TrendLineSettings> implements ISeriesDrawing {
  private removeSelf?: () => void;
  private openSettings?: () => void;

  protected settings: TrendLineSettings = createDefaultSettings();
  protected mode: TrendLineMode = 'idle';

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

  private dragPointerId: number | null = null;
  private dragStartPoint: Point | null = null;
  private dragStateSnapshot: TrendLineState | null = null;

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

  private readonly paneView: TrendLinePaneView;
  private readonly timeAxisPaneView: CustomTimeAxisPaneView;
  private readonly priceAxisPaneView: CustomPriceAxisPaneView;
  private readonly startTimeAxisView: CustomTimeAxisView;
  private readonly endTimeAxisView: CustomTimeAxisView;
  private readonly startPriceAxisView: CustomPriceAxisView;
  private readonly endPriceAxisView: CustomPriceAxisView;

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

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

    this.paneView = new TrendLinePaneView(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.startPriceAxisView = new CustomPriceAxisView({
      getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
      labelKind: 'start',
    });

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

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

    this.series.attachPrimitive(this);
  }

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

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

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

    const nextState = state as Partial<TrendLineState>;

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

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

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

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

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

    this.render();
  }

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

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

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

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

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

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

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      ...geometry,
      showHandles: this.shouldShowHandles(),
      ...this.settings,
    };
  }

  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.getPointTarget(point)) {
      return {
        cursorStyle: 'move',
        externalId: 'trend-line',
        zOrder: 'top',
      };
    }

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

    return {
      cursorStyle: 'grab',
      externalId: 'trend-line',
      zOrder: 'top',
    };
  }

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

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

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

    const coordinate = this.getTimeCoordinate(kind);
    const text = this.getTimeText(kind);

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

    const { colors } = getThemeStore();

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

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

    const coordinate = this.getPriceCoordinate(kind);
    const text = this.getPriceText(kind);

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

    const { colors } = getThemeStore();

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

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

    const rect = this.container.getBoundingClientRect();
    const point = {
      x: event.clientX - rect.left,
      y: event.clientY - rect.top,
    };

    if (!this.getPointTarget(point) && !this.isPointNearLine(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;
    }

    const pointTarget = this.getPointTarget(point);
    const isNearLine = this.isPointNearLine(point);
    const isDrawingHit = pointTarget !== null || isNearLine;

    if (!this.isSelected()) {
      if (!isDrawingHit) {
        return;
      }

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

      this.select();
      return;
    }

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

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

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

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

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

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

    this.deselect();
  };

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

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

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

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

      this.movePoint(point);
      this.render();
      return;
    }

    if (this.mode === 'dragging-body') {
      event.preventDefault();
      event.stopPropagation();

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

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

    if (this.mode === 'dragging-start' || this.mode === 'dragging-end' || this.mode === 'dragging-body') {
      this.finishDragging();
    }
  };

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

    if (!anchor) {
      return;
    }

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

    this.render();
  }

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

    if (!anchor) {
      return;
    }

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

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

    if (!geometry) {
      return;
    }

    const lineSize = Math.hypot(
      geometry.endPoint.x - geometry.startPoint.x,
      geometry.endPoint.y - geometry.startPoint.y,
    );

    if (lineSize < MIN_LINE_SIZE) {
      this.removeSelf?.();
      return;
    }

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

    this.render();
  }

  private startDragging(mode: TrendLineMode, point: Point, pointerId: number): void {
    this.mode = mode;

    this.dragPointerId = pointerId;
    this.dragStartPoint = point;
    this.dragStateSnapshot = this.getState();

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

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

    this.dragPointerId = null;
    this.dragStartPoint = null;
    this.dragStateSnapshot = null;

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

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

    if (!anchor) {
      return;
    }

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

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

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

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

    const offsetX = point.x - this.dragStartPoint.x;
    const priceOffset = getPriceDeltaFromCoordinates(this.series, this.dragStartPoint.y, point.y);

    const nextStartTime = shiftTimeByPixels(this.chart, snapshot.startAnchor.time, offsetX, this.series);
    const nextEndTime = shiftTimeByPixels(this.chart, snapshot.endAnchor.time, offsetX, this.series);

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

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

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

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

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

    const startX = getXCoordinateFromTime(this.chart, this.startAnchor.time, this.series);
    const endX = getXCoordinateFromTime(this.chart, this.endAnchor.time, this.series);
    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)),
    };

    return {
      startPoint,
      endPoint,
      left: Math.min(startPoint.x, endPoint.x),
      right: Math.max(startPoint.x, endPoint.x),
      top: Math.min(startPoint.y, endPoint.y),
      bottom: Math.max(startPoint.y, endPoint.y),
    };
  }

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

    if (!geometry) {
      return null;
    }

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

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

    return null;
  }

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

    if (!geometry) {
      return false;
    }

    return this.getDistanceToSegment(point, geometry.startPoint, geometry.endPoint) <= LINE_HIT_TOLERANCE;
  }

  private getDistanceToSegment(point: Point, startPoint: Point, endPoint: Point): number {
    const dx = endPoint.x - startPoint.x;
    const dy = endPoint.y - startPoint.y;

    if (dx === 0 && dy === 0) {
      return Math.hypot(point.x - startPoint.x, point.y - startPoint.y);
    }

    const t = Math.max(
      0,
      Math.min(1, ((point.x - startPoint.x) * dx + (point.y - startPoint.y) * dy) / (dx * dx + dy * dy)),
    );

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

    return Math.hypot(point.x - projectionX, point.y - projectionY);
  }

  private getTimeCoordinate(kind: TimeLabelKind): number | null {
    const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;

    if (!anchor) {
      return null;
    }

    const coordinate = getXCoordinateFromTime(this.chart, anchor.time, this.series);

    return coordinate === null ? null : Number(coordinate);
  }

  private getPriceCoordinate(kind: PriceLabelKind): number | null {
    const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;

    if (!anchor) {
      return null;
    }

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

    return coordinate === null ? null : Number(coordinate);
  }

  private getTimeText(kind: TimeLabelKind): string {
    const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;

    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 anchor = kind === 'start' ? this.startAnchor : this.endAnchor;

    if (!anchor) {
      return '';
    }

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


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

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

import type { SliderPositionTextStyle } from './settings';
import type { SliderPosition } from './sliderPosition';

const UI = {
  lineWidth: 1,
  lineHeightMultiplier: 1.2,
  mainBoxHeight: 28,
  sideBoxHeight: 16,
  padding: 4,
  boxRadius: 4,
  labelOffset: 10,
  handleSize: 10,
  handleRadius: 3,
  handleBorderWidth: 1,
};

export class SliderPaneRenderer implements IPrimitivePaneRenderer {
  private readonly slider: SliderPosition;

  constructor(slider: SliderPosition) {
    this.slider = slider;
  }

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

    if (!data) {
      return;
    }

    target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
      const startX = data.startX * horizontalPixelRatio;
      const endX = data.endX * horizontalPixelRatio;
      const left = data.leftX * horizontalPixelRatio;
      const right = data.rightX * horizontalPixelRatio;

      const entryY = data.entryY * verticalPixelRatio;
      const stopY = data.stopY * verticalPixelRatio;
      const targetY = data.targetY * verticalPixelRatio;

      const profitTop = data.profitTop * verticalPixelRatio;
      const profitBottom = data.profitBottom * verticalPixelRatio;
      const lossTop = data.lossTop * verticalPixelRatio;
      const lossBottom = data.lossBottom * verticalPixelRatio;

      const centerX = (left + right) / 2;
      const sideBoxHeightPx = UI.sideBoxHeight * verticalPixelRatio;
      const mainBoxHeightPx = UI.mainBoxHeight * verticalPixelRatio;
      const labelOffsetPx = UI.labelOffset * verticalPixelRatio;

      const targetLabelCenterY =
        targetY < entryY
          ? targetY - labelOffsetPx - sideBoxHeightPx / 2
          : targetY + labelOffsetPx + sideBoxHeightPx / 2;

      const stopLabelCenterY =
        stopY < entryY ? stopY - labelOffsetPx - sideBoxHeightPx / 2 : stopY + labelOffsetPx + sideBoxHeightPx / 2;

      context.save();

      if (data.showFill) {
        context.fillStyle = data.positiveFillColor;
        context.fillRect(left, profitTop, right - left, profitBottom - profitTop);

        context.fillStyle = data.negativeFillColor;
        context.fillRect(left, lossTop, right - left, lossBottom - lossTop);
      }

      context.lineWidth = UI.lineWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
      context.strokeStyle = data.lineColor;
      drawHorizontalLine(context, left, right, entryY);

      if (data.showHandles) {
        drawHandle(context, startX, entryY, horizontalPixelRatio, verticalPixelRatio, 'circle');
        drawHandle(context, endX, entryY, horizontalPixelRatio, verticalPixelRatio, 'rounded');
        drawHandle(context, startX, targetY, horizontalPixelRatio, verticalPixelRatio, 'rounded');
        drawHandle(context, startX, stopY, horizontalPixelRatio, verticalPixelRatio, 'rounded');
      }

      if (data.showLabels) {
        drawTextBox(
          context,
          centerX,
          targetLabelCenterY,
          data.targetText,
          data.positiveFillColor,
          data,
          horizontalPixelRatio,
          verticalPixelRatio,
          UI.sideBoxHeight,
        );

        drawTextBox(
          context,
          centerX,
          entryY + labelOffsetPx + mainBoxHeightPx / 2,
          data.centerText,
          data.centerBoxColor,
          data,
          horizontalPixelRatio,
          verticalPixelRatio,
          UI.mainBoxHeight,
        );

        drawTextBox(
          context,
          centerX,
          stopLabelCenterY,
          data.stopText,
          data.negativeFillColor,
          data,
          horizontalPixelRatio,
          verticalPixelRatio,
          UI.sideBoxHeight,
        );
      }

      context.restore();
    });
  }
}

function drawHorizontalLine(context: CanvasRenderingContext2D, left: number, right: number, y: number): void {
  context.beginPath();
  context.moveTo(left, y);
  context.lineTo(right, y);
  context.stroke();
}

function drawHandle(
  context: CanvasRenderingContext2D,
  x: number,
  y: number,
  horizontalPixelRatio: number,
  verticalPixelRatio: number,
  shape: 'circle' | 'rounded',
): void {
  const width = UI.handleSize * horizontalPixelRatio;
  const height = UI.handleSize * verticalPixelRatio;
  const left = x - width / 2;
  const top = y - height / 2;

  const { colors } = getThemeStore();

  context.save();
  context.fillStyle = colors.chartBackground;
  context.strokeStyle = colors.chartLineColor;
  context.lineWidth = UI.handleBorderWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);

  context.beginPath();

  if (shape === 'circle') {
    context.arc(x, y, Math.min(width, height) / 2, 0, Math.PI * 2);
  } else {
    drawRoundedRect(
      context,
      left,
      top,
      width,
      height,
      UI.handleRadius * Math.max(horizontalPixelRatio, verticalPixelRatio),
    );
  }

  context.fill();
  context.stroke();
  context.restore();
}

function drawTextBox(
  context: CanvasRenderingContext2D,
  centerX: number,
  centerY: number,
  text: string,
  fillColor: string,
  textStyle: SliderPositionTextStyle,
  horizontalPixelRatio: number,
  verticalPixelRatio: number,
  fixedHeight: number,
): void {
  context.save();

  const lines = text.split('\n');
  const fontSize = textStyle.fontSize * verticalPixelRatio;
  const lineHeight = Math.round(textStyle.fontSize * UI.lineHeightMultiplier) * verticalPixelRatio;
  const paddingX = UI.padding * horizontalPixelRatio;
  const paddingY = UI.padding * verticalPixelRatio;
  const minBoxHeight = fixedHeight * verticalPixelRatio;
  const boxHeight = Math.max(minBoxHeight, lines.length * lineHeight + paddingY * 2);
  const radius = UI.boxRadius * Math.max(horizontalPixelRatio, verticalPixelRatio);

  context.font = getTextFont(textStyle, fontSize);
  context.textAlign = 'center';
  context.textBaseline = 'middle';

  let maxTextWidth = 0;

  for (const line of lines) {
    maxTextWidth = Math.max(maxTextWidth, context.measureText(line).width);
  }

  const width = maxTextWidth + paddingX * 2;
  const x = centerX - width / 2;
  const y = centerY - boxHeight / 2;

  context.fillStyle = fillColor;
  context.beginPath();
  drawRoundedRect(context, x, y, width, boxHeight, radius);
  context.fill();

  context.fillStyle = textStyle.textColor;

  if (lines.length === 1) {
    context.fillText(lines[0], centerX, centerY);
    context.restore();
    return;
  }

  const textBlockHeight = lines.length * lineHeight;
  const firstLineCenterY = centerY - textBlockHeight / 2 + lineHeight / 2;

  lines.forEach((line, index) => {
    context.fillText(line, centerX, firstLineCenterY + index * lineHeight);
  });

  context.restore();
}

function getTextFont(style: SliderPositionTextStyle, fontSize: number): string {
  const italic = style.isItalic ? 'italic ' : '';
  const bold = style.isBold ? '700 ' : '';

  return `${italic}${bold}${fontSize}px Inter, sans-serif`;
}

function drawRoundedRect(
  context: CanvasRenderingContext2D,
  x: number,
  y: number,
  width: number,
  height: number,
  radius: number,
): void {
  const safeRadius = Math.min(radius, width / 2, height / 2);

  context.moveTo(x + safeRadius, y);
  context.arcTo(x + width, y, x + width, y + height, safeRadius);
  context.arcTo(x + width, y + height, x, y + height, safeRadius);
  context.arcTo(x, y + height, x, y, safeRadius);
  context.arcTo(x, y, x + width, y, safeRadius);
  context.closePath();
}


import { Observable, skip } from 'rxjs';

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

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

import { SliderPaneView } from './paneView';

import {
  createDefaultSettings,
  getSliderPositionSettingsTabs,
  SliderPositionSettings,
  SliderPositionStyle,
  SliderPositionTextStyle,
} from './settings';

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

type SliderSide = 'long' | 'short';
type SliderMode = 'idle' | 'ready' | 'dragging';
type DragTarget = 'body' | 'entry' | 'target' | 'stop' | 'end' | null;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'target' | 'entry' | 'stop';

interface SliderPositionParams {
  side: SliderSide;
  container: HTMLElement;
  interaction: DrawingInteraction;
  formatObservable?: Observable<ChartOptionsModel>;
  resetTriggers?: Observable<unknown>[];
  removeSelf?: () => void;
  openSettings?: () => void;
}

interface SliderPositionState {
  hidden: boolean;
  mode: SliderMode;
  startTime: Time | null;
  endTime: Time | null;
  entryPrice: number | null;
  stopPrice: number | null;
  targetPrice: number | null;
  riskRewardRatio: number;
  amount: number;
  tickSize: number;
  settings: SliderPositionSettings;
}

interface SliderGeometry {
  startX: number;
  endX: number;
  leftX: number;
  rightX: number;
  entryY: number;
  stopY: number;
  targetY: number;
  entryPrice: number;
  stopPrice: number;
  targetPrice: number;
  profitTop: number;
  profitBottom: number;
  lossTop: number;
  lossBottom: number;
}

export interface SliderRenderData extends SliderGeometry, SliderPositionStyle, SliderPositionTextStyle {
  targetText: string;
  centerText: string;
  stopText: string;
  centerBoxColor: string;
  showFill: boolean;
  showHandles: boolean;
  showLabels: boolean;
}

const HIT_TOLERANCE = 8;
const INITIAL_WIDTH_PX = 160;
const MIN_DISTANCE = 0.00000001;

export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> implements ISeriesDrawing {
  private removeSelf?: () => void;
  private openSettings?: () => void;

  protected settings: SliderPositionSettings = createDefaultSettings();

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

  protected mode: SliderMode = 'idle';

  private side: SliderSide;

  private startTime: Time | null = null;
  private endTime: Time | null = null;
  private entryPrice: number | null = null;
  private stopPrice: number | null = null;
  private targetPrice: number | null = null;

  private activeDragTarget: DragTarget = null;
  private dragPointerId: number | null = null;
  private dragStartPoint: Point | null = null;
  private dragStateSnapshot: SliderPositionState | null = null;

  private defaultRiskRewardRatio = 1;
  private amount = 1000;
  private tickSize = 1;

  private clickHandler: MouseEventHandler<Time>;
  private paneView: SliderPaneView;
  private timeAxisPaneView: CustomTimeAxisPaneView;
  private priceAxisPaneView: CustomPriceAxisPaneView;
  private startTimeAxisView: CustomTimeAxisView;
  private endTimeAxisView: CustomTimeAxisView;
  private targetPriceAxisView: CustomPriceAxisView;
  private entryPriceAxisView: CustomPriceAxisView;
  private stopPriceAxisView: CustomPriceAxisView;

  constructor(
    chart: IChartApi,
    series: SeriesApi,
    {
      side,
      container,
      interaction,
      formatObservable,
      resetTriggers = [],
      removeSelf,
      openSettings,
    }: SliderPositionParams,
  ) {
    super({
      chart,
      series,
      container,
      interaction,
    });

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

    this.clickHandler = (params) => this.handleChartClick(params);

    this.paneView = new SliderPaneView(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.targetPriceAxisView = new CustomPriceAxisView({
      getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
      labelKind: 'target',
    });

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

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

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

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

    this.series.attachPrimitive(this);
  }

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

  public getState(): SliderPositionState {
    return {
      hidden: this.hidden,
      mode: this.mode,
      startTime: this.startTime,
      endTime: this.endTime,
      entryPrice: this.entryPrice,
      stopPrice: this.stopPrice,
      targetPrice: this.targetPrice,
      riskRewardRatio: this.getCurrentRiskRewardRatio(),
      amount: this.amount,
      tickSize: this.tickSize,
      settings: { ...this.settings },
    };
  }

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

    const next = state as Partial<SliderPositionState>;

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

    this.startTime = next.startTime ?? this.startTime;
    this.endTime = next.endTime ?? this.endTime;
    this.entryPrice = next.entryPrice ?? this.entryPrice;
    this.stopPrice = next.stopPrice ?? this.stopPrice;
    this.amount = next.amount ?? this.amount;

    if (typeof next.tickSize === 'number' && next.tickSize > 0) {
      this.tickSize = next.tickSize;
    }

    if (next.targetPrice !== undefined) {
      this.targetPrice = next.targetPrice;
    } else if (
      this.entryPrice !== null &&
      this.stopPrice !== null &&
      typeof next.riskRewardRatio === 'number' &&
      next.riskRewardRatio > 0
    ) {
      const risk = Math.abs(this.entryPrice - this.stopPrice);

      this.targetPrice =
        this.side === 'long'
          ? this.entryPrice + risk * next.riskRewardRatio
          : this.entryPrice - risk * next.riskRewardRatio;
    }

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

    this.render();
  }

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

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

  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.targetPriceAxisView, this.entryPriceAxisView, this.stopPriceAxisView];
  }

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    const reward = Math.abs(geometry.targetPrice - geometry.entryPrice);
    const qty = reward > 0 ? this.amount / reward : 0;

    const selectedPrice = this.getPriceAtTime(this.endTime);
    const pnl =
      selectedPrice === null
        ? 0
        : this.side === 'long'
          ? (selectedPrice - geometry.entryPrice) * qty
          : (geometry.entryPrice - selectedPrice) * qty;

    const { colors } = getThemeStore();

    return {
      ...geometry,
      targetText: this.getTargetText(geometry),
      centerText: this.getCenterText(qty, pnl),
      stopText: this.getStopText(geometry),
      centerBoxColor: pnl >= 0 ? colors.chartCandleUp : colors.chartCandleDown,
      showFill: true,
      showHandles: this.shouldShowHandles(),
      showLabels: this.isSelected(),
      ...this.settings,
    };
  }

  public getTimeBounds(): { left: number; right: number } | null {
    const start = this.getTimeCoordinate('start');
    const end = this.getTimeCoordinate('end');

    if (start === null || end === null) {
      return null;
    }

    return {
      left: Math.min(start, end),
      right: Math.max(start, end),
    };
  }

  public getTimeCoordinate(kind: TimeLabelKind): number | null {
    const time = kind === 'start' ? this.startTime : this.endTime;

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

    const coordinate = getXCoordinateFromTime(this.chart, time, this.series);

    return coordinate === null ? null : Number(coordinate);
  }

  public 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,
    );
  }

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

    if (!geometry) {
      return null;
    }

    switch (kind) {
      case 'target':
        return geometry.targetY;
      case 'entry':
        return geometry.entryY;
      case 'stop':
        return geometry.stopY;
      default:
        return null;
    }
  }

  public getPriceText(kind: PriceLabelKind): string {
    const geometry = this.getGeometry();

    if (!geometry) {
      return '';
    }

    switch (kind) {
      case 'target':
        return formatPrice(geometry.targetPrice) ?? '';
      case 'entry':
        return formatPrice(geometry.entryPrice) ?? '';
      case 'stop':
        return formatPrice(geometry.stopPrice) ?? '';
      default:
        return '';
    }
  }

  protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
    const point = { x, y };

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

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

    const dragTarget = this.getHandleTarget(point);

    if (!dragTarget) {
      return null;
    }

    let cursorStyle: PrimitiveHoveredItem['cursorStyle'] = 'grab';

    if (dragTarget === 'target' || dragTarget === 'stop') {
      cursorStyle = 'ns-resize';
    }

    if (dragTarget === 'end') {
      cursorStyle = 'ew-resize';
    }

    return {
      cursorStyle,
      externalId: 'slider-position',
      zOrder: 'top',
    };
  }

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

    const bounds = this.getTimeBounds();

    if (!bounds) {
      return [];
    }

    const { colors } = getThemeStore();

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

    return [
      {
        from: geometry.profitTop,
        to: geometry.profitBottom,
        color: colors.axisMarkerAreaFill,
      },
      {
        from: geometry.lossTop,
        to: geometry.lossBottom,
        color: colors.axisMarkerAreaFill,
      },
    ];
  }

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

    const labelKind = kind as TimeLabelKind;
    const coordinate = this.getTimeCoordinate(labelKind);
    const text = this.getTimeText(labelKind);

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

    const { colors } = getThemeStore();

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

  protected getPriceAxisLabel(kind: string): AxisLabel | null {
    if (kind !== 'target' && kind !== 'entry' && kind !== 'stop') {
      return null;
    }

    const labelKind = kind as PriceLabelKind;
    const coordinate = this.getPriceCoordinate(labelKind);
    const text = this.getPriceText(labelKind);

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

    const { colors } = getThemeStore();

    let backgroundColor = colors.axisMarkerLabelDefaultFill;

    if (labelKind === 'target') {
      backgroundColor = colors.axisMarkerLabelPositiveFill;
    }

    if (labelKind === 'stop') {
      backgroundColor = colors.axisMarkerLabelNegativeFill;
    }

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

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

    super.bindEvents();
    this.chart.subscribeClick(this.clickHandler);
  }

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

    this.chart.unsubscribeClick(this.clickHandler);
    super.unbindEvents();
  }

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

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

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

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

    this.openSettings?.();
  };

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

    const anchor = this.createAnchor(params);

    if (!anchor) {
      return;
    }

    const distance = this.getInitialZoneDistance(anchor.price);
    const stopDirection = this.side === 'long' ? -1 : 1;
    const targetDirection = -stopDirection;

    this.startTime = anchor.time;
    this.endTime = this.shiftTime(anchor.time, INITIAL_WIDTH_PX) ?? anchor.time;
    this.entryPrice = anchor.price;
    this.stopPrice = this.normalizeStop(anchor.price, anchor.price + distance * stopDirection, distance);
    this.targetPrice = this.normalizeTarget(
      anchor.price,
      anchor.price + distance * targetDirection * this.defaultRiskRewardRatio,
      distance,
    );

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

    this.render();
  }

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

    const point = this.getEventPoint(event);

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

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

      this.select();
      return;
    }

    const dragTarget = this.getHandleTarget(point);

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

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

    this.activeDragTarget = dragTarget;
    this.dragPointerId = event.pointerId;
    this.dragStartPoint = point;
    this.dragStateSnapshot = this.getState();
    this.mode = 'dragging';
  };

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

    event.preventDefault();

    this.applyDrag(this.getEventPoint(event));
    this.render();
  };

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

    this.activeDragTarget = null;
    this.dragPointerId = null;
    this.dragStartPoint = null;
    this.dragStateSnapshot = null;

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

    this.render();
  };

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

    if (!snapshot) {
      return;
    }

    switch (this.activeDragTarget) {
      case 'body':
        this.moveWhole(snapshot, point);
        break;
      case 'entry':
        this.moveEntry(snapshot, point);
        break;
      case 'target':
        this.moveTarget(snapshot, point);
        break;
      case 'stop':
        this.moveStop(snapshot, point);
        break;
      case 'end':
        this.resizeEnd(snapshot, point);
        break;
      default:
        break;
    }
  }

  private moveEntry(snapshot: SliderPositionState, point: Point): void {
    if (snapshot.stopPrice === null || snapshot.targetPrice === null) {
      return;
    }

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

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

    const minDistance = this.getEditMinimumDistance();
    const low = Math.min(snapshot.stopPrice, snapshot.targetPrice) + minDistance;
    const high = Math.max(snapshot.stopPrice, snapshot.targetPrice) - minDistance;

    if (low > high) {
      return;
    }

    this.entryPrice = Math.max(low, Math.min(nextPrice, high));
  }

  private moveWhole(snapshot: SliderPositionState, point: Point): void {
    if (snapshot.startTime === null || snapshot.endTime === null) {
      return;
    }

    if (snapshot.entryPrice === null || snapshot.stopPrice === null || snapshot.targetPrice === null) {
      return;
    }

    if (!this.dragStartPoint) {
      return;
    }

    const timeOffset = point.x - this.dragStartPoint.x;
    const priceOffset = this.getPriceDelta(this.dragStartPoint.y, point.y);

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

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

    let nextEntryPrice = snapshot.entryPrice + priceOffset;
    let nextStopPrice = snapshot.stopPrice + priceOffset;
    let nextTargetPrice = snapshot.targetPrice + priceOffset;

    const range = this.getPriceScaleRange();

    if (range) {
      const minValue = Math.min(nextEntryPrice, nextStopPrice, nextTargetPrice);
      const maxValue = Math.max(nextEntryPrice, nextStopPrice, nextTargetPrice);

      if (minValue < range.min) {
        const shift = range.min - minValue;

        nextEntryPrice += shift;
        nextStopPrice += shift;
        nextTargetPrice += shift;
      }

      if (maxValue > range.max) {
        const shift = maxValue - range.max;

        nextEntryPrice -= shift;
        nextStopPrice -= shift;
        nextTargetPrice -= shift;
      }
    }

    this.startTime = nextStartTime;
    this.endTime = nextEndTime;
    this.entryPrice = nextEntryPrice;
    this.stopPrice = nextStopPrice;
    this.targetPrice = nextTargetPrice;
  }

  private moveStop(snapshot: SliderPositionState, point: Point): void {
    if (snapshot.entryPrice === null) {
      return;
    }

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

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

    this.stopPrice = this.normalizeStop(
      snapshot.entryPrice,
      this.clampPriceToRange(nextPrice),
      this.getEditMinimumDistance(),
    );
  }

  private moveTarget(snapshot: SliderPositionState, point: Point): void {
    if (snapshot.entryPrice === null) {
      return;
    }

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

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

    this.targetPrice = this.normalizeTarget(
      snapshot.entryPrice,
      this.clampPriceToRange(nextPrice),
      this.getEditMinimumDistance(),
    );
  }

  private resizeEnd(snapshot: SliderPositionState, point: Point): void {
    const nextEndTime = getTimeFromXCoordinate(this.chart, point.x);

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

    this.startTime = snapshot.startTime;
    this.endTime = nextEndTime;
  }

  private createAnchor(params: MouseEventParams<Time>): { time: Time; price: number } | null {
    if (!params.point || params.time === undefined) {
      return null;
    }

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

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

    return {
      time: params.time,
      price,
    };
  }

  private normalizeStop(entryPrice: number, rawPrice: number, minDistance = this.getEditMinimumDistance()): number {
    const distance = this.getAllowedMinimumDistance(entryPrice, 'stop', minDistance);

    return this.side === 'long' ? Math.min(rawPrice, entryPrice - distance) : Math.max(rawPrice, entryPrice + distance);
  }

  private normalizeTarget(entryPrice: number, rawPrice: number, minDistance = this.getEditMinimumDistance()): number {
    const distance = this.getAllowedMinimumDistance(entryPrice, 'target', minDistance);

    return this.side === 'long' ? Math.max(rawPrice, entryPrice + distance) : Math.min(rawPrice, entryPrice - distance);
  }

  private getAllowedMinimumDistance(entryPrice: number, kind: 'stop' | 'target', minDistance: number): number {
    const range = this.getPriceScaleRange();

    if (!range) {
      return minDistance;
    }

    const availableDistance =
      this.side === 'long'
        ? kind === 'stop'
          ? Math.max(entryPrice - range.min, 0)
          : Math.max(range.max - entryPrice, 0)
        : kind === 'stop'
          ? Math.max(range.max - entryPrice, 0)
          : Math.max(entryPrice - range.min, 0);

    return Math.min(minDistance, availableDistance);
  }

  private clampPriceToRange(price: number): number {
    const range = this.getPriceScaleRange();

    if (!range) {
      return price;
    }

    return Math.max(range.min, Math.min(price, range.max));
  }

  private getInitialZoneDistance(entryPrice: number): number {
    const fallback = Math.max(Math.abs(entryPrice) * 0.0075, this.tickSize * 3, MIN_DISTANCE);
    const range = this.getPriceScaleRange();

    if (!range) {
      return fallback;
    }

    const size = range.max - range.min;

    if (size <= 0) {
      return fallback;
    }

    return Math.max(size * 0.05, this.tickSize * 3, MIN_DISTANCE);
  }

  private getEditMinimumDistance(): number {
    return Math.max(this.tickSize, MIN_DISTANCE);
  }

  private getPriceScaleRange(): { min: number; max: number } | null {
    return getPriceRangeInContainer(this.series, this.container);
  }

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

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

  private getCurrentRiskRewardRatio(): number {
    if (this.entryPrice === null || this.stopPrice === null || this.targetPrice === null) {
      return this.defaultRiskRewardRatio;
    }

    const risk = Math.abs(this.entryPrice - this.stopPrice);

    if (!risk) {
      return this.defaultRiskRewardRatio;
    }

    return Math.abs(this.targetPrice - this.entryPrice) / risk;
  }

  private getPriceAtTime(time: Time | null): number | null {
    if (typeof time !== 'number') {
      return null;
    }

    const data = this.series.data() ?? [];
    let lastPrice: number | null = null;

    for (const item of data) {
      if (typeof item.time !== 'number') {
        continue;
      }

      if (item.time > time) {
        break;
      }

      if ('close' in item && typeof item.close === 'number') {
        lastPrice = item.close;
        continue;
      }

      if ('value' in item && typeof item.value === 'number') {
        lastPrice = item.value;
      }
    }

    return lastPrice;
  }

  protected getGeometry(): SliderGeometry | null {
    if (this.startTime === null || this.endTime === null) {
      return null;
    }

    if (this.entryPrice === null || this.stopPrice === null || this.targetPrice === null) {
      return null;
    }

    const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
    const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
    const entryY = getYCoordinateFromPrice(this.series, this.entryPrice);
    const stopY = getYCoordinateFromPrice(this.series, this.stopPrice);
    const targetY = getYCoordinateFromPrice(this.series, this.targetPrice);

    if (startX === null || endX === null || entryY === null || stopY === null || targetY === null) {
      return null;
    }

    const start = Number(startX);
    const end = Number(endX);
    const entry = Number(entryY);
    const stop = Number(stopY);
    const target = Number(targetY);

    return {
      startX: start,
      endX: end,
      leftX: Math.min(start, end),
      rightX: Math.max(start, end),
      entryY: entry,
      stopY: stop,
      targetY: target,
      entryPrice: this.entryPrice,
      stopPrice: this.stopPrice,
      targetPrice: this.targetPrice,
      profitTop: Math.min(target, entry),
      profitBottom: Math.max(target, entry),
      lossTop: Math.min(stop, entry),
      lossBottom: Math.max(stop, entry),
    };
  }

  private getTargetText(geometry: SliderGeometry): string {
    const diff = Math.abs(geometry.targetPrice - geometry.entryPrice);
    const percent = geometry.entryPrice !== 0 ? (diff / Math.abs(geometry.entryPrice)) * 100 : 0;
    const ticks = this.tickSize > 0 ? diff / this.tickSize : 0;

    const formattedDiff = formatPrice(diff) ?? '0';
    const formattedTicks = formatPrice(ticks) ?? '0';
    const formattedAmount = formatPrice(this.amount) ?? '0';

    return `${t('Target')}: ${formattedDiff} (${formatPercent(percent)}) ${formattedTicks}, ${t('Amount')}: ${formattedAmount}`;
  }

  private getCenterText(qty: number, pnl: number): string {
    const formattedQty = formatPrice(qty) ?? '0';
    const formattedRatio = formatPrice(this.getCurrentRiskRewardRatio()) ?? '0';

    return `${t('Open P&L')}: ${formatSignedNumber(pnl)}, ${t('Qty')}: ${formattedQty}\n${t('Risk/Reward Ratio')}: ${formattedRatio}`;
  }

  private getStopText(geometry: SliderGeometry): string {
    const stopDiff = Math.abs(geometry.stopPrice - geometry.entryPrice);
    const rewardDiff = Math.abs(geometry.targetPrice - geometry.entryPrice);
    const percent = geometry.entryPrice !== 0 ? (stopDiff / Math.abs(geometry.entryPrice)) * 100 : 0;
    const ticks = this.tickSize > 0 ? stopDiff / this.tickSize : 0;
    const qty = rewardDiff > 0 ? this.amount / rewardDiff : 0;
    const stopAmount = stopDiff * qty;

    const formattedStopDiff = formatPrice(stopDiff) ?? '0';
    const formattedTicks = formatPrice(ticks) ?? '0';
    const formattedStopAmount = formatPrice(stopAmount) ?? '0';

    return `${t('Stop')}: ${formattedStopDiff} (${formatPercent(percent)}) ${formattedTicks}, ${t('Amount')}: ${formattedStopAmount}`;
  }

  private getHandleTarget(point: Point): DragTarget {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    if (isNearPoint(point, geometry.startX, geometry.entryY, HIT_TOLERANCE)) {
      return 'entry';
    }

    if (isNearPoint(point, geometry.startX, geometry.targetY, HIT_TOLERANCE)) {
      return 'target';
    }

    if (isNearPoint(point, geometry.startX, geometry.stopY, HIT_TOLERANCE)) {
      return 'stop';
    }

    if (isNearPoint(point, geometry.endX, geometry.entryY, HIT_TOLERANCE)) {
      return 'end';
    }

    const minY = Math.min(geometry.targetY, geometry.stopY);
    const maxY = Math.max(geometry.targetY, geometry.stopY);

    const bounds: Bounds = {
      left: geometry.leftX,
      right: geometry.rightX,
      top: minY,
      bottom: maxY,
    };

    if (isPointInBounds(point, bounds)) {
      return 'body';
    }

    return null;
  }

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

    if (!geometry) {
      return false;
    }

    const minY = Math.min(geometry.targetY, geometry.stopY);
    const maxY = Math.max(geometry.targetY, geometry.stopY);

    const bounds: Bounds = {
      left: geometry.leftX,
      right: geometry.rightX,
      top: minY,
      bottom: maxY,
    };

    return isPointInBounds(point, bounds, HIT_TOLERANCE);
  }
}