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


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

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

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

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

interface MeasuredLabel {
  label: PriceAxisLabel;
  text: string;
  width: number;
  height: number;
  ascent: number;
}

interface LabelGeometry {
  label: LaidOutPriceAxisLabel;
  text: string;
  left: number;
  top: number;
  width: number;
  height: number;
  textX: number;
  textY: number;
}

const HORIZONTAL_PADDING_RATIO = 7 / 12;
const VERTICAL_PADDING_RATIO = 2.5 / 12;
const CONTRAST_THRESHOLD = 160;

function parseColor(color: string): [number, number, number] | null {
  const normalizedColor = color.trim();

  if (normalizedColor.startsWith('#')) {
    const hex = normalizedColor.slice(1);

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

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

      return [red, green, blue];
    }

    if (hex.length === 6 || hex.length === 8) {
      const red = Number.parseInt(hex.slice(0, 2), 16);
      const green = Number.parseInt(hex.slice(2, 4), 16);
      const blue = Number.parseInt(hex.slice(4, 6), 16);

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

      return [red, green, blue];
    }

    return null;
  }

  const rgbMatch = normalizedColor.match(
    /^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)/i,
  );

  if (!rgbMatch) {
    return null;
  }

  const red = Number(rgbMatch[1]);
  const green = Number(rgbMatch[2]);
  const blue = Number(rgbMatch[3]);

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

  return [red, green, blue];
}

function normalizeCanvasColor(
  context: CanvasRenderingContext2D,
  color: string,
): string {
  const previousFillStyle = context.fillStyle;

  context.fillStyle = '#000000';
  context.fillStyle = color;

  const normalizedColor = String(context.fillStyle);

  context.fillStyle = previousFillStyle;

  return normalizedColor;
}

function getContrastTextColor(
  context: CanvasRenderingContext2D,
  color: string,
): string {
  const normalizedColor = normalizeCanvasColor(context, color);
  const rgb = parseColor(normalizedColor);

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

  const [red, green, blue] = rgb;

  const grayscale =
    0.199 * red +
    0.687 * green +
    0.114 * blue;

  return grayscale > CONTRAST_THRESHOLD
    ? '#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 getFont(layout: ChartLayoutOptions): string {
  return `${layout.fontSize}px ${layout.fontFamily}`;
}

function getHorizontalPadding(
  layout: ChartLayoutOptions,
): number {
  return layout.fontSize * HORIZONTAL_PADDING_RATIO;
}

function getVerticalPadding(
  layout: ChartLayoutOptions,
): number {
  return layout.fontSize * VERTICAL_PADDING_RATIO;
}

function getLabelLeft(
  side: PriceAxisSide,
  axisWidth: number,
  labelWidth: number,
): number {
  return side === 'right'
    ? 0
    : axisWidth - labelWidth;
}

function measureLabels(
  context: CanvasRenderingContext2D,
  labels: readonly PriceAxisLabel[],
  axisWidth: number,
  layout: ChartLayoutOptions,
): MeasuredLabel[] {
  const horizontalPadding =
    getHorizontalPadding(layout);

  const verticalPadding =
    getVerticalPadding(layout);

  const maxTextWidth = Math.max(
    0,
    axisWidth - horizontalPadding * 2,
  );

  return labels.map((label) => {
    const text = fitText(
      context,
      label.text,
      maxTextWidth,
    );

    const textMetrics = context.measureText(text);

    const ascent =
      textMetrics.actualBoundingBoxAscent ||
      layout.fontSize * 0.75;

    const descent =
      textMetrics.actualBoundingBoxDescent ||
      layout.fontSize * 0.25;

    const textWidth = Math.ceil(textMetrics.width);
    const textHeight = ascent + descent;

    return {
      label,
      text,
      width:
        textWidth +
        horizontalPadding * 2,
      height:
        textHeight +
        verticalPadding * 2,
      ascent,
    };
  });
}

function createLabelGeometries(
  context: CanvasRenderingContext2D,
  labels: readonly PriceAxisLabel[],
  axisWidth: number,
  axisHeight: number,
  side: PriceAxisSide,
  layout: ChartLayoutOptions,
): LabelGeometry[] {
  const measuredLabels = measureLabels(
    context,
    labels,
    axisWidth,
    layout,
  );

  const labelsWithMeasuredHeight =
    measuredLabels.map(({ label, height }) => ({
      ...label,
      height,
    }));

  const laidOutLabels = layoutPriceAxisLabels(
    labelsWithMeasuredHeight,
    axisHeight,
  );

  const measuredLabelsById = new Map(
    measuredLabels.map((measuredLabel) => [
      measuredLabel.label.id,
      measuredLabel,
    ]),
  );

  const verticalPadding =
    getVerticalPadding(layout);

  return laidOutLabels.flatMap((label) => {
    const measuredLabel = measuredLabelsById.get(
      label.id,
    );

    if (!measuredLabel) {
      return [];
    }

    const width = Math.min(
      axisWidth,
      measuredLabel.width,
    );

    const height = measuredLabel.height;

    const left = getLabelLeft(
      side,
      axisWidth,
      width,
    );

    const top =
      label.coordinate - height / 2;

    return [
      {
        label,
        text: measuredLabel.text,
        left,
        top,
        width,
        height,
        textX: left + width / 2,
        textY:
          top +
          verticalPadding +
          measuredLabel.ascent,
      },
    ];
  });
}

function drawLabelBackgrounds(
  target: CanvasRenderingTarget2D,
  geometries: readonly LabelGeometry[],
): void {
  target.useBitmapCoordinateSpace(
    ({
      context,
      horizontalPixelRatio,
      verticalPixelRatio,
    }) => {
      const { colors } = getThemeStore();

      context.save();

      geometries.forEach(
        ({
          label,
          left,
          top,
          width,
          height,
        }) => {
          const bitmapLeft = Math.round(
            left * horizontalPixelRatio,
          );

          const bitmapTop = Math.round(
            top * verticalPixelRatio,
          );

          const bitmapWidth = Math.round(
            width * horizontalPixelRatio,
          );

          const bitmapHeight = Math.round(
            height * verticalPixelRatio,
          );

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

          context.fillStyle = backgroundColor;

          context.fillRect(
            bitmapLeft,
            bitmapTop,
            bitmapWidth,
            bitmapHeight,
          );

          if (label.style !== 'outlined') {
            return;
          }

          const borderWidth = Math.max(
            1,
            Math.floor(
              Math.min(
                horizontalPixelRatio,
                verticalPixelRatio,
              ),
            ),
          );

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

          context.strokeRect(
            bitmapLeft + borderWidth / 2,
            bitmapTop + borderWidth / 2,
            Math.max(
              0,
              bitmapWidth - borderWidth,
            ),
            Math.max(
              0,
              bitmapHeight - borderWidth,
            ),
          );
        },
      );

      context.restore();
    },
  );
}

function drawLabelTexts(
  target: CanvasRenderingTarget2D,
  geometries: readonly LabelGeometry[],
  layout: ChartLayoutOptions,
): void {
  target.useMediaCoordinateSpace(({ context }) => {
    context.save();

    context.font = getFont(layout);
    context.textAlign = 'center';
    context.textBaseline = 'alphabetic';

    geometries.forEach(
      ({ label, text, textX, textY }) => {
        context.fillStyle =
          label.style === 'outlined'
            ? layout.textColor
            : getContrastTextColor(
                context,
                label.color,
              );

        context.fillText(
          text,
          textX,
          textY,
        );
      },
    );

    context.restore();
  });
}

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;
    }

    let geometries: LabelGeometry[] = [];

    target.useMediaCoordinateSpace(
      ({ context, mediaSize }) => {
        if (
          mediaSize.width <= 0 ||
          mediaSize.height <= 0
        ) {
          return;
        }

        context.save();
        context.font = getFont(layout);

        geometries = createLabelGeometries(
          context,
          labels,
          mediaSize.width,
          mediaSize.height,
          this.getSide(),
          layout,
        );

        context.restore();
      },
    );

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

    drawLabelBackgrounds(target, geometries);
    drawLabelTexts(target, geometries, layout);
  }
}

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?.();
  }
}