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


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

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

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

type PriceAxisSide = 'left' | 'right';
type ChartLayoutOptions = Readonly<ChartOptions['layout']>;

interface PreparedLabel {
  text: string;
  width: number;
  height: number;
  ascent: number;
}

const HORIZONTAL_PADDING = 5;
const VERTICAL_PADDING = 2;

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

  if (normalizedColor.length === 3) {
    const red = Number.parseInt(`${normalizedColor[0]}${normalizedColor[0]}`, 16);
    const green = Number.parseInt(`${normalizedColor[1]}${normalizedColor[1]}`, 16);
    const blue = Number.parseInt(`${normalizedColor[2]}${normalizedColor[2]}`, 16);

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

    return [red, green, blue];
  }

  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 getContrastTextColor(color: string): string {
  const rgb = parseHexColor(color);

  if (!rgb) {
    return '#FFFFFF';
  }

  const [red, green, blue] = rgb;
  const brightness = 0.199 * red + 0.687 * green + 0.114 * blue;

  return brightness >= 128 ? '#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.priority === nextLabel.priority
    );
  });
}

function getPreparedLabels(
  context: CanvasRenderingContext2D,
  labels: readonly PriceAxisLabel[],
  axisWidth: number,
  horizontalPixelRatio: number,
  verticalPixelRatio: number,
  fontSize: number,
): Map<string, PreparedLabel> {
  const preparedLabels = new Map<string, PreparedLabel>();
  const horizontalPadding = HORIZONTAL_PADDING * horizontalPixelRatio;
  const verticalPadding = VERTICAL_PADDING * verticalPixelRatio;
  const maxTextWidth = Math.max(0, axisWidth - horizontalPadding * 2);

  labels.forEach((label) => {
    const text = fitText(context, label.text, maxTextWidth);
    const metrics = context.measureText(text);
    const ascent = metrics.actualBoundingBoxAscent || fontSize * 0.75;
    const descent = metrics.actualBoundingBoxDescent || fontSize * 0.25;
    const textHeight = ascent + descent;

    preparedLabels.set(label.id, {
      text,
      width: Math.ceil(metrics.width + horizontalPadding * 2),
      height: Math.ceil(textHeight + verticalPadding * 2),
      ascent,
    });
  });

  return preparedLabels;
}

function drawLabel(
  context: CanvasRenderingContext2D,
  label: LaidOutPriceAxisLabel,
  preparedLabel: PreparedLabel,
  axisWidth: number,
  side: PriceAxisSide,
  horizontalPixelRatio: number,
  verticalPixelRatio: number,
): void {
  const { colors } = getThemeStore();
  const coordinate = Math.round(label.coordinate * verticalPixelRatio);
  const width = Math.min(axisWidth, preparedLabel.width);
  const height = preparedLabel.height;
  const left = side === 'left' ? 0 : axisWidth - width;
  const top = Math.round(coordinate - height / 2);
  const verticalPadding = VERTICAL_PADDING * verticalPixelRatio;
  const backgroundColor = label.style === 'outlined' ? colors.chartBackground : label.color;
  const textColor = label.style === 'outlined' ? label.color : getContrastTextColor(label.color);

  context.fillStyle = backgroundColor;
  context.fillRect(left, top, width, height);

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

    context.strokeStyle = label.color;
    context.lineWidth = borderWidth;
    context.strokeRect(
      left + borderWidth / 2,
      top + borderWidth / 2,
      Math.max(0, width - borderWidth),
      Math.max(0, height - borderWidth),
    );
  }

  context.fillStyle = textColor;
  context.textAlign = 'center';
  context.textBaseline = 'alphabetic';
  context.fillText(
    preparedLabel.text,
    left + width / 2,
    top + verticalPadding + preparedLabel.ascent,
  );
}

class PriceAxisLabelsRenderer implements IPrimitivePaneRenderer {
  constructor(
    private readonly getLabels: () => readonly PriceAxisLabel[],
    private readonly getLayout: () => ChartLayoutOptions | null,
    private readonly getSide: () => PriceAxisSide,
  ) {}

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

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

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

      const fontSize = layout.fontSize * verticalPixelRatio;

      context.save();
      context.font = `${fontSize}px ${layout.fontFamily}`;

      const preparedLabels = getPreparedLabels(
        context,
        labels,
        bitmapSize.width,
        horizontalPixelRatio,
        verticalPixelRatio,
        fontSize,
      );

      const measuredLabels = labels.map((label) => {
        const preparedLabel = preparedLabels.get(label.id);

        return {
          ...label,
          height: preparedLabel ? preparedLabel.height / verticalPixelRatio : label.height,
        };
      });

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

      laidOutLabels.forEach((label) => {
        const preparedLabel = preparedLabels.get(label.id);

        if (!preparedLabel) {
          return;
        }

        drawLabel(
          context,
          label,
          preparedLabel,
          bitmapSize.width,
          this.getSide(),
          horizontalPixelRatio,
          verticalPixelRatio,
        );
      });

      context.restore();
    });
  }
}

class PriceAxisLabelsPaneView implements IPrimitivePaneView {
  private readonly rendererInstance: PriceAxisLabelsRenderer;

  constructor(
    private readonly getLabels: () => readonly PriceAxisLabel[],
    getLayout: () => ChartLayoutOptions | null,
    getSide: () => PriceAxisSide,
  ) {
    this.rendererInstance = new PriceAxisLabelsRenderer(getLabels, getLayout, getSide);
  }

  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 chart: SeriesAttachedParameter<Time>['chart'] | null = null;
  private series: SeriesAttachedParameter<Time>['series'] | null = null;
  private requestUpdate: (() => void) | null = null;

  constructor() {
    this.priceAxisPaneView = new PriceAxisLabelsPaneView(
      () => this.labels,
      () => this.chart?.options().layout ?? null,
      () => (this.series?.options().priceScaleId === 'left' ? 'left' : 'right'),
    );

    this.priceAxisPaneViewList = [this.priceAxisPaneView];
  }

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

  public detached(): void {
    this.chart = null;
    this.series = null;
    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?.();
  }
}