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


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

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

export type PriceAxisLabelStyle = 'filled' | 'outlined';

export interface PriceAxisLabel {
  id: string;
  coordinate: number | null;
  value: string;
  color: string;
  style: PriceAxisLabelStyle;
  symbol?: string;
  priority?: number;
}

interface ResolvedPriceAxisLabel extends PriceAxisLabel {
  coordinate: number;
}

const LABEL_HEIGHT = 20;
const LABEL_GAP = 2;
const HORIZONTAL_PADDING = 6;
const SYMBOL_MIN_WIDTH_RATIO = 0.3;
const SYMBOL_MAX_WIDTH_RATIO = 0.48;
const FONT_SIZE = 11;
const FONT_WEIGHT = 500;

function getContrastTextColor(color: string): string {
  const normalizedColor = color.replace('#', '').slice(0, 6);

  if (normalizedColor.length !== 6) {
    return '#FFFFFF';
  }

  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 '#FFFFFF';
  }

  const luminance =
    (red * 0.299 + green * 0.587 + blue * 0.114) /
    255;

  return luminance > 0.6 ? '#000000' : '#FFFFFF';
}

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

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

  const ellipsis = '…';
  let result = text;

  while (
    result.length > 0 &&
    context.measureText(`${result}${ellipsis}`).width >
      maxWidth
  ) {
    result = result.slice(0, -1);
  }

  return result.length > 0
    ? `${result}${ellipsis}`
    : '';
}

function resolveLabelPositions(
  labels: PriceAxisLabel[],
  height: number,
): ResolvedPriceAxisLabel[] {
  const halfLabelHeight = LABEL_HEIGHT / 2;
  const minCoordinate = halfLabelHeight;
  const maxCoordinate = Math.max(
    halfLabelHeight,
    height - halfLabelHeight,
  );

  const resolvedLabels = labels
    .filter(
      (
        label,
      ): label is PriceAxisLabel & {
        coordinate: number;
      } =>
        label.coordinate !== null &&
        Number.isFinite(label.coordinate),
    )
    .sort((left, right) => {
      if (left.coordinate === right.coordinate) {
        return (
          (right.priority ?? 0) -
          (left.priority ?? 0)
        );
      }

      return left.coordinate - right.coordinate;
    })
    .map((label) => ({
      ...label,
      coordinate: Math.min(
        Math.max(label.coordinate, minCoordinate),
        maxCoordinate,
      ),
    }));

  for (
    let index = 1;
    index < resolvedLabels.length;
    index += 1
  ) {
    const previousLabel = resolvedLabels[index - 1];
    const currentLabel = resolvedLabels[index];

    const minCurrentCoordinate =
      previousLabel.coordinate +
      LABEL_HEIGHT +
      LABEL_GAP;

    if (
      currentLabel.coordinate < minCurrentCoordinate
    ) {
      currentLabel.coordinate = minCurrentCoordinate;
    }
  }

  const lastLabel =
    resolvedLabels[resolvedLabels.length - 1];

  if (
    lastLabel &&
    lastLabel.coordinate > maxCoordinate
  ) {
    const offset =
      lastLabel.coordinate - maxCoordinate;

    resolvedLabels.forEach((label) => {
      label.coordinate -= offset;
    });
  }

  for (
    let index = resolvedLabels.length - 2;
    index >= 0;
    index -= 1
  ) {
    const currentLabel = resolvedLabels[index];
    const nextLabel = resolvedLabels[index + 1];

    const maxCurrentCoordinate =
      nextLabel.coordinate -
      LABEL_HEIGHT -
      LABEL_GAP;

    if (
      currentLabel.coordinate > maxCurrentCoordinate
    ) {
      currentLabel.coordinate = maxCurrentCoordinate;
    }
  }

  const firstLabel = resolvedLabels[0];

  if (
    firstLabel &&
    firstLabel.coordinate < minCoordinate
  ) {
    const offset =
      minCoordinate - firstLabel.coordinate;

    resolvedLabels.forEach((label) => {
      label.coordinate += offset;
    });
  }

  return resolvedLabels;
}

class PriceAxisLabelsRenderer
  implements IPrimitivePaneRenderer
{
  constructor(
    private readonly getLabels: () => readonly PriceAxisLabel[],
  ) {}

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

    if (labels.length === 0) {
      return;
    }

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

        if (width <= 0 || height <= 0) {
          return;
        }

        const resolvedLabels =
          resolveLabelPositions(
            [...labels],
            height / verticalPixelRatio,
          );

        if (resolvedLabels.length === 0) {
          return;
        }

        const { colors } = getThemeStore();

        context.save();

        context.font = `${FONT_WEIGHT} ${Math.round(
          FONT_SIZE * verticalPixelRatio,
        )}px Inter, sans-serif`;

        context.textBaseline = 'middle';

        resolvedLabels.forEach((label) => {
          const coordinate = Math.round(
            label.coordinate * verticalPixelRatio,
          );

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

          const top = Math.round(
            coordinate - labelHeight / 2,
          );

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

          const backgroundColor =
            label.style === 'outlined'
              ? colors.chartBackground
              : label.color;

          const textColor =
            label.style === 'outlined'
              ? label.color
              : getContrastTextColor(label.color);

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

          if (label.style === 'outlined') {
            const borderWidth = Math.max(
              1,
              Math.round(horizontalPixelRatio),
            );

            context.strokeStyle = label.color;
            context.lineWidth = borderWidth;

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

          context.fillStyle = textColor;

          if (!label.symbol) {
            const maxValueWidth =
              width - horizontalPadding * 2;

            const value = fitText(
              context,
              label.value,
              maxValueWidth,
            );

            context.textAlign = 'center';

            context.fillText(
              value,
              width / 2,
              coordinate,
            );

            return;
          }

          const availableWidth =
            width - horizontalPadding * 2;

          const measuredSymbolWidth =
            context.measureText(label.symbol).width +
            horizontalPadding * 2;

          const symbolWidth = Math.min(
            Math.max(
              measuredSymbolWidth,
              availableWidth *
                SYMBOL_MIN_WIDTH_RATIO,
            ),
            availableWidth *
              SYMBOL_MAX_WIDTH_RATIO,
          );

          const separatorX = Math.round(
            horizontalPadding + symbolWidth,
          );

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

          const symbolMaxWidth =
            symbolWidth - horizontalPadding * 2;

          const valueAreaWidth =
            width - separatorX;

          const valueMaxWidth =
            valueAreaWidth -
            horizontalPadding * 2;

          const symbol = fitText(
            context,
            label.symbol,
            symbolMaxWidth,
          );

          const value = fitText(
            context,
            label.value,
            valueMaxWidth,
          );

          context.textAlign = 'center';

          context.fillText(
            symbol,
            horizontalPadding +
              symbolWidth / 2,
            coordinate,
          );

          context.globalAlpha = 0.5;

          context.fillRect(
            separatorX,
            top +
              Math.round(
                3 * verticalPixelRatio,
              ),
            separatorWidth,
            labelHeight -
              Math.round(
                6 * verticalPixelRatio,
              ),
          );

          context.globalAlpha = 1;

          context.fillText(
            value,
            separatorX +
              valueAreaWidth / 2,
            coordinate,
          );
        });

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

class PriceAxisLabelsPaneView
  implements IPrimitivePaneView
{
  private readonly rendererInstance: PriceAxisLabelsRenderer;

  constructor(
    private readonly getLabels: () => readonly PriceAxisLabel[],
  ) {
    this.rendererInstance =
      new PriceAxisLabelsRenderer(getLabels);
  }

  public renderer(): IPrimitivePaneRenderer | null {
    return this.getLabels().length > 0
      ? this.rendererInstance
      : null;
  }

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

export class PriceAxisLabelsPrimitive
  implements ISeriesPrimitive<Time>
{
  private readonly priceAxisPaneView: PriceAxisLabelsPaneView;

  private readonly priceAxisPaneViewList: readonly IPrimitivePaneView[];

  private labels: readonly PriceAxisLabel[] = [];

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

  constructor() {
    this.priceAxisPaneView =
      new PriceAxisLabelsPaneView(
        () => this.labels,
      );

    this.priceAxisPaneViewList = [
      this.priceAxisPaneView,
    ];
  }

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

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

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

  public updateAllViews(): void {}

  public setLabels(
    labels: readonly PriceAxisLabel[],
  ): void {
    this.labels = labels;
    this.requestUpdate?.();
  }

  public clear(): void {
    if (this.labels.length === 0) {
      return;
    }

    this.labels = [];
    this.requestUpdate?.();
  }
}