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


import {
  IPrimitivePaneRenderer,
  IPrimitivePaneView,
  ISeriesPrimitive,
  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 normalized = color.replace('#', '').slice(0, 6);

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

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

  if ([red, green, blue].some(Number.isNaN)) {
    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 || 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 halfHeight = LABEL_HEIGHT / 2;
  const minCoordinate = halfHeight;
  const maxCoordinate = Math.max(halfHeight, height - halfHeight);

  const resolved = 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 < resolved.length; index += 1) {
    const previous = resolved[index - 1];
    const current = resolved[index];
    const minimumCoordinate = previous.coordinate + LABEL_HEIGHT + LABEL_GAP;

    if (current.coordinate < minimumCoordinate) {
      current.coordinate = minimumCoordinate;
    }
  }

  const lastLabel = resolved.at(-1);

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

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

  for (let index = resolved.length - 2; index >= 0; index -= 1) {
    const current = resolved[index];
    const next = resolved[index + 1];
    const maximumCoordinate = next.coordinate - LABEL_HEIGHT - LABEL_GAP;

    if (current.coordinate > maximumCoordinate) {
      current.coordinate = maximumCoordinate;
    }
  }

  const firstLabel = resolved[0];

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

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

  return resolved;
}

class PriceAxisLabelsRenderer implements IPrimitivePaneRenderer {
  constructor(private readonly labels: PriceAxisLabel[]) {}

  public draw(target: Parameters<IPrimitivePaneRenderer['draw']>[0]): void {
    target.useBitmapCoordinateSpace(
      ({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }) => {
        const width = bitmapSize.width;
        const height = bitmapSize.height;

        if (width <= 0 || height <= 0 || this.labels.length === 0) {
          return;
        }

        const resolvedLabels = resolveLabelPositions(
          this.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,
              width - borderWidth,
              labelHeight -Width = borderWidth;
            context.strokeRect(
              borderWidth / 2,
              top + borderWidth / 2,
              width - borderWidth,
              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 labels: PriceAxisLabel[] = [];

  public update(labels: PriceAxisLabel[]): void {
    this.labels = labels;
  }

  public renderer(): IPrimitivePaneRenderer {
    return new PriceAxisLabelsRenderer(this.labels);
  }

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

export class PriceAxisLabelsPrimitive implements ISeriesPrimitive<Time> {
  private readonly paneView = new PriceAxisLabelsPaneView();
  private readonly paneViews = [this.paneView];

  private labels: PriceAxisLabel[] = [];
  private requestUpdate: (() => void) | null = null;

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

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

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

  public updateAllViews(): void {
    this.paneView.update(this.labels);
  }

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

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

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