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


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

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

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

const UI = {
  lineWidth: 1,
  fontSize: 10,
  lineHeight: 12,
  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 }) => {
      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 sideBoxHeightPx = UI.sideBoxHeight * verticalPixelRatio;
      const mainBoxHeightPx = UI.mainBoxHeight * verticalPixelRatio;
      const labelOffsetPx = UI.labelOffset * verticalPixelRatio;

      const targetLabelCenterY =
        targetY < entryY
          ? targetY - labelOffsetPx - sideBoxHeightPx / 2
          : targetY + labelOffsetPx + sideBoxHeightPx / 2;

      const stopLabelCenterY =
        stopY < entryY ? stopY - labelOffsetPx - sideBoxHeightPx / 2 : stopY + labelOffsetPx + sideBoxHeightPx / 2;

      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) {
        drawHandle(context, startX, entryY, horizontalPixelRatio, verticalPixelRatio, 'circle');
        drawHandle(context, endX, entryY, horizontalPixelRatio, verticalPixelRatio, 'rounded');
        drawHandle(context, startX, targetY, horizontalPixelRatio, verticalPixelRatio, 'rounded');
        drawHandle(context, startX, stopY, horizontalPixelRatio, verticalPixelRatio, 'rounded');
      }

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

        drawTextBox(
          context,
          centerX,
          entryY + labelOffsetPx + mainBoxHeightPx / 2,
          data.centerText,
          data.centerBoxColor,
          data.textColor,
          horizontalPixelRatio,
          verticalPixelRatio,
          UI.mainBoxHeight,
        );

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

      context.restore();
    });
  }
}

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,
  textColor: string,
  horizontalPixelRatio: number,
  verticalPixelRatio: number,
  fixedHeight: number,
): void {
  context.save();

  const lines = text.split('\n');
  const fontSize = UI.fontSize * verticalPixelRatio;
  const lineHeight = UI.lineHeight * verticalPixelRatio;
  const paddingX = UI.padding * horizontalPixelRatio;
  const boxHeight = fixedHeight * verticalPixelRatio;
  const radius = UI.boxRadius * Math.max(horizontalPixelRatio, verticalPixelRatio);

  context.font = `${fontSize}px Inter, sans-serif`;
  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 = 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 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 { getThemeStore } from '@src/theme';
import type { SettingField, SettingsTab, SettingsValues } from '@src/types/settings';

export interface SliderPositionStyle {
  lineColor: string;
  positiveFillColor: string;
  negativeFillColor: string;
  textColor: string;
}

export function createDefaultSliderPositionSettings(): SliderPositionStyle {
  const { colors } = getThemeStore();

  return {
    lineColor: colors.chartCrosshairLine,
    positiveFillColor: colors.sliderPositiveFill,
    negativeFillColor: colors.sliderNegativeFill,
    textColor: colors.chartPriceLineText,
  };
}

export function getSliderPositionSettingsTabs(settings: SliderPositionStyle): SettingsTab[] {
  const fields: SettingField[] = [
    {
      key: 'lineColor',
      label: 'Цвет линии',
      type: 'color',
      defaultValue: settings.lineColor,
    },
    {
      key: 'positiveFillColor',
      label: 'Цвет положительной зоны',
      type: 'color',
      defaultValue: settings.positiveFillColor,
    },
    {
      key: 'negativeFillColor',
      label: 'Цвет отрицательной зоны',
      type: 'color',
      defaultValue: settings.negativeFillColor,
    },
    {
      key: 'textColor',
      label: 'Цвет текста',
      type: 'color',
      defaultValue: settings.textColor,
    },
  ];

  return [
    {
      key: 'style',
      label: 'Стиль',
      fields,
    },
  ];
}

export function mergeSliderPositionSettings(settings?: Partial<SliderPositionStyle>): SliderPositionStyle {
  return {
    ...createDefaultSliderPositionSettings(),
    ...settings,
  };
}

export function isSliderPositionStyle(value: SettingsValues): value is SliderPositionStyle {
  return (
    typeof value.lineColor === 'string' &&
    typeof value.positiveFillColor === 'string' &&
    typeof value.negativeFillColor === 'string' &&
    typeof value.textColor === 'string'
  );
}