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


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

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

import { layoutPriceAxisLabels } from './layout';
import { LaidOutPriceAxisLabel, PriceAxisLabel } from './types';

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 areLabelsEqual(currentLabels: readonly PriceAxisLabel[], nextLabels: readonly PriceAxisLabel[]): boolean {
  if (currentLabels.length !== nextLabels.length) {
    return false;
  }

  return currentLabels.every((currentLabel, index) => {
    const nextLabel = nextLabels[index];

    return (
      currentLabel.id === nextLabel.id &&
      currentLabel.collisionGroup === nextLabel.collisionGroup &&
      currentLabel.desiredCoordinate === nextLabel.desiredCoordinate &&
      currentLabel.text === nextLabel.text &&
      currentLabel.color === nextLabel.color &&
      currentLabel.style === nextLabel.style &&
      currentLabel.symbol === nextLabel.symbol &&
      currentLabel.priority === nextLabel.priority &&
      currentLabel.height === nextLabel.height
    );
  });
}

function drawLabel(
  context: CanvasRenderingContext2D,
  label: LaidOutPriceAxisLabel,
  width: number,
  horizontalPixelRatio: number,
  verticalPixelRatio: number,
): void {
  const { colors } = getThemeStore();
  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 maxTextWidth = width - horizontalPadding * 2;
    const text = fitText(context, label.text, maxTextWidth);

    context.textAlign = 'center';
    context.fillText(text, 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 textAreaWidth = width - separatorX;
  const textMaxWidth = textAreaWidth - horizontalPadding * 2;
  const symbol = fitText(context, label.symbol, symbolMaxWidth);
  const text = fitText(context, label.text, textMaxWidth);

  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(text, separatorX + textAreaWidth / 2, coordinate);
}

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

  public draw(target: CanvasRenderingTarget2D): void {
    const labels = this.getLabels();

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

    target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }) => {
      if (bitmapSize.width <= 0 || bitmapSize.height <= 0) {
        return;
      }

      const laidOutLabels = layoutPriceAxisLabels(labels, bitmapSize.height / verticalPixelRatio);

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

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

      laidOutLabels.forEach((label) => {
        drawLabel(context, label, bitmapSize.width, horizontalPixelRatio, verticalPixelRatio);
      });

      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 {
    if (areLabelsEqual(this.labels, labels)) {
      return;
    }

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

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

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