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


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

import type { ViewPoint } from '../common';
import type { TrendLineOptions } from './trendLine';

interface TrendLinePaneRendererParams {
  p1: ViewPoint;
  p2: ViewPoint;
  text1: string;
  text2: string;
  options: TrendLineOptions;
  visible: boolean;
}

export class TrendLinePaneRenderer implements IPrimitivePaneRenderer {
  private _p1: ViewPoint;
  private _p2: ViewPoint;
  private _text1: string;
  private _text2: string;
  private _options: TrendLineOptions;
  private _visible: boolean;

  constructor({ p1, p2, text1, text2, options, visible }: TrendLinePaneRendererParams) {
    this._p1 = p1;
    this._p2 = p2;
    this._text1 = text1;
    this._text2 = text2;
    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 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);

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

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

  private _drawTextLabel(
    scope: BitmapCoordinatesRenderingScope,
    text: string,
    x: number,
    y: number,
    left: boolean,
  ): void {
    scope.context.font = '24px Arial';
    scope.context.beginPath();

    const offset = 5 * scope.horizontalPixelRatio;
    const textWidth = scope.context.measureText(text);
    const leftAdjustment = left ? textWidth.width + offset * 4 : 0;
    const padding = 24;

    scope.context.fillStyle = this._options.labelBackgroundColor;
    scope.context.roundRect(
      x + offset - leftAdjustment,
      y - padding,
      textWidth.width + offset * 2,
      padding + offset,
      5,
    );
    scope.context.fill();

    scope.context.beginPath();
    scope.context.fillStyle = this._options.labelTextColor;
    scope.context.save();
    scope.context.font = '14px serif';
    scope.context.fillText(text, x + offset * 2 - leftAdjustment, y);
    scope.context.restore();
  }
}


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

import { getXCoordinateFromTime, getYCoordinateFromPrice } from '../helpers';

import { TrendLinePaneRenderer } from './paneRenderer';

import type { ViewPoint } from '../common';
import type { TrendLine } from './trendLine';

export class TrendLinePaneView implements IPrimitivePaneView {
  _source: TrendLine;
  _p1: ViewPoint = { x: null, y: null };
  _p2: ViewPoint = { x: null, y: null };

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

  public update(): void {
    if (!this._source._p1 || !this._source._p2) {
      return;
    }

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

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

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

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


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

import { ISeriesDrawing, Point } from '../common';
import { getPriceFromYCoordinate, getTimeFromXCoordinate, getXCoordinateFromTime } from '../helpers';

import { TrendLinePaneView } from './paneView';

export interface TrendLineOptions {
  lineColor: string;
  width: number;
  showLabels: boolean;
  labelBackgroundColor: string;
  labelTextColor: string;
}

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

export class TrendLine implements ISeriesDrawing {
  private _attached = false;

  _chart: IChartApi;
  _series: ISeriesApi<keyof SeriesOptionsMap>;
  _p1!: Point;
  _p2!: Point;
  _paneViews: TrendLinePaneView[];
  _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 TrendLinePaneView(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;
    }
  }
}