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


import { IChartApi, IPrimitivePaneView, PrimitiveHoveredItem } from 'lightweight-charts';
import { clamp } from 'lodash-es';
import { Observable } from 'rxjs';

import { CustomPriceAxisPaneView, CustomTimeAxisPaneView } from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
  clampPointToContainer as clampPointToContainerInElement,
  getAnchorFromPoint,
  getContainerSize as getElementContainerSize,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  isNearPoint,
} from '@core/Drawings/helpers';
import { AxisLabel } from '@core/Drawings/types';
import { updateViews } from '@core/Drawings/utils';

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

import { TraectoryPaneView } from './paneView';

import { createDefaultSettings, getTraectorySettingsTabs, TraectorySettings, TraectoryStyle } from './settings';

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

type TraectoryMode = 'idle' | 'drawing' | 'ready' | 'dragging-point' | 'dragging-body';

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

export interface TraectoryState {
  hidden: boolean;
  mode: TraectoryMode;
  points: Anchor[];
  settings: TraectorySettings;
}

interface TraectoryGeometry {
  points: Point[];
  left: number;
  right: number;
  top: number;
  bottom: number;
}

export interface TraectoryRenderData extends TraectoryGeometry, TraectoryStyle {
  previewPoint: Point | null;
  showHandles: boolean;
  showArrow: boolean;
}

const POINT_HIT_TOLERANCE = 8;
const SEGMENT_HIT_TOLERANCE = 6;
const MIN_POINTS_COUNT = 2;

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

  protected settings: TraectorySettings = createDefaultSettings();

  protected mode: TraectoryMode = 'idle';

  private points: Anchor[] = [];
  private previewAnchor: Anchor | null = null;

  private dragPointerId: number | null = null;
  private dragStartPoint: Point | null = null;
  private dragPointIndex: number | null = null;
  private dragGeometrySnapshot: TraectoryGeometry | null = null;

  private readonly paneView: TraectoryPaneView;
  private readonly timeAxisPaneView: CustomTimeAxisPaneView;
  private readonly priceAxisPaneView: CustomPriceAxisPaneView;

  constructor(chart: IChartApi, series: SeriesApi, params: TraectoryParams) {
    super({
      chart,
      series,
      container: params.container,
      interaction: params.interaction,
    });

    const { removeSelf, openSettings } = params;

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

    this.paneView = new TraectoryPaneView(this);

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

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

    this.series.attachPrimitive(this);
  }

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

  public getState(): TraectoryState {
    return {
      hidden: this.hidden,
      mode: this.mode,
      points: this.points,
      settings: { ...this.settings },
    };
  }

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

    const nextState = state as Partial<TraectoryState>;

    this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
    this.mode = nextState.mode ?? this.mode;
    this.points = Array.isArray(nextState.points) ? nextState.points : this.points;

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

    this.render();
  }

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

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

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

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

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

  public timeAxisViews() {
    return [];
  }

  public priceAxisViews() {
    return [];
  }

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      ...geometry,
      previewPoint: this.getPreviewPoint(),
      showHandles: this.shouldShowHandles(),
      showArrow: this.mode !== 'drawing' && geometry.points.length > 1,
      ...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 };
    const pointIndex = this.getPointIndexAt(point);

    if (pointIndex !== null) {
      return {
        cursorStyle: 'move',
        externalId: 'traectory',
        zOrder: 'top',
      };
    }

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

    return {
      cursorStyle: 'move',
      externalId: 'traectory',
      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 getPriceAxisLabel(kind: string): AxisLabel | null {
    return null;
  }

  protected getTimeAxisLabel(kind: string): AxisLabel | null {
    return null;
  }

  protected handleDoubleClick = (event: MouseEvent): void => {
    if (this.hidden) {
      return;
    }

    if (this.mode === 'drawing') {
      event.preventDefault();
      event.stopPropagation();

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

      this.appendPoint(point);
      this.finishDrawing();
      return;
    }

    if (this.mode !== 'ready' || !this.isSelected()) {
      return;
    }

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

    if (this.getPointIndexAt(point) === null && !this.isPointNearTraectory(point)) {
      return;
    }

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

    this.openSettings?.();
  };

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

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

    this.finishDrawing();
  };

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

      if (event.detail > 1) {
        return;
      }

      this.appendPoint(point);
      return;
    }

    if (this.mode !== 'ready') {
      return;
    }

    const pointIndex = this.getPointIndexAt(point);
    const isInsideTraectory = pointIndex !== null || this.isPointNearTraectory(point);

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

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

      this.select();
      return;
    }

    if (pointIndex !== null) {
      event.preventDefault();
      event.stopPropagation();

      this.startDraggingPoint(point, event.pointerId, pointIndex);
      return;
    }

    if (!this.isPointNearTraectory(point)) {
      this.deselect();
      return;
    }

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

    this.startDraggingBody(point, event.pointerId);
  };

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

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

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

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

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

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

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

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

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

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

    if (!anchor) {
      return;
    }

    this.points = [anchor];
    this.previewAnchor = anchor;
    this.mode = 'drawing';

    this.render();
  }

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

    if (!anchor) {
      return;
    }

    const lastPoint = this.points[this.points.length - 1];

    if (lastPoint && Number(lastPoint.time) === Number(anchor.time) && lastPoint.price === anchor.price) {
      this.previewAnchor = anchor;
      this.render();
      return;
    }

    this.points = [...this.points, anchor];
    this.previewAnchor = anchor;
    this.render();
  }

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

    if (!anchor) {
      return;
    }

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

  private finishDrawing(): void {
    if (this.points.length < MIN_POINTS_COUNT) {
      if (this.removeSelf) {
        this.removeSelf();
        return;
      }

      this.resetToIdle();
      return;
    }

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

    this.render();
  }

  private startDraggingPoint(point: Point, pointerId: number, pointIndex: number): void {
    this.mode = 'dragging-point';

    this.dragPointerId = pointerId;
    this.dragStartPoint = point;
    this.dragPointIndex = pointIndex;
    this.dragGeometrySnapshot = this.getGeometry();

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

  private startDraggingBody(point: Point, pointerId: number): void {
    this.mode = 'dragging-body';

    this.dragPointerId = pointerId;
    this.dragStartPoint = point;
    this.dragPointIndex = null;
    this.dragGeometrySnapshot = this.getGeometry();

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

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

    this.dragPointerId = null;
    this.dragStartPoint = null;
    this.dragPointIndex = null;
    this.dragGeometrySnapshot = null;

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

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

    this.points = [];
    this.previewAnchor = null;
    this.dragPointerId = null;
    this.dragStartPoint = null;
    this.dragPointIndex = null;
    this.dragGeometrySnapshot = null;

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

  private movePoint(point: Point): void {
    const geometry = this.dragGeometrySnapshot;
    const pointIndex = this.dragPointIndex;

    if (!geometry || pointIndex === null) {
      return;
    }

    const nextPoint = this.clampPointToContainer(point);
    const nextPoints = [...geometry.points];

    nextPoints[pointIndex] = nextPoint;

    this.setAnchorsFromPoints(nextPoints);
  }

  private moveBody(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 nextPoints = geometry.points.map((item) =>
      this.clampPointToContainer({
        x: item.x + offsetX,
        y: item.y + offsetY,
      }),
    );

    this.setAnchorsFromPoints(nextPoints);
  }

  private setAnchorsFromPoints(points: Point[]): void {
    const nextAnchors: Anchor[] = [];

    for (const point of points) {
      const anchor = this.createAnchor(point);

      if (!anchor) {
        return;
      }

      nextAnchors.push(anchor);
    }

    this.points = nextAnchors;
  }

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

  protected getGeometry(): TraectoryGeometry | null {
    if (!this.points.length) {
      return null;
    }

    const { width, height } = this.getContainerSize();
    const screenPoints: Point[] = [];

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

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

      screenPoints.push({
        x: clamp(Math.round(Number(x)), 0, width),
        y: clamp(Math.round(Number(y)), 0, height),
      });
    }

    const left = Math.round(Math.min(...screenPoints.map((point) => point.x)));
    const right = Math.round(Math.max(...screenPoints.map((point) => point.x)));
    const top = Math.round(Math.min(...screenPoints.map((point) => point.y)));
    const bottom = Math.round(Math.max(...screenPoints.map((point) => point.y)));

    return {
      points: screenPoints,
      left,
      right,
      top,
      bottom,
    };
  }

  private getPreviewPoint(): Point | null {
    if (this.mode !== 'drawing' || !this.previewAnchor) {
      return null;
    }

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

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

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

    return {
      x: clamp(Math.round(Number(x)), 0, width),
      y: clamp(Math.round(Number(y)), 0, height),
    };
  }

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

    if (!geometry) {
      return null;
    }

    const index = geometry.points.findIndex((item) => isNearPoint(point, item.x, item.y, POINT_HIT_TOLERANCE));

    return index >= 0 ? index : null;
  }

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

    if (!geometry) {
      return false;
    }

    if (geometry.points.length === 1) {
      const firstPoint = geometry.points[0];

      return isNearPoint(point, firstPoint.x, firstPoint.y, POINT_HIT_TOLERANCE);
    }

    for (let index = 0; index < geometry.points.length - 1; index += 1) {
      const startPoint = geometry.points[index];
      const endPoint = geometry.points[index + 1];

      if (this.getDistanceToSegment(point, startPoint, endPoint) <= SEGMENT_HIT_TOLERANCE) {
        return true;
      }
    }

    return false;
  }

  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 getContainerSize(): { width: number; height: number } {
    return getElementContainerSize(this.container);
  }

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