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


export interface SliderRenderData extends SliderGeometry, SliderPositionStyle, SliderPositionTextStyle {
  targetText: string;
  centerText: string;
  stopText: string;
  centerBoxColor: string;
  targetLabelDirection: 'up' | 'down';
  stopLabelDirection: 'up' | 'down';
  showFill: boolean;
  showHandles: boolean;
  showLabels: boolean;
}

public getRenderData(): SliderRenderData | null {
  if (this.hidden) {
    return null;
  }

  const geometry = this.getGeometry();

  if (!geometry) {
    return null;
  }

  const reward = Math.abs(geometry.targetPrice - geometry.entryPrice);
  const qty = reward > 0 ? this.amount / reward : 0;

  const selectedPrice = this.getPriceAtTime(this.endTime);
  const pnl =
    selectedPrice === null
      ? 0
      : this.side === 'long'
        ? (selectedPrice - geometry.entryPrice) * qty
        : (geometry.entryPrice - selectedPrice) * qty;

  const { colors } = getThemeStore();

  return {
    ...geometry,
    targetText: this.getTargetText(geometry),
    centerText: this.getCenterText(qty, pnl),
    stopText: this.getStopText(geometry),
    centerBoxColor: pnl >= 0 ? colors.chartCandleUp : colors.chartCandleDown,
    targetLabelDirection: this.side === 'long' ? 'up' : 'down',
    stopLabelDirection: this.side === 'long' ? 'down' : 'up',
    showFill: true,
    showHandles: this.shouldShowHandles(),
    showLabels: this.isSelected(),
    ...this.settings,
  };
}


private moveEntry(snapshot: SliderPositionState, point: Point): void {
  if (snapshot.stopPrice === null || snapshot.targetPrice === null) {
    return;
  }

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

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

  const minPrice = Math.min(snapshot.stopPrice, snapshot.targetPrice);
  const maxPrice = Math.max(snapshot.stopPrice, snapshot.targetPrice);

  this.entryPrice = Math.max(minPrice, Math.min(nextPrice, maxPrice));
}


private moveStop(snapshot: SliderPositionState, point: Point): void {
  if (snapshot.entryPrice === null) {
    return;
  }

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

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

  this.stopPrice = this.normalizeStop(
    snapshot.entryPrice,
    this.clampPriceToRange(nextPrice),
  );
}


private normalizeStop(entryPrice: number, rawPrice: number, minDistance = 0): number {
  const distance = this.getAllowedMinimumDistance(entryPrice, 'stop', minDistance);

  return this.side === 'long'
    ? Math.min(rawPrice, entryPrice - distance)
    : Math.max(rawPrice, entryPrice + distance);
}


private normalizeTarget(entryPrice: number, rawPrice: number, minDistance = 0): number {
  const distance = this.getAllowedMinimumDistance(entryPrice, 'target', minDistance);

  return this.side === 'long'
    ? Math.max(rawPrice, entryPrice + distance)
    : Math.min(rawPrice, entryPrice - distance);
}


private getCurrentRiskRewardRatio(): number {
  if (this.entryPrice === null || this.stopPrice === null || this.targetPrice === null) {
    return this.defaultRiskRewardRatio;
  }

  const risk = Math.abs(this.entryPrice - this.stopPrice);
  const reward = Math.abs(this.targetPrice - this.entryPrice);

  if (risk <= MIN_DISTANCE || reward <= MIN_DISTANCE) {
    return 0;
  }

  return reward / risk;
}


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

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

import type { SliderPositionTextStyle } from './settings';
import type { SliderPosition } from './sliderPosition';

const UI = {
  lineWidth: 1,
  lineHeightMultiplier: 1.2,
  mainBoxHeight: 28,
  sideBoxHeight: 16,
  padding: 4,
  boxRadius: 4,
  labelOffset: 10,
  handleSize: 10,
  handleRadius: 3,
  handleBorderWidth: 1,
};

export class SliderPaneRenderer implements IPrimitivePaneRenderer {
  private readonly slider: SliderPosition;

  constructor(slider: SliderPosition) {
    this.slider = slider;
  }

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

    if (!data) {
      return;
    }

    target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio, bitmapSize }) => {
      const startX = data.startX * horizontalPixelRatio;
      const endX = data.endX * horizontalPixelRatio;
      const left = data.leftX * horizontalPixelRatio;
      const right = data.rightX * horizontalPixelRatio;

      const entryY = data.entryY * verticalPixelRatio;
      const stopY = data.stopY * verticalPixelRatio;
      const targetY = data.targetY * verticalPixelRatio;

      const profitTop = data.profitTop * verticalPixelRatio;
      const profitBottom = data.profitBottom * verticalPixelRatio;
      const lossTop = data.lossTop * verticalPixelRatio;
      const lossBottom = data.lossBottom * verticalPixelRatio;

      const centerX = (left + right) / 2;
      const labelOffsetPx = UI.labelOffset * verticalPixelRatio;

      const targetBoxHeight = getTextBoxHeight(
        data.targetText,
        data,
        UI.sideBoxHeight,
        verticalPixelRatio,
      );

      const centerBoxHeight = getTextBoxHeight(
        data.centerText,
        data,
        UI.mainBoxHeight,
        verticalPixelRatio,
      );

      const stopBoxHeight = getTextBoxHeight(
        data.stopText,
        data,
        UI.sideBoxHeight,
        verticalPixelRatio,
      );

      const targetLabelCenterY = getOuterLabelCenterY(
        targetY,
        data.targetLabelDirection,
        targetBoxHeight,
        labelOffsetPx,
      );

      const stopLabelCenterY = getOuterLabelCenterY(
        stopY,
        data.stopLabelDirection,
        stopBoxHeight,
        labelOffsetPx,
      );

      const centerLabelCenterY = getCenterLabelCenterY(
        entryY,
        targetY,
        stopY,
        centerBoxHeight,
        labelOffsetPx,
        bitmapSize.height,
      );

      context.save();

      if (data.showFill) {
        context.fillStyle = data.positiveFillColor;
        context.fillRect(left, profitTop, right - left, profitBottom - profitTop);

        context.fillStyle = data.negativeFillColor;
        context.fillRect(left, lossTop, right - left, lossBottom - lossTop);
      }

      context.lineWidth = UI.lineWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
      context.strokeStyle = data.lineColor;

      drawHorizontalLine(context, left, right, entryY);

      if (data.showHandles) {
        const handleHeight = UI.handleSize * verticalPixelRatio;

        const visibleEntryY = clampVisibleHandleCoordinate(
          entryY,
          handleHeight,
          bitmapSize.height,
        );

        const visibleTargetY = clampVisibleHandleCoordinate(
          targetY,
          handleHeight,
          bitmapSize.height,
        );

        const visibleStopY = clampVisibleHandleCoordinate(
          stopY,
          handleHeight,
          bitmapSize.height,
        );

        drawHandle(
          context,
          startX,
          visibleEntryY,
          horizontalPixelRatio,
          verticalPixelRatio,
          'circle',
        );

        drawHandle(
          context,
          endX,
          visibleEntryY,
          horizontalPixelRatio,
          verticalPixelRatio,
          'rounded',
        );

        drawHandle(
          context,
          startX,
          visibleTargetY,
          horizontalPixelRatio,
          verticalPixelRatio,
          'rounded',
        );

        drawHandle(
          context,
          startX,
          visibleStopY,
          horizontalPixelRatio,
          verticalPixelRatio,
          'rounded',
        );
      }

      if (data.showLabels) {
        drawTextBox(
          context,
          centerX,
          targetLabelCenterY,
          data.targetText,
          data.positiveFillColor,
          data,
          horizontalPixelRatio,
          verticalPixelRatio,
          UI.sideBoxHeight,
        );

        drawTextBox(
          context,
          centerX,
          centerLabelCenterY,
          data.centerText,
          data.centerBoxColor,
          data,
          horizontalPixelRatio,
          verticalPixelRatio,
          UI.mainBoxHeight,
        );

        drawTextBox(
          context,
          centerX,
          stopLabelCenterY,
          data.stopText,
          data.negativeFillColor,
          data,
          horizontalPixelRatio,
          verticalPixelRatio,
          UI.sideBoxHeight,
        );
      }

      context.restore();
    });
  }
}

function getOuterLabelCenterY(
  coordinate: number,
  direction: 'up' | 'down',
  boxHeight: number,
  labelOffset: number,
): number {
  const offset = labelOffset + boxHeight / 2;

  return direction === 'up'
    ? coordinate - offset
    : coordinate + offset;
}

function getCenterLabelCenterY(
  entryY: number,
  targetY: number,
  stopY: number,
  boxHeight: number,
  labelOffset: number,
  viewportHeight: number,
): number {
  const targetSpace = Math.abs(targetY - entryY);
  const stopSpace = Math.abs(stopY - entryY);

  let direction: -1 | 1;

  if (targetSpace < stopSpace) {
    direction = stopY < entryY ? -1 : 1;
  } else {
    direction = targetY < entryY ? -1 : 1;
  }

  const centerY = entryY + direction * (labelOffset + boxHeight / 2);

  return clampTextBoxCenter(centerY, boxHeight, viewportHeight);
}

function clampTextBoxCenter(
  coordinate: number,
  boxHeight: number,
  viewportHeight: number,
): number {
  if (viewportHeight <= boxHeight) {
    return viewportHeight / 2;
  }

  const halfHeight = boxHeight / 2;

  return Math.max(
    halfHeight,
    Math.min(coordinate, viewportHeight - halfHeight),
  );
}

function clampVisibleHandleCoordinate(
  coordinate: number,
  size: number,
  viewportSize: number,
): number {
  const halfSize = size / 2;

  if (coordinate < -halfSize || coordinate > viewportSize + halfSize) {
    return coordinate;
  }

  return Math.max(
    halfSize,
    Math.min(coordinate, viewportSize - halfSize),
  );
}

function drawHorizontalLine(
  context: CanvasRenderingContext2D,
  left: number,
  right: number,
  y: number,
): void {
  context.beginPath();
  context.moveTo(left, y);
  context.lineTo(right, y);
  context.stroke();
}

function drawHandle(
  context: CanvasRenderingContext2D,
  x: number,
  y: number,
  horizontalPixelRatio: number,
  verticalPixelRatio: number,
  shape: 'circle' | 'rounded',
): void {
  const width = UI.handleSize * horizontalPixelRatio;
  const height = UI.handleSize * verticalPixelRatio;
  const left = x - width / 2;
  const top = y - height / 2;

  const { colors } = getThemeStore();

  context.save();

  context.fillStyle = colors.chartBackground;
  context.strokeStyle = colors.chartLineColor;
  context.lineWidth =
    UI.handleBorderWidth *
    Math.max(horizontalPixelRatio, verticalPixelRatio);

  context.beginPath();

  if (shape === 'circle') {
    context.arc(
      x,
      y,
      Math.min(width, height) / 2,
      0,
      Math.PI * 2,
    );
  } else {
    drawRoundedRect(
      context,
      left,
      top,
      width,
      height,
      UI.handleRadius *
        Math.max(horizontalPixelRatio, verticalPixelRatio),
    );
  }

  context.fill();
  context.stroke();

  context.restore();
}

function drawTextBox(
  context: CanvasRenderingContext2D,
  centerX: number,
  centerY: number,
  text: string,
  fillColor: string,
  textStyle: SliderPositionTextStyle,
  horizontalPixelRatio: number,
  verticalPixelRatio: number,
  fixedHeight: number,
): void {
  context.save();

  const lines = text.split('\n');
  const fontSize = textStyle.fontSize * verticalPixelRatio;

  const lineHeight =
    Math.round(textStyle.fontSize * UI.lineHeightMultiplier) *
    verticalPixelRatio;

  const paddingX = UI.padding * horizontalPixelRatio;

  const boxHeight = getTextBoxHeight(
    text,
    textStyle,
    fixedHeight,
    verticalPixelRatio,
  );

  const radius =
    UI.boxRadius *
    Math.max(horizontalPixelRatio, verticalPixelRatio);

  context.font = getTextFont(textStyle, fontSize);
  context.textAlign = 'center';
  context.textBaseline = 'middle';

  let maxTextWidth = 0;

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

  const width = maxTextWidth + paddingX * 2;
  const x = centerX - width / 2;
  const y = centerY - boxHeight / 2;

  context.fillStyle = fillColor;

  context.beginPath();
  drawRoundedRect(
    context,
    x,
    y,
    width,
    boxHeight,
    radius,
  );
  context.fill();

  context.fillStyle = textStyle.textColor;

  if (lines.length === 1) {
    context.fillText(
      lines[0],
      centerX,
      centerY,
    );

    context.restore();
    return;
  }

  const textBlockHeight = lines.length * lineHeight;

  const firstLineCenterY =
    centerY -
    textBlockHeight / 2 +
    lineHeight / 2;

  lines.forEach((line, index) => {
    context.fillText(
      line,
      centerX,
      firstLineCenterY + index * lineHeight,
    );
  });

  context.restore();
}

function getTextBoxHeight(
  text: string,
  textStyle: SliderPositionTextStyle,
  fixedHeight: number,
  verticalPixelRatio: number,
): number {
  const linesCount = text.split('\n').length;

  const lineHeight =
    Math.round(textStyle.fontSize * UI.lineHeightMultiplier) *
    verticalPixelRatio;

  const paddingY = UI.padding * verticalPixelRatio;
  const minBoxHeight = fixedHeight * verticalPixelRatio;

  return Math.max(
    minBoxHeight,
    linesCount * lineHeight + paddingY * 2,
  );
}

function getTextFont(
  style: SliderPositionTextStyle,
  fontSize: number,
): string {
  const italic = style.isItalic ? 'italic ' : '';
  const bold = style.isBold ? '700 ' : '';

  return `${italic}${bold}${fontSize}px Inter, sans-serif`;
}

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