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


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

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

import type { VolumeProfileRendererData } from './volumeProfile';

export class VolumeProfilePaneRenderer implements IPrimitivePaneRenderer {
  _data: VolumeProfileRendererData;

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

  public draw(target: CanvasRenderingTarget2D): void {
    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 itemVerticalPositions = positionsBox(row.y, row.y - this._data.columnHeight, scope.verticalPixelRatio);

        const itemHorizontalPositions = positionsBox(
          this._data.x!,
          this._data.x! + row.width,
          scope.horizontalPixelRatio,
        );

        ctx.fillRect(
          itemHorizontalPositions.position,
          itemVerticalPositions.position,
          itemHorizontalPositions.length,
          itemVerticalPositions.length - 2,
        );
      });
    });
  }
}



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

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

import { VolumeProfilePaneRenderer } from './paneRenderer';

import type { VolumeProfile, VolumeProfileItem, VolumeProfileRendererData } from './volumeProfile';

export 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;
  }

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

    const data = this._source._vpData;

    this._x = getXCoordinateFromTime(this._source._chart, data.time);

    const left = getXCoordinateFromTime(
      this._source._chart,
      Math.max(Number(this._source._p1.time), Number(this._source._p2.time)) as typeof data.time,
    );

    const right = getXCoordinateFromTime(
      this._source._chart,
      Math.min(Number(this._source._p1.time), Number(this._source._p2.time)) as typeof data.time,
    );

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

    this._width = Math.abs(left - right);

    const firstPrice = data.profile[0]?.price ?? this._source._minPrice;
    const secondPrice = data.profile[1]?.price ?? firstPrice;

    const y1 = getYCoordinateFromPrice(this._source._series, this._source._minPrice);
    const y2 = getYCoordinateFromPrice(this._source._series, secondPrice);

    if (y1 === null || y2 === null) {
      return;
    }

    this._columnHeight = Math.max(1, Math.abs(y1 - y2));
    this._top = y1;

    const maxVolume = data.profile.reduce((max, item) => Math.max(max, item.vol), 0);

    this._items = data.profile.map((row) => ({
      y: getYCoordinateFromPrice(this._source._series, row.price),
      width: maxVolume === 0 ? 0 : (this._width * row.vol) / maxVolume,
    }));
  }

  public renderer(): IPrimitivePaneRenderer {
    const rendererData: VolumeProfileRendererData = {
      x: this._x,
      top: this._top,
      columnHeight: this._columnHeight,
      width: this._width,
      items: this._items,
    };

    return new VolumeProfilePaneRenderer(rendererData);
  }
}



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

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

import { VolumeProfilePaneView } from './paneView';

interface SeriesCandleData {
  time: Time;
  high?: number;
  low?: number;
  close?: number;
  value?: number;
  customValues?: {
    volume?: number;
  };
}

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

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

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

export interface VolumeProfileData {
  time: Time;
  profile: VolumeProfileDataPoint[];
  width: number;
}

const PROFILE_ROW_COUNT = 15;

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;

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

    this._chart.timeScale().subscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);
    this._chart.subscribeClick(this.startDrawing);

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

  private handleVisibleLogicalRangeChange = (logicalRange: LogicalRange | null) => {
    if (!logicalRange) {
      return;
    }

    if (!this._p1 || !this._p2 || !this._attached) {
      return;
    }

    this.calcProfile();
    this.updateAllViews();
  };

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

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

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

    this.updateAllViews();
  };

  private calcProfile = () => {
    if (!this._p1 || !this._p2) {
      return;
    }

    const leftFrameTime = Math.min(Number(this._p1.time), Number(this._p2.time));
    const rightFrameTime = Math.max(Number(this._p1.time), Number(this._p2.time));

    const candles = this._series.data() as SeriesCandleData[];
    const selectedCandles: SeriesCandleData[] = [];

    let maxFramePrice = Math.max(this._p1.price, this._p2.price);
    let minFramePrice = Math.min(this._p1.price, this._p2.price);

    candles.forEach((candle) => {
      const candleTime = Number(candle.time);

      if (candleTime > leftFrameTime && candleTime < rightFrameTime) {
        const candlePrice = candle.close ?? candle.value ?? 0;

        maxFramePrice = Math.max(candlePrice, maxFramePrice);
        minFramePrice = Math.min(candlePrice, minFramePrice);

        selectedCandles.push(candle);
      }
    });

    if (maxFramePrice === minFramePrice) {
      maxFramePrice = minFramePrice + Math.max(Math.abs(minFramePrice) * 0.001, 1);
    }

    this._minPrice = minFramePrice;
    this._maxPrice = maxFramePrice;

    const priceRange = this._maxPrice - this._minPrice;
    const priceStep = priceRange / PROFILE_ROW_COUNT;

    const profile: VolumeProfileDataPoint[] = [];

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

    selectedCandles.forEach((candle) => {
      const candleVolume = candle.customValues?.volume ?? 0;
      const candleHigh = candle.high ?? candle.close ?? candle.value ?? this._maxPrice;
      const candleLow = candle.low ?? candle.close ?? candle.value ?? this._minPrice;

      const isCandleOutOfRange = candleHigh < this._minPrice || candleLow > this._maxPrice;

      if (isCandleOutOfRange) {
        return;
      }

      const topPriceInRange = Math.min(this._maxPrice, candleHigh);
      const lowPriceInRange = Math.max(this._minPrice, candleLow);
      const inRangePartLength = topPriceInRange - lowPriceInRange;

      const candleRange = candleHigh - candleLow;
      const safeCandleRange = candleRange === 0 ? priceStep : candleRange;
      const inRangeVolume = candleVolume * (inRangePartLength / safeCandleRange);

      const parts = Math.max(1, Math.ceil((inRangePartLength / priceRange) * PROFILE_ROW_COUNT));

      const volumePerPart = inRangeVolume / parts;

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

        const isInRange = !(lowPrice > topPriceInRange || highPrice < lowPriceInRange);

        if (isInRange) {
          profile[index].vol += volumePerPart;
        }
      }
    });

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

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

  public autoscaleInfo(startTimePoint: Logical, endTimePoint: Logical): AutoscaleInfo | null {
    if (!this._vpData) {
      return null;
    }

    const vpCoordinate = getXCoordinateFromTime(this._chart, 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,
      },
    };
  }

  public paneViews() {
    return this._paneViews;
  }

  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);
    this._chart.timeScale().unsubscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);

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