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


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

import { Direction } from '@src/types';

import type { Ruler, RulerStyle } from './ruler';

import type { Bounds, Point } from '@core/Drawings/types';

export class RulerPaneRenderer implements IPrimitivePaneRenderer {
  private readonly ruler: Ruler;

  constructor(ruler: Ruler) {
    this.ruler = ruler;
  }

  public draw(target: CanvasRenderingTarget2D): void {
    const data = this.ruler.getRenderData();

    if (data.hidden || !data.startPoint || !data.endPoint) {
      return;
    }

    const bounds = getBounds(data.startPoint, data.endPoint);

    target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
      const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);

      const left = bounds.left * horizontalPixelRatio;
      const right = bounds.right * horizontalPixelRatio;
      const top = bounds.top * verticalPixelRatio;
      const bottom = bounds.bottom * verticalPixelRatio;

      const centerX = (left + right) / 2;
      const centerY = (top + bottom) / 2;

      context.save();

      context.fillStyle = data.fillColor;
      context.fillRect(left, top, right - left, bottom - top);

      context.lineWidth = data.style.lineWidth * pixelRatio;
      context.strokeStyle = data.lineColor;

      drawHorizontalArrow(context, left, right, centerY, 10 * pixelRatio, data.horizontalArrowSide);

      drawVerticalArrow(context, centerX, top, bottom, 10 * pixelRatio, data.verticalArrowSide);

      drawInfoBox(
        context,
        centerX,
        top - data.style.infoOffset * pixelRatio,
        data.infoLines,
        data.style,
        data.lineColor,
        data.textColor,
        pixelRatio,
        verticalPixelRatio,
      );

      context.restore();
    });
  }
}

function getBounds(startPoint: Point, endPoint: Point): Bounds {
  return {
    left: Math.min(startPoint.x, endPoint.x),
    right: Math.max(startPoint.x, endPoint.x),
    top: Math.min(startPoint.y, endPoint.y),
    bottom: Math.max(startPoint.y, endPoint.y),
  };
}

function drawHorizontalArrow(
  context: CanvasRenderingContext2D,
  left: number,
  right: number,
  y: number,
  size: number,
  side: Direction.Left | Direction.Right | null,
): void {
  context.beginPath();
  context.moveTo(left, y);
  context.lineTo(right, y);
  context.stroke();

  if (!side) {
    return;
  }

  context.beginPath();

  if (side === Direction.Left) {
    context.moveTo(left, y);
    context.lineTo(left + size, y - size);
    context.moveTo(left, y);
    context.lineTo(left + size, y + size);
  }

  if (side === Direction.Right) {
    context.moveTo(right, y);
    context.lineTo(right - size, y - size);
    context.moveTo(right, y);
    context.lineTo(right - size, y + size);
  }

  context.stroke();
}

function drawVerticalArrow(
  context: CanvasRenderingContext2D,
  x: number,
  top: number,
  bottom: number,
  size: number,
  side: Direction.Top | Direction.Bottom | null,
): void {
  context.beginPath();
  context.moveTo(x, top);
  context.lineTo(x, bottom);
  context.stroke();

  if (!side) {
    return;
  }

  context.beginPath();

  if (side === Direction.Top) {
    context.moveTo(x, top);
    context.lineTo(x - size, top + size);
    context.moveTo(x, top);
    context.lineTo(x + size, top + size);
  }

  if (side === Direction.Bottom) {
    context.moveTo(x, bottom);
    context.lineTo(x - size, bottom - size);
    context.moveTo(x, bottom);
    context.lineTo(x + size, bottom - size);
  }

  context.stroke();
}

function drawInfoBox(
  context: CanvasRenderingContext2D,
  centerX: number,
  topY: number,
  lines: readonly string[],
  style: Required<RulerStyle>,
  fillColor: string,
  textColor: string,
  pixelRatio: number,
  verticalPixelRatio: number,
): void {
  context.save();
  context.font = style.infoFont;
  context.textAlign = 'center';

  const padding = style.padding * pixelRatio;
  const lineHeight = 14 * verticalPixelRatio;
  const gap = 2 * verticalPixelRatio;

  let maxWidth = 0;

  for (const line of lines) {
    maxWidth = Math.max(maxWidth, context.measureText(line).width);
  }

  const boxWidth = maxWidth + padding * 2;
  const boxHeight = lines.length * lineHeight + (lines.length - 1) * gap + padding * 2;

  const boxX = centerX - boxWidth / 2;
  const boxY = topY - boxHeight;

  context.fillStyle = fillColor;
  context.beginPath();
  drawRoundedRect(context, boxX, boxY, boxWidth, boxHeight, 2 * pixelRatio);
  context.fill();

  context.fillStyle = textColor;

  let textY = boxY + padding + lineHeight * 0.8;

  for (const line of lines) {
    context.fillText(line, centerX, textY);
    textY += lineHeight + gap;
  }

  context.restore();
}

function drawRoundedRect(
  context: CanvasRenderingContext2D,
  x: number,
  y: number,
  width: number,
  height: number,
  radius: number,
): void {
  const safeRadius = Math.min(radius, width / 2, height / 2);

  context.moveTo(x + safeRadius, y);
  context.arcTo(x + width, y, x + width, y + height, safeRadius);
  context.arcTo(x + width, y + height, x, y + height, safeRadius);
  context.arcTo(x, y + height, x, y, safeRadius);
  context.arcTo(x, y, x + width, y, safeRadius);
  context.closePath();
}




import { Observable, skip, Subscription } from 'rxjs';

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';

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

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

import { ChartOptionsModel, Direction } from '@src/types';
import { Defaults } from '@src/types/defaults';
import { formatPrice, formatVolume } from '@src/utils';
import { formatDate } from '@src/utils/formatter';

import { RulerPaneView } from './paneView';

import type { ISeriesDrawing } from '@core/Drawings/common';
import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type {
  AutoscaleInfo,
  Coordinate,
  IChartApi,
  IPrimitivePaneView,
  ISeriesApi,
  ISeriesPrimitiveAxisView,
  Logical,
  MouseEventHandler,
  MouseEventParams,
  SeriesAttachedParameter,
  SeriesOptionsMap,
  Time,
  UTCTimestamp,
} from 'lightweight-charts';

type SeriesApi = ISeriesApi<keyof SeriesOptionsMap, Time>;
type RulerMode = 'idle' | 'placingEnd' | 'ready';

export interface RulerStyle {
  lineWidth?: number;
  infoFont?: string;
  padding?: number;
  infoOffset?: number;
  textAlign?: string;
}

interface RulerState {
  hidden: boolean;
  mode: RulerMode;
  startAnchor: Anchor | null;
  endAnchor: Anchor | null;
}

interface RulerParams {
  style?: RulerStyle;
  formatObservable?: Observable<ChartOptionsModel>;
  resetTriggers?: Observable<unknown>[];
  removeSelf?: () => void;
}

interface RulerRenderData {
  hidden: boolean;
  startPoint: Point | null;
  endPoint: Point | null;
  style: Required<RulerStyle>;
  lineColor: string;
  fillColor: string;
  textColor: string;
  infoLines: string[];
  horizontalArrowSide: Direction.Left | Direction.Right | null;
  verticalArrowSide: Direction.Top | Direction.Bottom | null;
}

const DEFAULT_STYLE: Required<RulerStyle> = {
  lineWidth: 2,
  infoFont: '12px Inter, sans-serif',
  padding: 4,
  infoOffset: 8,
  textAlign: 'center',
};

export class Ruler implements ISeriesDrawing {
  private chart: IChartApi;
  private series: SeriesApi;

  private requestUpdate: (() => void) | null = null;
  private removeSelf?: () => void;

  private subscriptions = new Subscription();

  private displayFormat: ChartOptionsModel = {
    dateFormat: Defaults.dateFormat,
    timeFormat: Defaults.timeFormat,
    showTime: Defaults.showTime,
  };

  private hidden = false;
  private mode: RulerMode = 'idle';

  private startAnchor: Anchor | null = null;
  private endAnchor: Anchor | null = null;

  private readonly style: Required<RulerStyle>;
  private readonly clickHandler: MouseEventHandler<Time>;
  private readonly moveHandler: MouseEventHandler<Time>;

  private isBound = false;

  private readonly paneView: RulerPaneView;
  private readonly timeAxisPaneView: CustomTimeAxisPaneView;
  private readonly priceAxisPaneView: CustomPriceAxisPaneView;
  private readonly startTimeAxisView: CustomTimeAxisView;
  private readonly endTimeAxisView: CustomTimeAxisView;
  private readonly startPriceAxisView: CustomPriceAxisView;
  private readonly endPriceAxisView: CustomPriceAxisView;

  constructor(
    chart: IChartApi,
    series: SeriesApi,
    { style = {}, resetTriggers = [], formatObservable, removeSelf }: RulerParams = {},
  ) {
    this.chart = chart;
    this.series = series;
    this.removeSelf = removeSelf;
    this.style = { ...DEFAULT_STYLE, ...style };

    this.clickHandler = (params) => this.handleClick(params);
    this.moveHandler = (params) => this.handleMove(params);

    this.paneView = new RulerPaneView(this);

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

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

    this.startTimeAxisView = new CustomTimeAxisView({
      getAxisLabel: (labelKind) => this.getTimeAxisLabel(labelKind),
      labelKind: 'start',
    });

    this.endTimeAxisView = new CustomTimeAxisView({
      getAxisLabel: (labelKind) => this.getTimeAxisLabel(labelKind),
      labelKind: 'end',
    });

    this.startPriceAxisView = new CustomPriceAxisView({
      getAxisLabel: (labelKind) => this.getPriceAxisLabel(labelKind),
      labelKind: 'start',
    });

    this.endPriceAxisView = new CustomPriceAxisView({
      getAxisLabel: (labelKind) => this.getPriceAxisLabel(labelKind),
      labelKind: 'end',
    });

    if (formatObservable) {
      this.subscriptions.add(
        formatObservable.subscribe((format) => {
          this.displayFormat = format;
          this.render();
        }),
      );
    }

    resetTriggers.forEach((trigger) => {
      this.subscriptions.add(
        trigger.pipe(skip(1)).subscribe(() => {
          this.removeSelf?.();
        }),
      );
    });

    this.series.attachPrimitive(this);
  }

  public show(): void {
    this.hidden = false;
    this.render();
  }

  public hide(): void {
    this.hidden = true;
    this.render();
  }

  public destroy(): void {
    this.setCrosshairVisible(true);
    this.unbindEvents();
    this.subscriptions.unsubscribe();
    this.series.detachPrimitive(this);
    this.requestUpdate = null;
  }

  public rebind(series: SeriesApi): void {
    if (this.series === series) {
      return;
    }

    this.unbindEvents();
    this.series.detachPrimitive(this);

    this.series = series;
    this.requestUpdate = null;

    this.series.attachPrimitive(this);
    this.render();
  }

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

  public shouldShowInObjectTree(): boolean {
    return this.mode !== 'idle';
  }

  public getState(): RulerState {
    return {
      hidden: this.hidden,
      mode: this.mode,
      startAnchor: this.startAnchor,
      endAnchor: this.endAnchor,
    };
  }

  public setState(state: unknown): void {
    const nextState = state as Partial<RulerState>;

    this.hidden = nextState.hidden ?? this.hidden;
    this.mode = nextState.mode ?? this.mode;

    this.startAnchor = nextState.startAnchor ?? this.startAnchor;
    this.endAnchor = nextState.endAnchor ?? this.endAnchor;

    this.render();
  }

  public attached(param: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
    this.requestUpdate = param.requestUpdate;
    this.bindEvents();
  }

  public detached(): void {
    this.setCrosshairVisible(true);
    this.unbindEvents();
    this.requestUpdate = null;
  }

  public updateAllViews(): void {
    this.paneView.update();
    this.timeAxisPaneView.update();
    this.priceAxisPaneView.update();
    this.startTimeAxisView.update();
    this.endTimeAxisView.update();
    this.startPriceAxisView.update();
    this.endPriceAxisView.update();
  }

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

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

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

  public timeAxisViews(): readonly ISeriesPrimitiveAxisView[] {
    return [this.startTimeAxisView, this.endTimeAxisView];
  }

  public priceAxisViews(): readonly ISeriesPrimitiveAxisView[] {
    return [this.startPriceAxisView, this.endPriceAxisView];
  }

  public autoscaleInfo(startTimePoint: Logical, endTimePoint: Logical): AutoscaleInfo | null {
    if (this.hidden || this.mode === 'placingEnd') {
      return null;
    }

    if (!this.startAnchor || !this.endAnchor) {
      return null;
    }

    const startCoordinate = getXCoordinateFromTime(this.chart, this.startAnchor.time);
    const endCoordinate = getXCoordinateFromTime(this.chart, this.endAnchor.time);

    if (startCoordinate === null || endCoordinate === null) {
      return null;
    }

    const startLogical = this.chart.timeScale().coordinateToLogical(startCoordinate as Coordinate);
    const endLogical = this.chart.timeScale().coordinateToLogical(endCoordinate as Coordinate);

    if (startLogical === null || endLogical === null) {
      return null;
    }

    const leftLogical = Math.min(Number(startLogical), Number(endLogical));
    const rightLogical = Math.max(Number(startLogical), Number(endLogical));

    if (endTimePoint < leftLogical || startTimePoint > rightLogical) {
      return null;
    }

    return {
      priceRange: {
        minValue: Math.min(Number(this.startAnchor.price), Number(this.endAnchor.price)),
        maxValue: Math.max(Number(this.startAnchor.price), Number(this.endAnchor.price)),
      },
    };
  }

  public getRenderData(): RulerRenderData {
    const startPoint = this.startAnchor ? this.getPoint(this.startAnchor) : null;
    const endPoint = this.endAnchor ? this.getPoint(this.endAnchor) : null;

    const startPrice = this.startAnchor ? Number(this.startAnchor.price) : 0;
    const endPrice = this.endAnchor ? Number(this.endAnchor.price) : 0;
    const priceDiff = endPrice - startPrice;
    const percentDiff = startPrice !== 0 ? (priceDiff / startPrice) * 100 : null;

    const startIndex = this.startAnchor ? this.findIndexByTime(this.startAnchor.time) : -1;
    const endIndex = this.endAnchor ? this.findIndexByTime(this.endAnchor.time) : -1;

    const barsCount = startIndex >= 0 && endIndex >= 0 ? Math.abs(endIndex - startIndex) : 0;
    const volume = this.getVolumeInRange();
    const isLong = priceDiff >= 0;

    const horizontalArrowSide =
      startPoint && endPoint && startPoint.x !== endPoint.x
        ? endPoint.x > startPoint.x
          ? Direction.Right
          : Direction.Left
        : null;

    const verticalArrowSide =
      startPoint && endPoint && startPoint.y !== endPoint.y
        ? endPoint.y > startPoint.y
          ? Direction.Bottom
          : Direction.Top
        : null;

    const { colors } = getThemeStore();

    return {
      hidden: this.hidden,
      startPoint,
      endPoint,
      style: this.style,
      lineColor: isLong ? colors.chartLineColor : colors.chartLineColorAlternative,
      fillColor: isLong ? colors.rulerPositiveFill : colors.rulerNegativeFill,
      textColor: colors.chartPriceLineText,
      infoLines: [
        `${formatPrice(Math.abs(priceDiff))} (${percentDiff === null ? '-' : `${formatPrice(Math.abs(percentDiff))}%`})`,
        `${barsCount} bars,`,
        `Vol ${formatVolume(volume)}`,
      ],
      horizontalArrowSide,
      verticalArrowSide,
    };
  }

  public getTimeCoordinate(kind: 'start' | 'end'): Coordinate | null {
    const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;

    if (!anchor) {
      return null;
    }

    return getXCoordinateFromTime(this.chart, anchor.time);
  }

  public getPriceCoordinate(kind: 'start' | 'end'): Coordinate | null {
    const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;

    if (!anchor) {
      return null;
    }

    return getYCoordinateFromPrice(this.series, anchor.price);
  }

  public getTimeBounds(): { left: number; right: number } | null {
    const startX = this.getTimeCoordinate('start');
    const endX = this.getTimeCoordinate('end');

    if (startX === null || endX === null) {
      return null;
    }

    return {
      left: Math.min(startX, endX),
      right: Math.max(startX, endX),
    };
  }

  public getPriceBounds(): { top: number; bottom: number } | null {
    const startY = this.getPriceCoordinate('start');
    const endY = this.getPriceCoordinate('end');

    if (startY === null || endY === null) {
      return null;
    }

    return {
      top: Math.min(startY, endY),
      bottom: Math.max(startY, endY),
    };
  }

  public getTimeText(kind: 'start' | 'end'): string {
    const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;

    if (!anchor || typeof anchor.time !== 'number') {
      return '';
    }

    return formatDate(
      anchor.time as UTCTimestamp,
      this.displayFormat.dateFormat,
      this.displayFormat.timeFormat,
      this.displayFormat.showTime,
    );
  }

  public getPriceText(kind: 'start' | 'end'): string {
    const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;

    if (!anchor) {
      return '';
    }

    return formatPrice(Number(anchor.price)) ?? '';
  }

  public getTimeAxisSegments(): AxisSegment[] {
    const bounds = this.getTimeBounds();

    if (!bounds) {
      return [];
    }

    const { colors } = getThemeStore();

    return [
      {
        from: bounds.left,
        to: bounds.right,
        color: colors.axisMarkerAreaFill,
      },
    ];
  }

  public getPriceAxisSegments(): AxisSegment[] {
    const bounds = this.getPriceBounds();

    if (!bounds) {
      return [];
    }

    const { colors } = getThemeStore();

    return [
      {
        from: bounds.top,
        to: bounds.bottom,
        color: colors.axisMarkerAreaFill,
      },
    ];
  }

  public getTimeAxisLabel(kind: string): AxisLabel | null {
    if (kind !== 'start' && kind !== 'end') {
      return null;
    }

    const coordinate = this.getTimeCoordinate(kind);
    const text = this.getTimeText(kind);

    if (coordinate === null || !text) {
      return null;
    }

    const { colors } = getThemeStore();

    return {
      coordinate,
      text,
      textColor: colors.chartPriceLineText,
      backgroundColor: colors.axisMarkerLabelFill,
    };
  }

  public getPriceAxisLabel(kind: string): AxisLabel | null {
    if (kind !== 'start' && kind !== 'end') {
      return null;
    }

    const coordinate = this.getPriceCoordinate(kind);
    const text = this.getPriceText(kind);

    if (coordinate === null || !text) {
      return null;
    }

    const { colors } = getThemeStore();

    return {
      coordinate,
      text,
      textColor: colors.chartPriceLineText,
      backgroundColor: colors.axisMarkerLabelFill,
    };
  }

  private findIndexByTime(time: Time): number {
    const data = this.series.data() ?? [];

    return data.findIndex((item) => {
      if (typeof item.time === 'number' && typeof time === 'number') {
        return item.time === time;
      }

      return false;
    });
  }

  private setCrosshairVisible(visible: boolean): void {
    this.chart.applyOptions({
      crosshair: {
        vertLine: {
          visible,
          labelVisible: visible,
        },
        horzLine: {
          visible,
          labelVisible: visible,
        },
      },
    });
  }

  private bindEvents(): void {
    if (this.isBound) {
      return;
    }

    this.isBound = true;
    this.chart.subscribeClick(this.clickHandler);
    this.chart.subscribeCrosshairMove(this.moveHandler);
  }

  private unbindEvents(): void {
    if (!this.isBound) {
      return;
    }

    this.isBound = false;
    this.chart.unsubscribeClick(this.clickHandler);
    this.chart.unsubscribeCrosshairMove(this.moveHandler);
  }

  private render(): void {
    this.updateAllViews();
    this.requestUpdate?.();
  }

  private handleClick(params: MouseEventParams<Time>): void {
    if (this.hidden || !params.point) {
      return;
    }

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

    const anchor = this.createAnchor(params);

    if (!anchor) {
      return;
    }

    if (this.mode === 'idle') {
      this.startAnchor = anchor;
      this.endAnchor = anchor;
      this.mode = 'placingEnd';

      this.setCrosshairVisible(false);
      this.render();
      return;
    }

    if (this.mode === 'placingEnd') {
      this.endAnchor = anchor;
      this.mode = 'ready';

      this.setCrosshairVisible(true);
      this.render();
    }
  }

  private handleMove(params: MouseEventParams<Time>): void {
    if (this.hidden || !params.point) {
      return;
    }

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

    const anchor = this.createAnchor(params);

    if (!anchor) {
      return;
    }

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

  private createAnchor({ time, point }: MouseEventParams<Time>): Anchor | null {
    if (!point || time === undefined) {
      return null;
    }

    const price = getPriceFromYCoordinate(this.series, point.y);

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

    return {
      price,
      time,
    };
  }

  private getPoint(anchor: Anchor): Point | null {
    const x = getXCoordinateFromTime(this.chart, anchor.time);
    const y = getYCoordinateFromPrice(this.series, anchor.price);

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

    return {
      x,
      y,
    };
  }

  private getVolumeInRange(): number {
    if (!this.startAnchor || !this.endAnchor) {
      return 0;
    }

    const data = this.series.data() ?? [];

    if (!data.length) {
      return 0;
    }

    const startIndex = this.findIndexByTime(this.startAnchor.time);
    const endIndex = this.findIndexByTime(this.endAnchor.time);

    if (startIndex < 0 || endIndex < 0) {
      return 0;
    }

    const from = Math.min(startIndex, endIndex);
    const to = Math.max(startIndex, endIndex);

    let volume = 0;

    for (let index = from; index <= to; index += 1) {
      const item = data.at(index) as Record<string, unknown> | undefined;

      if (!item) {
        continue;
      }

      if (typeof item.volume === 'number') {
        volume += item.volume;
        continue;
      }

      const customValues = item.customValues as Record<string, unknown> | undefined;

      if (customValues && typeof customValues.volume === 'number') {
        volume += customValues.volume;
      }
    }

    return volume;
  }
}