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


import { CanvasRenderingTarget2D } from 'fancy-canvas';

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

import { ISeriesDrawing, Point, positionsBox } from '@core/Drawings/common';

interface VolumeProfileItem {
  y: Coordinate | null;
  width: number;
}

interface VolumeProfileRendererData {
  x: Coordinate | null;
  top: Coordinate | null;
  columnHeight: number;
  width: number;
  items: VolumeProfileItem[];
}

interface VolumeProfileDataPoint {
  price: number;
  vol: number;
}

export interface VolumeProfileData {
  time: Time;
  profile: VolumeProfileDataPoint[];
  width: number;
}
class VolumeProfileRenderer implements IPrimitivePaneRenderer {
  _data: VolumeProfileRendererData;

  constructor(data: VolumeProfileRendererData) {
    this._data = data;
  }

  draw(target: CanvasRenderingTarget2D) {
    target.useBitmapCoordinateSpace((scope) => {
      if (this._data.x === null || this._data.top === null) return;
      const ctx = scope.context;
      const horizontalPositions = positionsBox(
        this._data.x,
        this._data.x + this._data.width,
        scope.horizontalPixelRatio,
      );
      const verticalPositions = positionsBox(
        this._data.top,
        this._data.top - this._data.columnHeight * this._data.items.length,
        scope.verticalPixelRatio,
      );
      ctx.fillStyle = 'rgba(0, 0, 255, 0.2)';
      ctx.fillRect(
        horizontalPositions.position,
        verticalPositions.position,
        horizontalPositions.length,
        verticalPositions.length,
      );
      ctx.fillStyle = 'rgba(80, 80, 255, 0.8)';
      this._data.items.forEach((row) => {
        if (row.y === null) return;
        const itemVerticalPos = positionsBox(row.y, row.y - this._data.columnHeight, scope.verticalPixelRatio);
        const itemHorizontalPos = positionsBox(this._data.x!, this._data.x! + row.width, scope.horizontalPixelRatio);
        ctx.fillRect(
          itemHorizontalPos.position,
          itemVerticalPos.position,
          itemHorizontalPos.length,
          itemVerticalPos.length - 2, // 1 to close gaps
        );
      });
    });
  }
}

class VolumeProfilePaneView implements IPrimitivePaneView {
  _source: VolumeProfile;
  _x: Coordinate | null = null;
  _width = 6;
  _columnHeight = 0;
  _top: Coordinate | null = null;
  _items: VolumeProfileItem[] = [];

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

  update() {
    const data = this._source._vpData;
    const series = this._source._series;
    const timeScale = this._source._chart.timeScale();
    this._x = timeScale.timeToCoordinate(data.time);

    const left = timeScale.timeToCoordinate(
      Math.max(<number>this._source._p1.time, <number>this._source._p2.time) as Time,
    );
    const right = timeScale.timeToCoordinate(
      Math.min(<number>this._source._p1.time, <number>this._source._p2.time) as Time,
    );

    if (left === null || right === null) {
      return;
    }

    this._width = left - right;

    const y1 = series.priceToCoordinate(this._source._minPrice) ?? (0 as Coordinate);
    const y2 = series.priceToCoordinate(data.profile[1].price) ?? (timeScale.height() as Coordinate);

    this._columnHeight = Math.max(1, y1 - y2);
    const maxVolume = data.profile.reduce((acc, item) => Math.max(acc, item.vol), 0);
    this._top = y1;
    this._items = data.profile.map((row) => ({
      y: series.priceToCoordinate(row.price),
      width: (this._width * row.vol) / maxVolume,
    }));
  }

  renderer() {
    return new VolumeProfileRenderer({
      x: this._x,
      top: this._top,
      columnHeight: this._columnHeight,
      width: this._width,
      items: this._items,
    });
  }
}

export class VolumeProfile implements ISeriesDrawing {
  private _attached = false;

  _chart: IChartApi;
  _series: ISeriesApi<keyof SeriesOptionsMap>;
  _vpData!: VolumeProfileData;
  _minPrice!: number;
  _maxPrice!: number;
  _paneViews: VolumeProfilePaneView[];
  _visible = true;
  _p1!: Point;
  _p2!: Point;
  _visibleData: CandlestickData<Time>[] = []; // todo: неверный тип, мы должны уметь строить по разным типа серии

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

    const logRange = this._chart.timeScale().getVisibleLogicalRange();

    const f = Math.floor(logRange?.from ?? 0);
    const t = Math.ceil(logRange?.to ?? 0);

    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    this._visibleData = this._series.data().slice(f, t + 1);

    this._chart.timeScale().subscribeVisibleLogicalRangeChange((logicalRange) => {
      if (!logicalRange) return;

      const from = Math.floor(logicalRange.from);
      const to = Math.ceil(logicalRange.to);

      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
      // @ts-ignore
      this._visibleData = this._series.data().slice(from, to + 1);

      this.calcProfile();
    });

    chart.subscribeClick(this.startDrawing);

    this._paneViews = [new VolumeProfilePaneView(this)];
  }

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

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

    const price = this._series.coordinateToPrice(param.point.y);
    const time = this._chart.timeScale().coordinateToTime(param.point.x);

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

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

    this.calcProfile();

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

    this._chart.subscribeClick(this.endDrawing);

    this._chart.subscribeCrosshairMove(this.drawing);
  };

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

    const price = this._series.coordinateToPrice(param.point.y);
    const time = this._chart.timeScale().coordinateToTime(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.calcProfile();
    this.updateAllViews();
  };

  private endDrawing = (param: MouseEventParams) => {
    if (!param.point) {
      throw new Error('[Drawings] - не удалось получить координаты точки');
    }
    const price = this._series.coordinateToPrice(param.point.y);
    const time = this._chart.timeScale().coordinateToTime(param.point.x);

    if (!time || !price) {
      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.calcProfile();

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

  private calcProfile = () => {
    const leftFrameTime = Math.min(<number>this._p1.time, <number>this._p2.time);
    const rightFrameTime = Math.max(<number>this._p1.time, <number>this._p2.time);

    let maxFramePrice = 0;
    let minFramePrice = Infinity;

    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    const data = [];

    this._series.data().forEach((candle) => {
      if (<number>candle.time < rightFrameTime && <number>candle.time > leftFrameTime) {
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        maxFramePrice = Math.max(candle.close ?? candle.value, maxFramePrice); // todo: обработать разные серии
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        minFramePrice = Math.min(candle.close ?? candle.value, minFramePrice); // todo: обработать разные серии

        data.push(candle);
      }
    });

    this._minPrice = minFramePrice;
    this._maxPrice = maxFramePrice;
    const priceStep = Math.abs(maxFramePrice - minFramePrice) / 15;

    const profile: { price: number; vol: number }[] = [];

    for (let i = 0; i < 15; i++) {
      profile.push({
        price: this._minPrice + i * priceStep,
        vol: 0,
      });
    }

    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    data.forEach((candle) => {
      const candleVolume = (candle.customValues?.volume ?? 0) as number;

      const isCandleOutOfRange = candle.high < this._minPrice || candle.low > this._maxPrice;

      if (isCandleOutOfRange) return;

      const topPriceEnhanced = Math.min(this._maxPrice, candle.high);
      const lowPriceEnhanced = Math.max(this._minPrice, candle.low);

      const inRangePartLength = topPriceEnhanced - lowPriceEnhanced;

      const inRangeVolume = candleVolume * (inRangePartLength / (candle.high - candle.low));

      const parts = Math.ceil((inRangePartLength / (this._maxPrice - this._minPrice)) * 15);

      const volumeIterations = inRangeVolume / parts;

      for (let i = 0; i < 15; i++) {
        const lowPrice = this._minPrice + priceStep * i;
        const highPrice = this._minPrice + priceStep * i + 1;

        const isInRange = !(lowPrice > topPriceEnhanced || highPrice < lowPriceEnhanced);

        if (isInRange) {
          profile[i].vol += volumeIterations;
        }
      }
    });

    this._vpData = {
      time: Math.min(<number>this._p2.time, <number>this._p1.time) as Time,
      profile,
      width: 10,
    };
  };

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

  // Ensures that the VP is within autoScale
  autoscaleInfo(startTimePoint: Logical, endTimePoint: Logical): AutoscaleInfo | null {
    // calculation of vpIndex could be remembered to reduce CPU usage
    // and only recheck if the data is changed ('full' update).
    const vpCoordinate = this._chart.timeScale().timeToCoordinate(this._vpData.time);

    if (vpCoordinate === null) return null;

    const vpIndex = this._chart.timeScale().coordinateToLogical(vpCoordinate);

    if (vpIndex === null) return null;
    if (endTimePoint < vpIndex || startTimePoint > vpIndex + this._vpData.width) return null;

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

  paneViews() {
    return this._paneViews;
  }

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

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

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

    this._series = series;

    this.updateAllViews();

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