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


import type { AxisLabel } from '@core/Drawings/types';
import type { ISeriesPrimitiveAxisView } from 'lightweight-charts';

export interface CustomAxisViewParams {
  getAxisLabel(labelKind: string): AxisLabel | null;
  labelKind: string;
  viewport: HTMLElement;
}

export abstract class CustomAxisView implements ISeriesPrimitiveAxisView {
  private readonly getAxisLabel: (labelKind: string) => AxisLabel | null;
  private readonly labelKind: string;
  private readonly viewport: HTMLElement;

  constructor({ getAxisLabel, labelKind, viewport }: CustomAxisViewParams) {
    this.getAxisLabel = getAxisLabel;
    this.labelKind = labelKind;
    this.viewport = viewport;
  }

  public update(): void {
    // Данные подписи читаются через getAxisLabel при каждом обращении; обновлять кэш не требуется.
  }

  public visible(): boolean {
    const label = this.getCurrentLabel();

    if (!label?.text) {
      return false;
    }

    const coordinate = Number(label.coordinate);
    const size = this.getViewportSize(this.viewport.getBoundingClientRect());

    return Number.isFinite(coordinate) && coordinate >= 0 && coordinate <= size;
  }

  protected abstract getViewportSize(rect: DOMRect): number;

  public tickVisible(): boolean {
    return false;
  }

  public coordinate(): number {
    return this.getCurrentLabel()?.coordinate ?? 0;
  }

  public text(): string {
    return this.getCurrentLabel()?.text ?? '';
  }

  public textColor(): string {
    return this.getCurrentLabel()?.textColor ?? '';
  }

  public backColor(): string {
    return this.getCurrentLabel()?.backgroundColor ?? '';
  }

  private getCurrentLabel(): AxisLabel | null {
    return this.getAxisLabel(this.labelKind);
  }
}

















import { CrosshairMode } from 'lightweight-charts';
import { Subject, Subscription } from 'rxjs';

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import { DrawingHandlesPrimitive } from '@core/Drawings/handles';
import {
  getAnchorFromPoint,
  getPointerPoint as getPointerPointFromEvent,
  getRawPointerPoint,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
} from '@core/Drawings/helpers';

import { updateViews } from '@core/Drawings/utils';

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

import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, AxisSegment, Bounds, Point, SeriesApi, UpdatableView } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab, SettingsValues } from '@src/types';
import type {
  AutoscaleInfo,
  IChartApi,
  IPrimitivePaneView,
  ISeriesApi,
  ISeriesPrimitive,
  ISeriesPrimitiveAxisView,
  Logical,
  MouseEventParams,
  PrimitiveHoveredItem,
  PrimitivePaneViewZOrder,
  SeriesAttachedParameter,
  SeriesOptionsMap,
  SeriesType,
  Time,
  TouchMouseEventData,
} from 'lightweight-charts';
import type { Observable } from 'rxjs';

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;

  isHit(event: MouseEvent): boolean;
  pointerDown(event: PointerEvent): void;
  click(event: MouseEvent): void;
  doubleClick(event: MouseEvent): void;
  contextMenu(event: MouseEvent): void;

  getRenderData(): unknown;
}

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

export interface BaseDrawingParams {
  chart: IChartApi;
  series: SeriesApi;
  container: HTMLElement;
  interaction: DrawingInteraction;
  formatObservable?: Observable<ChartOptionsModel>;
  removeSelf?: () => void;
  openSettings?: () => void;
  initialEvent?: MouseEventParams;
}

export abstract class DrawingBase<TSettings extends SettingsValues = SettingsValues, THandleId extends string = string>
  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 readonly handlesPrimitive: DrawingHandlesPrimitive<THandleId>;
  private isInteractionBound = false;

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

  private registeredPaneViews: (IPrimitivePaneView & UpdatableView)[] = [];

  private registeredTimePaneViews: CustomTimeAxisPaneView[] = [];

  private registeredPricePaneViews: CustomPriceAxisPaneView[] = [];

  private registeredTimeViews: CustomTimeAxisView[] = [];

  private registeredPriceViews: CustomPriceAxisView[] = [];

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

    this.handlesPrimitive = new DrawingHandlesPrimitive(() => {
      if (this.hidden || !this.shouldShowHandles()) {
        return [];
      }

      return this.getDrawingHandles();
    });
  }

  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.series.attachPrimitive(this.handlesPrimitive);
    this.bindInteraction();
    this.bindEvents();
  }

  public detached(): void {
    this.series.detachPrimitive(this.handlesPrimitive);
    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 isHit(event: MouseEvent): boolean {
    if (!this.isEventInside(event)) {
      return false;
    }

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

    return this.getHoveredItem(point.x, point.y) !== null;
  }

  public pointerDown(event: PointerEvent): void {
    if (!this.isEventInside(event)) {
      return;
    }

    if (!this.isLocked() || this.isCreationPending() || event.button !== 0) {
      this.handlePointerDown(event);
      return;
    }

    const point = this.getEventPoint(event);

    if (this.getHoveredItem(point.x, point.y)) {
      this.select();
      return;
    }

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

  public click(event: MouseEvent): void {
    if (!this.isEventInside(event)) {
      return;
    }

    this.handleClick(event);
  }

  public doubleClick(event: MouseEvent): void {
    if (!this.isEventInside(event)) {
      return;
    }

    this.handleDoubleClick(event);
  }

  public contextMenu(event: MouseEvent): void {
    if (!this.isEventInside(event)) {
      return;
    }

    this.handleContextMenu(event);
  }

  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 updateAllViews(): void {
    updateViews([
      ...this.registeredPaneViews,
      ...this.registeredTimePaneViews,
      ...this.registeredPricePaneViews,
      ...this.registeredTimeViews,
      ...this.registeredPriceViews,
    ]);
  }

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

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

  public priceAxisViews(): readonly ISeriesPrimitiveAxisView[] {
    return [...this.registeredPriceViews];
  }

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

  public timeAxisViews(): readonly ISeriesPrimitiveAxisView[] {
    return [...this.registeredTimeViews];
  }

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

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

  protected isAxisLabelAvailable(): boolean {
    return !this.hidden && !this.isCreationPending();
  }

  protected shouldShowInteractiveAxis(): boolean {
    return this.isSelected() || this.isCreationPending();
  }

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

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

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

  protected getDrawingHandles(): readonly DrawingHandle<THandleId>[] {
    return [];
  }

  protected getDrawingHandleAtPoint(point: Point): DrawingHandle<THandleId> | null {
    return this.handlesPrimitive.findHandle(point);
  }

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

  protected getPointFromAnchor(anchor: Anchor | null): Point | null {
    if (!anchor) {
      return null;
    }

    const x = getXCoordinateFromTime(this.chart, anchor.time, this.series);
    const y = getYCoordinateFromPrice(this.series, anchor.price);

    if (x === null || y === null) {
      return null;
    }

    return {
      x: Number(x),
      y: Number(y),
    };
  }

  protected createTimeAxisView(labelKind: string): CustomTimeAxisView {
    return new CustomTimeAxisView({
      getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
      labelKind,
      viewport: this.container,
    });
  }

  protected createPriceAxisView(labelKind: string): CustomPriceAxisView {
    return new CustomPriceAxisView({
      getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
      labelKind,
      viewport: this.container,
    });
  }

  protected createTimeAxisPaneView(zOrder: PrimitivePaneViewZOrder = 'bottom'): CustomTimeAxisPaneView {
    return new CustomTimeAxisPaneView({
      getAxisSegments: () => this.getTimeAxisSegments(),
      zOrder,
    });
  }

  protected createPriceAxisPaneView(zOrder: PrimitivePaneViewZOrder = 'bottom'): CustomPriceAxisPaneView {
    return new CustomPriceAxisPaneView({
      getAxisSegments: () => this.getPriceAxisSegments(),
      zOrder,
    });
  }

  protected createAxisLabel(
    coordinate: number | null,
    text: string,
    style?: Partial<Pick<AxisLabel, 'textColor' | 'backgroundColor'>>,
  ): AxisLabel | null {
    if (coordinate === null || !text) {
      return null;
    }

    const { colors } = getThemeStore();

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

  protected createAxisSegment(from: number, to: number, color?: string): AxisSegment {
    const { colors } = getThemeStore();

    return {
      from,
      to,
      color: color ?? colors.axisMarkerAreaFill,
    };
  }

  protected subscribeFormat(
    formatObservable: Observable<ChartOptionsModel> | undefined,
    callback: (format: ChartOptionsModel) => void,
  ): void {
    if (!formatObservable) {
      return;
    }

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

  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 | TouchMouseEventData): Point {
    return getPointerPointFromEvent(this.container, event);
  }

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

    this.isBound = true;

    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;

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

  // todo: хочется общую реализацию для каждой кнопки
  protected handleClick(_event: MouseEvent): void {
    // Обработка клика необязательна; нужные инструменты переопределяют этот метод.
  }

  protected handleContextMenu(_event: MouseEvent): void {
    // Контекстное меню обрабатывают только инструменты, которым оно требуется.
  }

  protected handleDoubleClick(_event: MouseEvent): void {
    // Двойной клик обрабатывают только инструменты с соответствующим действием.
  }

  protected handlePointerMove(_event: PointerEvent): void {
    // Инструменты без обработки перемещения указателя используют эту реализацию.
  }

  protected handlePointerUp(_event: PointerEvent): void {
    // Инструменты без обработки отпускания указателя используют эту реализацию.
  }

  protected handlePointerDown(_event: PointerEvent | TouchMouseEventData): 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;

  protected getBoundsAxisSegments(axis: 'time' | 'price', getBounds: () => Bounds | null): AxisSegment[] {
    if (!this.shouldShowInteractiveAxis()) {
      return [];
    }

    const bounds = getBounds();

    if (!bounds) {
      return [];
    }

    return axis === 'time'
      ? [this.createAxisSegment(bounds.left, bounds.right)]
      : [this.createAxisSegment(bounds.top, bounds.bottom)];
  }

  protected getInteractiveAxisLabel<TKind extends string>(
    kind: string,
    kinds: readonly TKind[],
    getLabel: (kind: TKind) => AxisLabel | null,
  ): AxisLabel | null {
    const labelKind = kinds.find((candidate) => candidate === kind);

    if (!this.shouldShowInteractiveAxis() || labelKind === undefined) {
      return null;
    }

    return getLabel(labelKind);
  }

  protected consumeEvent(event: MouseEvent): void {
    event.preventDefault();
    event.stopPropagation();
  }

  protected selectOnPointerDown(event: PointerEvent, isHit: () => boolean): boolean {
    if (this.isSelected()) {
      return false;
    }

    if (isHit()) {
      this.consumeEvent(event);
      this.select();
    }

    return true;
  }

  protected openSettingsOnDoubleClick(
    event: MouseEvent,
    isHit: (point: Point) => boolean,
    openSettings: (() => void) | undefined,
    selectedOnly = false,
  ): void {
    if (this.hidden || this.mode !== 'ready' || (selectedOnly && !this.isSelected())) {
      return;
    }

    if (!isHit(this.getEventPoint(event as PointerEvent))) {
      return;
    }

    this.consumeEvent(event);
    openSettings?.();
  }

  protected initializeViews(
    paneView: IPrimitivePaneView & UpdatableView,
    timeLabels: readonly string[],
    priceLabels: readonly string[],
    zOrder: PrimitivePaneViewZOrder = 'bottom',
  ): void {
    this.registeredPaneViews = [paneView];
    this.registeredTimePaneViews = [this.createTimeAxisPaneView(zOrder)];
    this.registeredPricePaneViews = [this.createPriceAxisPaneView(zOrder)];
    this.registeredTimeViews = timeLabels.map((kind) => this.createTimeAxisView(kind));
    this.registeredPriceViews = priceLabels.map((kind) => this.createPriceAxisView(kind));
  }

  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 isEventInside(event: MouseEvent): boolean {
    if (event.target instanceof Node && this.container.contains(event.target)) {
      return true;
    }

    return this.handlesPrimitive.isTimeAxisHit(getRawPointerPoint(this.container, event));
  }
}

















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

import { AreaDrawingBase } from '@core/Drawings/AreaDrawingBase';
import { isPointInBounds } from '@core/Drawings/helpers';

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  private readonly stepSize: number;
  private readonly stepLabel: string;

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

    this.rangeMode = rangeMode;
    this.removeSelf = removeSelf;
    this.openSettings = openSettings;
    this.stepSize = stepSize > 0 ? stepSize : 1;
    this.stepLabel = stepLabel;
    this.initializeViews(new DiapsonPaneView(this), ['left', 'right'], ['top', 'bottom']);

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

    this.series.attachPrimitive(this);

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

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

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

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

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

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

    const nextState = state as Partial<DiapsonState>;
    this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
    this.mode = nextState.mode ?? this.mode;
    this.rangeMode = nextState.rangeMode ?? this.rangeMode;
    this.restoreAnchor('start', nextState);
    this.restoreAnchor('end', nextState);

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

    this.render();
  }

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

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

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

    if (!geometry) {
      return [];
    }

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

  protected getTimeAxisLabel(kind: string): AxisLabel | null {
    return this.getBoundsAxisLabel(kind, ['left', 'right'], (labelKind) => this.getTimeText(labelKind));
  }

  protected getPriceAxisLabel(kind: string): AxisLabel | null {
    return this.getBoundsAxisLabel(kind, ['top', 'bottom'], (labelKind) => this.getPriceText(labelKind));
  }

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

    const point = { x, y };

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

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

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

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

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

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

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

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

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

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

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

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

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

  protected handleDoubleClick = (event: MouseEvent): void => {
    this.openSettingsOnDoubleClick(
      event,
      (point) => this.getDrawingHandleAtPoint(point) !== null || this.containsPoint(point),
      () => this.openSettings?.(),
    );
  };

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

    this.finishDragging();
  };

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

    if (!anchor) {
      return;
    }

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

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

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

    this.render();
  }

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

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

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

      this.resetToIdle();
      return;
    }

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

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

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

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

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

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

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

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

    const { width, height } = this.getContainerSize();
    const rawOffsetX = point.x - dragStartPoint.x;
    const rawOffsetY = point.y - dragStartPoint.y;
    const offsetX = clamp(rawOffsetX, -geometry.left, width - geometry.right);
    const offsetY = clamp(rawOffsetY, -geometry.top, height - geometry.bottom);

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

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

    this.setAnchorsFromPoints(nextStartPoint, nextEndPoint);
  }

  protected resize(point: Point): void {
    const geometry = this.dragGeometrySnapshot;

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

    const nextPoint = this.clampPointToContainer(point);

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

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

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

      if (!metrics) {
        return [];
      }

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

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

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

    const metrics = this.getPriceMetrics();

    if (!metrics) {
      return [];
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

    const volume = this.getVolumeInRange();

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

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

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

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

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

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

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

    return Math.abs(endIndex - startIndex);
  }

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

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

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

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

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

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

    const geometry = this.getTwoPointGeometry();

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

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

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

    const geometry = this.getTwoPointGeometry();

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

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

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

    const geometry = this.getTwoPointGeometry();

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

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

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

    const geometry = this.getTwoPointGeometry();

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

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

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

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

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

    const geometry = this.getGeometry();

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

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

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

















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

import { LinearDrawingBase } from '@core/Drawings/LinearDrawingBase';
import { getDistanceToSegment } from '@core/Drawings/utils';

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

import { RayPaneView } from './paneView';
import { createDefaultSettings, getRaySettingTabs } from './settings';

import type { RaySettings, RayStyle, RayTextStyle } from './settings';

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

type RayMode = 'idle' | 'drawing' | 'ready' | 'dragging-start' | 'dragging-direction' | 'dragging-body';

type RayHandleKey = 'start' | 'direction';

type TimeLabelKind = 'start' | 'direction';

type PriceLabelKind = 'start' | 'direction';

type RayParams = BaseDrawingParams;

interface RayState {
  hidden: boolean;
  mode: RayMode;
  startAnchor: Anchor | null;
  directionAnchor: Anchor | null;
  settings: RaySettings;
}

interface RayGeometry {
  startPoint: Point;
  directionPoint: Point;
  rayEndPoint: Point;
  left: number;
  right: number;
  top: number;
  bottom: number;
}

interface RayIntersection {
  point: Point;
  t: number;
}

export interface RayRenderData extends RayGeometry, RayStyle, RayTextStyle {}

const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;

export class Ray extends LinearDrawingBase<RaySettings, RayHandleKey, RayState> implements ISeriesDrawing {
  private removeSelf?: () => void;
  private openSettings?: () => void;

  protected settings: RaySettings = createDefaultSettings();

  protected mode: RayMode = 'idle';

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

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

    this.removeSelf = removeSelf;
    this.openSettings = openSettings;
    this.initializeViews(new RayPaneView(this), ['start', 'direction'], ['start', 'direction']);

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

    this.series.attachPrimitive(this);

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

  private get directionAnchor(): Anchor | null {
    return this.endAnchor;
  }

  private set directionAnchor(anchor: Anchor | null) {
    this.endAnchor = anchor;
  }

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

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

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

    const nextState = state as Partial<RayState>;

    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 ('directionAnchor' in nextState) {
      this.directionAnchor = nextState.directionAnchor ?? null;
    }

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

    this.render();
  }

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      ...geometry,
      ...this.settings,
    };
  }

  protected getStartHandleId(): RayHandleKey {
    return 'start';
  }

  protected getEndHandleId(): RayHandleKey {
    return 'direction';
  }

  protected getLineHandleStrokeColor(): string {
    return this.settings.lineColor;
  }

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

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

    return {
      cursorStyle: 'grab',
      externalId: 'ray',
      zOrder: 'top',
    };
  }

  protected getTimeAxisLabel(kind: string): AxisLabel | null {
    return this.getInteractiveAxisLabel(kind, ['start', 'direction'], (labelKind) => {
      const anchorKind = labelKind === 'start' ? 'start' : 'end';

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

  protected getPriceAxisLabel(kind: string): AxisLabel | null {
    return this.getInteractiveAxisLabel(kind, ['start', 'direction'], (labelKind) => {
      const anchorKind = labelKind === 'start' ? 'start' : 'end';

      return this.createAxisLabel(this.getAnchorPriceCoordinate(anchorKind), this.getPriceText(labelKind));
    });
  }

  protected getGeometry(): RayGeometry | null {
    const geometry = this.getTwoPointGeometry();

    if (!geometry) {
      return null;
    }

    const { startPoint } = geometry;
    const directionPoint = geometry.endPoint;
    const rayEndPoint = this.getRayEndPoint(startPoint, directionPoint);

    if (!rayEndPoint) {
      return null;
    }

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

  protected handleDoubleClick = (event: MouseEvent): void => {
    this.openSettingsOnDoubleClick(
      event,
      (point) => this.getDrawingHandleAtPoint(point) !== null || this.isPointNearRay(point),
      () => this.openSettings?.(),
    );
  };

  protected handleReadyPointerDown(event: PointerEvent, point: Point): void {
    const pointTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
    const isNearRay = this.isPointNearRay(point);
    const isDrawingHit = pointTarget !== null || isNearRay;

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

      this.consumeEvent(event);
      this.select();
      return;
    }

    if (pointTarget === 'start') {
      this.consumeEvent(event);
      this.startDragging('dragging-start', point, event.pointerId);

      return;
    }

    if (pointTarget === 'direction') {
      this.consumeEvent(event);
      this.startDragging('dragging-direction', point, event.pointerId);

      return;
    }

    if (isNearRay) {
      this.consumeEvent(event);
      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-direction') {
      this.consumeEvent(event);
      this.movePoint(point);
      this.render();

      return;
    }

    if (this.mode === 'dragging-body') {
      this.consumeEvent(event);
      this.moveBody(point);
      this.render();
    }
  };

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

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

  protected finishDrawing(): void {
    this.finishLinearDrawing(this.getTwoPointGeometry(), MIN_LINE_SIZE, () => this.removeSelf?.());
  }

  private movePoint(point: Point): void {
    if (this.mode === 'dragging-start') {
      this.setAnchorFromPoint('start', point);

      return;
    }

    if (this.mode === 'dragging-direction') {
      this.setAnchorFromPoint('end', point);
    }
  }

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

    if (!snapshot) {
      return;
    }

    this.moveAnchorsByPixels(snapshot.startAnchor, snapshot.directionAnchor, this.dragStartPoint, point);
  }

  private getRayEndPoint(startPoint: Point, directionPoint: Point): Point | null {
    const dx = directionPoint.x - startPoint.x;
    const dy = directionPoint.y - startPoint.y;

    if (dx === 0 && dy === 0) {
      return null;
    }

    const { width } = this.container.getBoundingClientRect();
    const height = this.series.getPane().getHeight();

    if (width <= 0 || height <= 0) {
      return null;
    }

    const candidates: RayIntersection[] = [];
    this.addVerticalRayIntersections(candidates, startPoint, dx, dy, width, height);
    this.addHorizontalRayIntersections(candidates, startPoint, dx, dy, width, height);

    if (!candidates.length) {
      return null;
    }

    return candidates.reduce((farthest, candidate) => {
      return candidate.t > farthest.t ? candidate : farthest;
    }).point;
  }

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

    if (!geometry) {
      return false;
    }

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

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

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

    if (!anchor) {
      return '';
    }

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

  private addVerticalRayIntersections(
    candidates: RayIntersection[],
    startPoint: Point,
    dx: number,
    dy: number,
    width: number,
    height: number,
  ): void {
    if (dx !== 0) {
      const leftT = -startPoint.x / dx;
      const rightT = (width - startPoint.x) / dx;
      const leftY = startPoint.y + leftT * dy;
      const rightY = startPoint.y + rightT * dy;

      if (leftT >= 0 && leftY >= 0 && leftY <= height) {
        candidates.push({
          point: {
            x: 0,
            y: leftY,
          },
          t: leftT,
        });
      }

      if (rightT >= 0 && rightY >= 0 && rightY <= height) {
        candidates.push({
          point: {
            x: width,
            y: rightY,
          },
          t: rightT,
        });
      }
    }
  }

  private addHorizontalRayIntersections(
    candidates: RayIntersection[],
    startPoint: Point,
    dx: number,
    dy: number,
    width: number,
    height: number,
  ): void {
    if (dy !== 0) {
      const topT = -startPoint.y / dy;
      const bottomT = (height - startPoint.y) / dy;
      const topX = startPoint.x + topT * dx;
      const bottomX = startPoint.x + bottomT * dx;

      if (topT >= 0 && topX >= 0 && topX <= width) {
        candidates.push({
          point: {
            x: topX,
            y: 0,
          },
          t: topT,
        });
      }

      if (bottomT >= 0 && bottomX >= 0 && bottomX <= width) {
        candidates.push({
          point: {
            x: bottomX,
            y: height,
          },
          t: bottomT,
        });
      }
    }
  }
}