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


import { clamp } from 'lodash-es';

import type { Anchor, Bounds, ContainerSize, Point, SeriesApi } from './types';

import type { Coordinate, IChartApi, Time, TouchMouseEventData } from 'lightweight-charts';

interface SeriesTimeItem {
  time: Time;
}

interface SeriesTimePoint {
  time: Time;
  timestamp: number;
}

export function getPriceFromYCoordinate(series: SeriesApi, yCoordinate: number): number | null {
  return series.coordinateToPrice(yCoordinate as Coordinate);
}

export function getYCoordinateFromPrice(series: SeriesApi, price: number): Coordinate | null {
  return series.priceToCoordinate(price);
}

export function getTimeFromXCoordinate(chart: IChartApi, xCoordinate: number): Time | null {
  return chart.timeScale().coordinateToTime(xCoordinate as Coordinate) ?? null;
}

export function getXCoordinateFromTime(chart: IChartApi, time: Time, series?: SeriesApi): Coordinate | null {
  const coordinate = chart.timeScale().timeToCoordinate(time);

  if (isValidCoordinate(coordinate)) {
    return coordinate;
  }

  if (!series) {
    return null;
  }

  return getProjectedXCoordinate(chart, series, time);
}

export function getContainerSize(container: HTMLElement): ContainerSize {
  const rect = container.getBoundingClientRect();

  return {
    width: rect.width,
    height: rect.height,
  };
}

export function clampPointToContainer(point: Point, container: HTMLElement): Point {
  const { width, height } = getContainerSize(container);

  return {
    x: clamp(point.x, 0, width),
    y: clamp(point.y, 0, height),
  };
}

export function getRawPointerPoint(container: HTMLElement, event: MouseEvent | TouchMouseEventData): Point {
  const rect = container.getBoundingClientRect();

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

export function getPointerPoint(container: HTMLElement, event: MouseEvent | TouchMouseEventData): Point {
  return clampPointToContainer(
    getRawPointerPoint(container, event),
    container,
  );
}

export function isNearPoint(point: Point, x: number, y: number, tolerance: number): boolean {
  return Math.abs(point.x - x) <= tolerance && Math.abs(point.y - y) <= tolerance;
}

export function isPointInBounds(point: Point, bounds: Bounds, tolerance = 0): boolean {
  return (
    point.x >= bounds.left - tolerance &&
    point.x <= bounds.right + tolerance &&
    point.y >= bounds.top - tolerance &&
    point.y <= bounds.bottom + tolerance
  );
}

export function normalizeBounds(
  left: number,
  right: number,
  top: number,
  bottom: number,
  container: HTMLElement,
): Bounds {
  const { width, height } = getContainerSize(container);

  return {
    left: clamp(Math.min(left, right), 0, width),
    right: clamp(Math.max(left, right), 0, width),
    top: clamp(Math.min(top, bottom), 0, height),
    bottom: clamp(Math.max(top, bottom), 0, height),
  };
}

export function shiftTimeByPixels(
  chart: IChartApi,
  time: Time,
  offsetX: number,
  series?: SeriesApi,
): Time | null {
  const coordinate = getXCoordinateFromTime(chart, time, series);

  if (!isValidCoordinate(coordinate)) {
    return null;
  }

  return getTimeFromXCoordinate(
    chart,
    Number(coordinate) + offsetX,
  );
}

export function getPriceDelta(series: SeriesApi, fromY: number, toY: number): number {
  const fromPrice = getPriceFromYCoordinate(series, fromY);
  const toPrice = getPriceFromYCoordinate(series, toY);

  if (fromPrice === null || toPrice === null) {
    return 0;
  }

  return toPrice - fromPrice;
}

export function getPriceRangeInContainer(
  series: SeriesApi,
  container: HTMLElement,
): { min: number; max: number } | null {
  const { height } = getContainerSize(container);

  if (!height) {
    return null;
  }

  const topPrice = getPriceFromYCoordinate(series, 0);
  const bottomPrice = getPriceFromYCoordinate(series, height);

  if (topPrice === null || bottomPrice === null) {
    return null;
  }

  return {
    min: Math.min(topPrice, bottomPrice),
    max: Math.max(topPrice, bottomPrice),
  };
}

export function getAnchorFromPoint(chart: IChartApi, series: SeriesApi, point: Point): Anchor | null {
  const time = getTimeFromXCoordinate(chart, point.x);
  const price = getPriceFromYCoordinate(series, point.y);

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

  return {
    time,
    price,
  };
}

function getProjectedXCoordinate(
  chart: IChartApi,
  series: SeriesApi,
  time: Time,
): Coordinate | null {
  const targetTime = getNumericTime(time);

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

  const points = getSeriesTimePoints(series);

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

  if (points.length === 1) {
    return getCoordinateForTime(chart, points[0].time);
  }

  const lastIndex = points.length - 1;

  if (targetTime <= points[0].timestamp) {
    return interpolateCoordinate(
      chart,
      targetTime,
      points[0],
      points[1],
    );
  }

  if (targetTime >= points[lastIndex].timestamp) {
    return interpolateCoordinate(
      chart,
      targetTime,
      points[lastIndex - 1],
      points[lastIndex],
    );
  }

  let left = 0;
  let right = lastIndex;

  while (left <= right) {
    const middleIndex = Math.floor((left + right) / 2);
    const middlePoint = points[middleIndex];

    if (middlePoint.timestamp === targetTime) {
      return getCoordinateForTime(chart, middlePoint.time);
    }

    if (middlePoint.timestamp < targetTime) {
      left = middleIndex + 1;
    } else {
      right = middleIndex - 1;
    }
  }

  const previousPoint = points[right];
  const nextPoint = points[left];

  return interpolateCoordinate(
    chart,
    targetTime,
    previousPoint,
    nextPoint,
  );
}

function interpolateCoordinate(
  chart: IChartApi,
  targetTime: number,
  startPoint: SeriesTimePoint,
  endPoint: SeriesTimePoint,
): Coordinate | null {
  const startCoordinate = getCoordinateForTime(chart, startPoint.time);
  const endCoordinate = getCoordinateForTime(chart, endPoint.time);

  if (startCoordinate === null || endCoordinate === null) {
    return null;
  }

  const timeRange = endPoint.timestamp - startPoint.timestamp;

  if (timeRange === 0) {
    return startCoordinate;
  }

  const ratio = (targetTime - startPoint.timestamp) / timeRange;

  const coordinate =
    Number(startCoordinate) +
    (Number(endCoordinate) - Number(startCoordinate)) * ratio;

  if (!Number.isFinite(coordinate)) {
    return null;
  }

  return coordinate as Coordinate;
}

function getCoordinateForTime(chart: IChartApi, time: Time): Coordinate | null {
  const coordinate = chart.timeScale().timeToCoordinate(time);

  return isValidCoordinate(coordinate)
    ? coordinate
    : null;
}

function getSeriesTimePoints(series: SeriesApi): SeriesTimePoint[] {
  const data = series.data() as readonly SeriesTimeItem[];

  return data.reduce<SeriesTimePoint[]>((points, item) => {
    const timestamp = getNumericTime(item.time);

    if (timestamp === null) {
      return points;
    }

    points.push({
      time: item.time,
      timestamp,
    });

    return points;
  }, []);
}

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

  return Number.isFinite(time)
    ? time
    : null;
}

function isValidCoordinate(coordinate: Coordinate | null): coordinate is Coordinate {
  return coordinate !== null && Number.isFinite(Number(coordinate));
}

















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

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import { LinearDrawingBase } from '@core/Drawings/LinearDrawingBase';
import { getDistanceToSegment, updateViews } 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,
  RaySettings,
  RayStyle,
  RayTextStyle,
} from './settings';

import type { Anchor, AxisLabel, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
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> implements ISeriesDrawing {
  private removeSelf?: () => void;
  private openSettings?: () => void;

  protected settings: RaySettings = createDefaultSettings();
  protected mode: RayMode = 'idle';

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

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

  private readonly paneView: RayPaneView;
  private readonly timeAxisPaneView: CustomTimeAxisPaneView;
  private readonly priceAxisPaneView: CustomPriceAxisPaneView;
  private readonly startTimeAxisView: CustomTimeAxisView;
  private readonly directionTimeAxisView: CustomTimeAxisView;
  private readonly startPriceAxisView: CustomPriceAxisView;
  private readonly directionPriceAxisView: CustomPriceAxisView;

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

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

    this.paneView = new RayPaneView(this);

    this.timeAxisPaneView = this.createTimeAxisPaneView();
    this.priceAxisPaneView = this.createPriceAxisPaneView();

    this.startTimeAxisView = this.createTimeAxisView('start');
    this.directionTimeAxisView = this.createTimeAxisView('direction');

    this.startPriceAxisView = this.createPriceAxisView('start');
    this.directionPriceAxisView = this.createPriceAxisView('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 updateAllViews(): void {
    updateViews([
      this.paneView,
      this.timeAxisPaneView,
      this.priceAxisPaneView,
      this.startTimeAxisView,
      this.directionTimeAxisView,
      this.startPriceAxisView,
      this.directionPriceAxisView,
    ]);
  }

  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.directionTimeAxisView,
    ];
  }

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

  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 {
    if (!this.shouldShowInteractiveAxis() || (kind !== 'start' && kind !== 'direction')) {
      return null;
    }

    const labelKind = kind as TimeLabelKind;
    const anchorKind = labelKind === 'start'
      ? 'start'
      : 'end';

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

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

    const labelKind = kind as PriceLabelKind;
    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.startPoint;
    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 => {
    if (this.hidden || this.mode !== 'ready') {
      return;
    }

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

    if (!this.getDrawingHandleAtPoint(point) && !this.isPointNearRay(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.getDrawingHandleAtPoint(point)?.id ?? null;
    const isNearRay = this.isPointNearRay(point);
    const isDrawingHit = pointTarget !== null || isNearRay;

    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 === 'direction') {
      event.preventDefault();
      event.stopPropagation();

      this.startDragging(
        'dragging-direction',
        point,
        event.pointerId,
      );

      return;
    }

    if (isNearRay) {
      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-direction') {
      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-direction' ||
      this.mode === 'dragging-body'
    ) {
      this.finishDragging();
    }
  };

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

    if (!anchor) {
      return;
    }

    this.setAnchors(
      anchor,
      anchor,
    );

    this.mode = 'drawing';

    this.render();
  }

  private updateDrawing(point: Point): void {
    if (!this.setAnchorFromPoint('end', point)) {
      return;
    }

    this.render();
  }

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

    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: RayMode, 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 {
    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, height } = this.container.getBoundingClientRect();

    const candidates: RayIntersection[] = [];

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

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

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