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


import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { Coordinate, IPrimitivePaneRenderer, ISeriesApi, SeriesOptionsMap } from 'lightweight-charts';

import type { ViewPoint } from '@core/Drawings/common';
import type { TrendLineOptions } from '@core/Drawings/trendLine';

interface RayPaneRendererParams {
  p1: ViewPoint;
  p2: ViewPoint;
  text1: string;
  text2: string;
  series: ISeriesApi<keyof SeriesOptionsMap>;
  options: TrendLineOptions;
  visible: boolean;
}

export class RayPaneRenderer implements IPrimitivePaneRenderer {
  private _p1: ViewPoint;
  private _p2: ViewPoint;
  private _text1: string;
  private _text2: string;
  private _series: ISeriesApi<keyof SeriesOptionsMap>;
  private _options: TrendLineOptions;
  private _visible: boolean;

  constructor({ p1, p2, text1, text2, series, options, visible }: RayPaneRendererParams) {
    this._p1 = p1;
    this._p2 = p2;
    this._text1 = text1;
    this._text2 = text2;
    this._series = series;
    this._options = options;
    this._visible = visible;
  }

  public draw(target: CanvasRenderingTarget2D): void {
    target.useBitmapCoordinateSpace((scope) => {
      if (this._p1.x === null || this._p1.y === null || this._p2.x === null || this._p2.y === null || !this._visible) {
        return;
      }

      const paneSize = this.getPaneSize();

      if (!paneSize) {
        return;
      }

      const resPoint = bestIntersectionToP2(
        { x: this._p1.x, y: this._p1.y },
        { x: this._p2.x, y: this._p2.y },
        {
          xmin: 0,
          xmax: paneSize.width - 1,
          ymin: 0,
          ymax: paneSize.height - 1,
        },
      );

      const ctx = scope.context;
      const x1Scaled = Math.round(this._p1.x * scope.horizontalPixelRatio);
      const y1Scaled = Math.round(this._p1.y * scope.verticalPixelRatio);
      const x2Scaled = Math.round(this._p2.x * scope.horizontalPixelRatio);
      const y2Scaled = Math.round(this._p2.y * scope.verticalPixelRatio);

      const x3Scaled =
        resPoint?.x !== null && resPoint?.x !== undefined ? Math.round(resPoint.x * scope.horizontalPixelRatio) : null;
      const y3Scaled =
        resPoint?.y !== null && resPoint?.y !== undefined ? Math.round(resPoint.y * scope.verticalPixelRatio) : null;

      ctx.lineWidth = this._options.width;
      ctx.strokeStyle = this._options.lineColor;
      ctx.beginPath();
      ctx.moveTo(x1Scaled, y1Scaled);
      ctx.lineTo(x2Scaled, y2Scaled);

      if (x3Scaled !== null && y3Scaled !== null) {
        ctx.lineTo(x3Scaled, y3Scaled);
      }

      ctx.stroke();

      if (this._options.showLabels) {
        // this._drawTextLabel(scope, this._text1, x1Scaled, y1Scaled, true);
        // this._drawTextLabel(scope, this._text2, x2Scaled, y2Scaled, false);
      }
    });
  }

  private getPaneSize(): { width: number; height: number } | null {
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    const pane = this._series.getPane()._private__pane ?? this._series.getPane().Pt;

    if (!pane) {
      return null;
    }

    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    const width = pane._private__width ?? pane.Mo;

    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    const height = pane._private__height ?? pane.sl;

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

    return { width, height };
  }
}

function bestIntersectionToP2(
  p1: { x: Coordinate; y: Coordinate },
  p2: { x: Coordinate; y: Coordinate },
  rect: { xmin: number; xmax: number; ymin: number; ymax: number },
): ViewPoint | null {
  const { xmin, xmax, ymin, ymax } = rect;

  const dx = p2.x - p1.x;
  const dy = p2.y - p1.y;

  const canvasBorderIntersections: (ViewPoint & { t: number })[] = [];

  const pushIfValid = (x: number, y: number, t: number) => {
    const isP2Outside = p2.x < xmin || p2.x > xmax || p2.y < ymin || p2.y > ymax;

    const isCandidateOverP1 =
      (dx > 0 && x < p1.x) || (dx < 0 && x > p1.x) || (dy > 0 && y < p1.y) || (dy < 0 && y > p1.y);

    const isCandidateOverP2 =
      (dx > 0 && x < p2.x) || (dx < 0 && x > p2.x) || (dy > 0 && y < p2.y) || (dy < 0 && y > p2.y);

    if ((!isP2Outside && isCandidateOverP2) || (isP2Outside && isCandidateOverP1)) {
      return;
    }

    const isPointInBorders = x >= xmin && x <= xmax && y >= ymin && y <= ymax;

    if (!isPointInBorders) {
      return;
    }

    canvasBorderIntersections.push({ x, y, t } as ViewPoint & { t: number });
  };

  if (dx !== 0) {
    let t1 = (xmin - p1.x) / dx;
    let t2 = (xmin - p2.x) / dx;
    pushIfValid(xmin, p1.y + t1 * dy, t2);

    t1 = (xmax - p1.x) / dx;
    t2 = (xmax - p2.x) / dx;
    pushIfValid(xmax, p1.y + t1 * dy, t2);
  }

  if (dy !== 0) {
    let t1 = (ymin - p1.y) / dy;
    let t2 = (ymin - p2.y) / dy;
    pushIfValid(p1.x + t1 * dx, ymin, t2);

    t1 = (ymax - p1.y) / dy;
    t2 = (ymax - p2.y) / dy;
    pushIfValid(p1.x + t1 * dx, ymax, t2);
  }

  if (canvasBorderIntersections.length === 0) {
    return null;
  }

  let best = canvasBorderIntersections[0];
  let bestDist = Math.abs(best.t - 1);

  for (let index = 1; index < canvasBorderIntersections.length; index += 1) {
    const distance = Math.abs(canvasBorderIntersections[index].t - 1);

    if (distance > bestDist) {
      best = canvasBorderIntersections[index];
      bestDist = distance;
    }
  }

  return { x: best.x, y: best.y };
}



import { IPrimitivePaneRenderer, IPrimitivePaneView } from 'lightweight-charts';

import { getXCoordinateFromTime, getYCoordinateFromPrice } from '@core/Drawings/helpers';

import { RayPaneRenderer } from './paneRenderer';

import type { Ray } from './ray';

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

export class RayPaneView implements IPrimitivePaneView {
  private _source: Ray;
  private _p1: ViewPoint = { x: null, y: null };
  private _p2: ViewPoint = { x: null, y: null };

  constructor(source: Ray) {
    this._source = source;
  }

  public update(): void {
    const x1 = getXCoordinateFromTime(this._source._chart, this._source._p1.time);
    const x2 = getXCoordinateFromTime(this._source._chart, this._source._p2.time);
    const y1 = getYCoordinateFromPrice(this._source._series, this._source._p1.price);
    const y2 = getYCoordinateFromPrice(this._source._series, this._source._p2.price);

    this._p1 = { x: x1, y: y1 };
    this._p2 = { x: x2, y: y2 };
  }

  public renderer(): IPrimitivePaneRenderer {
    return new RayPaneRenderer({
      p1: this._p1,
      p2: this._p2,
      text1: `${this._source._p1.price.toFixed(1)}`,
      text2: `${this._source._p2.price.toFixed(1)}`,
      series: this._source._series,
      options: this._source._options,
      visible: this._source._visible,
    });
  }
}


import {
  AutoscaleInfo,
  IChartApi,
  ISeriesApi,
  Logical,
  MouseEventParams,
  SeriesOptionsMap,
  SeriesType,
  Time,
} from 'lightweight-charts';

import { ISeriesDrawing, Point } from '@core/Drawings/common';
import { getPriceFromYCoordinate, getTimeFromXCoordinate, getXCoordinateFromTime } from '@core/Drawings/helpers';

import { RayPaneView } from './paneView';

import type { TrendLineOptions } from '@core/Drawings/trendLine';

const defaultOptions: TrendLineOptions = {
  lineColor: 'gray',
  width: 3,
  showLabels: true,
  labelBackgroundColor: 'rgba(255, 255, 255, 0)',
  labelTextColor: 'rgb(0, 0, 0)',
};

export class Ray implements ISeriesDrawing {
  private _attached = false;

  _chart: IChartApi;
  _series: ISeriesApi<keyof SeriesOptionsMap>;
  _p1!: Point;
  _p2!: Point;
  _paneViews: RayPaneView[];
  _options: TrendLineOptions;
  _minPrice!: number;
  _maxPrice!: number;
  _visible = true;

  constructor(chart: IChartApi, series: ISeriesApi<SeriesType>, options?: Partial<TrendLineOptions>) {
    this._chart = chart;
    this._series = series;

    chart.subscribeClick(this.startDrawing);

    this._options = {
      ...defaultOptions,
      ...options,
    };
    this._paneViews = [new RayPaneView(this)];
  }

  private startDrawing = (param: MouseEventParams<Time>) => {
    this._chart.unsubscribeClick(this.startDrawing);

    if (!param.point) {
      throw new Error('[Drawings] - не удалось получить координаты точки');
    }

    const price = getPriceFromYCoordinate(this._series, param.point.y);
    const time = getTimeFromXCoordinate(this._chart, param.point.x);

    if (time == null || price == null) {
      throw new Error('[Drawings] - не удалось получить координаты точки');
    }

    this._p1 = { time, price };
    this._p2 = { time, price };

    this._minPrice = Math.min(this._p1.price, this._p2.price);
    this._maxPrice = Math.max(this._p1.price, this._p2.price);

    this._series.attachPrimitive(this);
    this._attached = true;

    this._chart.subscribeClick(this.endDrawing);
    this._chart.subscribeCrosshairMove(this.drawing);
    this.updateAllViews();
  };

  private drawing = (param: MouseEventParams<Time>) => {
    if (!param.point) {
      return;
    }

    const price = getPriceFromYCoordinate(this._series, param.point.y);
    const time = getTimeFromXCoordinate(this._chart, param.point.x);

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

    this._p2 = { time, price };

    this._minPrice = Math.min(this._p1.price, this._p2.price);
    this._maxPrice = Math.max(this._p1.price, this._p2.price);

    this.updateAllViews();
  };

  private endDrawing = (param: MouseEventParams<Time>) => {
    if (!param.point) {
      throw new Error('[Drawings] - не удалось получить координаты точки');
    }

    const price = getPriceFromYCoordinate(this._series, param.point.y);
    const time = getTimeFromXCoordinate(this._chart, param.point.x);

    if (time == null || price == null) {
      throw new Error('[Drawings] - не удалось получить координаты точки');
    }

    this._p2 = { time, price };

    this._minPrice = Math.min(this._p1.price, this._p2.price);
    this._maxPrice = Math.max(this._p1.price, this._p2.price);

    this._chart.unsubscribeCrosshairMove(this.drawing);
    this._chart.unsubscribeClick(this.endDrawing);

    this.updateAllViews();
  };

  public autoscaleInfo(startTimePoint: Logical, endTimePoint: Logical): AutoscaleInfo | null {
    const p1Index = this._pointIndex(this._p1);
    const p2Index = this._pointIndex(this._p2);

    if (p1Index === null || p2Index === null) {
      return null;
    }

    const fromIndex = Math.min(p1Index, p2Index);
    const toIndex = Math.max(p1Index, p2Index);

    if (endTimePoint < fromIndex || startTimePoint > toIndex) {
      return null;
    }

    return {
      priceRange: {
        minValue: this._minPrice,
        maxValue: this._maxPrice,
      },
    };
  }

  public updateAllViews() {
    this._paneViews.forEach((pw) => pw.update());
  }

  public paneViews() {
    return this._paneViews;
  }

  public _pointIndex(p: Point): number | null {
    const xCoordinate = getXCoordinateFromTime(this._chart, p.time);

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

    const index = this._chart.timeScale().coordinateToLogical(xCoordinate);
    return index;
  }

  public hide(): void {
    this._visible = false;
    this.updateAllViews();
  }

  public show(): void {
    this._visible = true;
    this.updateAllViews();
  }

  public rebind(series: ISeriesApi<SeriesType>): void {
    if (this._attached) {
      this._series.detachPrimitive(this);
    }

    this._series = series;
    this.updateAllViews();

    if (this._attached) {
      this._series.attachPrimitive(this);
    }
  }

  public destroy(): void {
    this._chart.unsubscribeClick(this.startDrawing);
    this._chart.unsubscribeClick(this.endDrawing);
    this._chart.unsubscribeCrosshairMove(this.drawing);

    if (this._attached) {
      this._series.detachPrimitive(this);
      this._attached = false;
    }
  }
}