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


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

import { getThemeStore } from '@src/theme/store';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';

export type PriceLabelKind = 'current' | 'historical';

export interface PriceLabelState {
  price: number | null;
  value: string;
  symbol?: string;
  color: string;
  kind: PriceLabelKind;
  visible: boolean;
}

interface PriceLabelSeries {
  priceToCoordinate(price: number): Coordinate | null;

  options(): {
    priceScaleId?: string;
  };
}

interface PriceLabelViewState extends PriceLabelState {
  coordinate: Coordinate | null;
}

const LABEL_HEIGHT = 20;
const LABEL_FONT_SIZE = 11;
const LABEL_MIN_FONT_SIZE = 9;
const LABEL_HORIZONTAL_PADDING = 5;
const LABEL_SECTION_GAP = 4;
const LABEL_SEPARATOR_HEIGHT = 12;
const LABEL_MIN_WIDTH = 34;
const LABEL_BORDER_WIDTH = 1;
const LABEL_AXIS_INSET = 1;

const FONT_FAMILY = 'Inter, sans-serif';
const SYMBOL_FONT_WEIGHT = 600;
const VALUE_FONT_WEIGHT = 500;

function getHexRgb(color: string): [number, number, number] | null {
  const normalizedColor = removeAlphaFromHex(color).replace('#', '');

  if (normalizedColor.length !== 6) {
    return null;
  }

  const red = Number.parseInt(normalizedColor.slice(0, 2), 16);
  const green = Number.parseInt(normalizedColor.slice(2, 4), 16);
  const blue = Number.parseInt(normalizedColor.slice(4, 6), 16);

  if (
    Number.isNaN(red) ||
    Number.isNaN(green) ||
    Number.isNaN(blue)
  ) {
    return null;
  }

  return [red, green, blue];
}

function getRelativeLuminance(color: string): number {
  const rgb = getHexRgb(color);

  if (!rgb) {
    return 0;
  }

  const channels = rgb.map((channel) => {
    const value = channel / 255;

    return value <= 0.03928
      ? value / 12.92
      : ((value + 0.055) / 1.055) ** 2.4;
  });

  return (
    channels[0] * 0.2126 +
    channels[1] * 0.7152 +
    channels[2] * 0.0722
  );
}

function getContrastRatio(
  firstColor: string,
  secondColor: string,
): number {
  const firstLuminance = getRelativeLuminance(firstColor);
  const secondLuminance = getRelativeLuminance(secondColor);

  const lighter = Math.max(firstLuminance, secondLuminance);
  const darker = Math.min(firstLuminance, secondLuminance);

  return (lighter + 0.05) / (darker + 0.05);
}

function getContrastTextColor(
  backgroundColor: string,
  firstColor: string,
  secondColor: string,
): string {
  return getContrastRatio(backgroundColor, firstColor) >=
    getContrastRatio(backgroundColor, secondColor)
    ? firstColor
    : secondColor;
}

function fitText(
  context: CanvasRenderingContext2D,
  text: string,
  maxWidth: number,
): string {
  if (!text || maxWidth <= 0) {
    return '';
  }

  if (context.measureText(text).width <= maxWidth) {
    return text;
  }

  const ellipsis = '…';

  if (context.measureText(ellipsis).width > maxWidth) {
    return '';
  }

  let left = 0;
  let right = text.length;

  while (left < right) {
    const middle = Math.ceil((left + right) / 2);
    const candidate = `${text.slice(0, middle)}${ellipsis}`;

    if (context.measureText(candidate).width <= maxWidth) {
      left = middle;
    } else {
      right = middle - 1;
    }
  }

  return `${text.slice(0, left)}${ellipsis}`;
}

class PriceLabelRenderer implements IPrimitivePaneRenderer {
  constructor(
    private readonly series: PriceLabelSeries,
    private readonly getState: () => PriceLabelViewState,
  ) {}

  public draw(
    target: Parameters<IPrimitivePaneRenderer['draw']>[0],
  ): void {
    const state = this.getState();

    if (
      !state.visible ||
      state.coordinate === null ||
      !state.value
    ) {
      return;
    }

    target.useBitmapCoordinateSpace(
      ({
        context,
        bitmapSize,
        horizontalPixelRatio,
        verticalPixelRatio,
      }) => {
        const { colors } = getThemeStore();

        const seriesColor = removeAlphaFromHex(state.color);
        const isCurrent = state.kind === 'current';

        const backgroundColor = isCurrent
          ? seriesColor
          : colors.chartBackground;

        const borderColor = seriesColor;

        const textColor = isCurrent
          ? getContrastTextColor(
              backgroundColor,
              colors.chartBackground,
              colors.chartTextPrimary,
            )
          : colors.chartTextPrimary;

        const separatorColor = isCurrent
          ? textColor
          : seriesColor;

        const scaleBackgroundColor = colors.chartBackground;

        const labelHeight = Math.round(
          LABEL_HEIGHT * verticalPixelRatio,
        );

        const horizontalPadding = Math.round(
          LABEL_HORIZONTAL_PADDING * horizontalPixelRatio,
        );

        const sectionGap = Math.round(
          LABEL_SECTION_GAP * horizontalPixelRatio,
        );

        const separatorHeight = Math.round(
          LABEL_SEPARATOR_HEIGHT * verticalPixelRatio,
        );

        const separatorWidth = Math.max(
          1,
          Math.round(horizontalPixelRatio),
        );

        const borderWidth = Math.max(
          1,
          Math.round(
            LABEL_BORDER_WIDTH * horizontalPixelRatio,
          ),
        );

        const axisInset = Math.max(
          1,
          Math.round(LABEL_AXIS_INSET * horizontalPixelRatio),
        );

        const minimumLabelWidth = Math.round(
          LABEL_MIN_WIDTH * horizontalPixelRatio,
        );

        const availableWidth = Math.max(
          0,
          bitmapSize.width - axisInset * 2,
        );

        if (availableWidth <= 0) {
          return;
        }

        const rawY =
          Number(state.coordinate) * verticalPixelRatio;

        const top = Math.round(
          Math.max(
            0,
            Math.min(
              bitmapSize.height - labelHeight,
              rawY - labelHeight / 2,
            ),
          ),
        );

        let fontSize = LABEL_FONT_SIZE * verticalPixelRatio;
        const minimumFontSize =
          LABEL_MIN_FONT_SIZE * verticalPixelRatio;

        const symbol = state.symbol?.trim() ?? '';

        const getValueFont = () =>
          `${VALUE_FONT_WEIGHT} ${fontSize}px ${FONT_FAMILY}`;

        const getSymbolFont = () =>
          `${SYMBOL_FONT_WEIGHT} ${fontSize}px ${FONT_FAMILY}`;

        context.save();

        context.font = getValueFont();

        let valueWidth = context.measureText(state.value).width;
        let symbolWidth = 0;

        if (symbol) {
          context.font = getSymbolFont();
          symbolWidth = context.measureText(symbol).width;
        }

        const getContentWidth = () =>
          symbol
            ? symbolWidth +
              sectionGap +
              separatorWidth +
              sectionGap +
              valueWidth
            : valueWidth;

        let requiredWidth =
          getContentWidth() + horizontalPadding * 2;

        if (
          requiredWidth > availableWidth &&
          fontSize > minimumFontSize
        ) {
          const scale = availableWidth / requiredWidth;

          fontSize = Math.max(
            minimumFontSize,
            fontSize * scale,
          );

          context.font = getValueFont();
          valueWidth = context.measureText(state.value).width;

          if (symbol) {
            context.font = getSymbolFont();
            symbolWidth = context.measureText(symbol).width;
          }

          requiredWidth =
            getContentWidth() + horizontalPadding * 2;
        }

        let fittedSymbol = symbol;

        if (requiredWidth > availableWidth && symbol) {
          const fixedWidth =
            valueWidth +
            sectionGap +
            separatorWidth +
            sectionGap +
            horizontalPadding * 2;

          const maxSymbolWidth = Math.max(
            0,
            availableWidth - fixedWidth,
          );

          context.font = getSymbolFont();

          fittedSymbol = fitText(
            context,
            symbol,
            maxSymbolWidth,
          );

          symbolWidth = fittedSymbol
            ? context.measureText(fittedSymbol).width
            : 0;

          requiredWidth =
            (fittedSymbol
              ? symbolWidth +
                sectionGap +
                separatorWidth +
                sectionGap
              : 0) +
            valueWidth +
            horizontalPadding * 2;
        }

        const labelWidth = Math.min(
          availableWidth,
          Math.max(
            minimumLabelWidth,
            Math.ceil(requiredWidth),
          ),
        );

        const isLeftScale =
          this.series.options().priceScaleId === 'left';

        const left = isLeftScale
          ? axisInset
          : bitmapSize.width - axisInset - labelWidth;

        context.fillStyle = scaleBackgroundColor;
        context.fillRect(
          0,
          top,
          bitmapSize.width,
          labelHeight,
        );

        context.fillStyle = backgroundColor;
        context.fillRect(
          left,
          top,
          labelWidth,
          labelHeight,
        );

        context.strokeStyle = borderColor;
        context.lineWidth = borderWidth;

        context.strokeRect(
          left + borderWidth / 2,
          top + borderWidth / 2,
          Math.max(0, labelWidth - borderWidth),
          Math.max(0, labelHeight - borderWidth),
        );

        const textY = top + labelHeight / 2;

        if (!fittedSymbol) {
          context.font = getValueFont();
          context.fillStyle = textColor;
          context.textAlign = 'center';
          context.textBaseline = 'middle';

          context.fillText(
            state.value,
            left + labelWidth / 2,
            textY,
            Math.max(
              0,
              labelWidth - horizontalPadding * 2,
            ),
          );

          context.restore();
          return;
        }

        let currentX = left + horizontalPadding;

        context.font = getSymbolFont();
        context.fillStyle = textColor;
        context.textAlign = 'left';
        context.textBaseline = 'middle';

        context.fillText(
          fittedSymbol,
          currentX,
          textY,
          symbolWidth,
        );

        currentX += symbolWidth + sectionGap;

        const separatorTop =
          top + (labelHeight - separatorHeight) / 2;

        context.save();
        context.globalAlpha = 0.65;
        context.strokeStyle = separatorColor;
        context.lineWidth = separatorWidth;

        context.beginPath();

        context.moveTo(
          currentX + separatorWidth / 2,
          separatorTop,
        );

        context.lineTo(
          currentX + separatorWidth / 2,
          separatorTop + separatorHeight,
        );

        context.stroke();
        context.restore();

        currentX += separatorWidth + sectionGap;

        context.font = getValueFont();
        context.fillStyle = textColor;
        context.textAlign = 'left';
        context.textBaseline = 'middle';

        const maxValueWidth = Math.max(
          0,
          left +
            labelWidth -
            horizontalPadding -
            currentX,
        );

        context.fillText(
          state.value,
          currentX,
          textY,
          maxValueWidth,
        );

        context.restore();
      },
    );
  }
}

class PriceLabelPaneView implements IPrimitivePaneView {
  private readonly rendererInstance: PriceLabelRenderer;

  constructor(
    series: PriceLabelSeries,
    private readonly getState: () => PriceLabelViewState,
  ) {
    this.rendererInstance = new PriceLabelRenderer(
      series,
      getState,
    );
  }

  public renderer(): IPrimitivePaneRenderer | null {
    return this.getState().visible
      ? this.rendererInstance
      : null;
  }

  public zOrder(): 'top' {
    return 'top';
  }
}

export class PriceLabelPrimitive
  implements ISeriesPrimitive<Time>
{
  private readonly views: readonly IPrimitivePaneView[];

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

  private state: PriceLabelViewState = {
    price: null,
    coordinate: null,
    value: '',
    symbol: undefined,
    color: '#000000',
    kind: 'historical',
    visible: false,
  };

  constructor(private readonly series: PriceLabelSeries) {
    this.views = [
      new PriceLabelPaneView(
        series,
        () => this.state,
      ),
    ];
  }

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

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

  public updateAllViews(): void {
    this.updateCoordinate();
  }

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

  public setState(nextState: PriceLabelState): void {
    this.state = {
      ...nextState,
      symbol: nextState.symbol?.trim() || undefined,
      color: removeAlphaFromHex(nextState.color),
      coordinate:
        nextState.price === null
          ? null
          : this.series.priceToCoordinate(nextState.price),
    };

    this.requestUpdate?.();
  }

  public hide(): void {
    if (!this.state.visible) {
      return;
    }

    this.state = {
      ...this.state,
      visible: false,
    };

    this.requestUpdate?.();
  }

  private updateCoordinate(): void {
    this.state = {
      ...this.state,
      coordinate:
        this.state.price === null
          ? null
          : this.series.priceToCoordinate(
              this.state.price,
            ),
    };
  }
}