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


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

import { layoutPriceAxisLabels } from './layout';

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

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

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

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

const HORIZONTAL_PADDING_RATIO = 7.25 / 12;
const VERTICAL_PADDING_RATIO = 3 / 12;
const TEXT_VERTICAL_OFFSET_RATIO = -0.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 getFilledLabelTextColor(
  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 getLabelTextColor(
  context: CanvasRenderingContext2D,
  label: PriceAxisLabel,
): string {
  if (label.style === 'outlined') {
    return label.color;
  }

  return getFilledLabelTextColor(
    context,
    label.color,
  );
}

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 getTextVerticalOffset(
  layout: ChartLayoutOptions,
): number {
  return (
    layout.fontSize *
    TEXT_VERTICAL_OFFSET_RATIO
  );
}

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

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

  const verticalPadding =
    getVerticalPadding(layout);

  return labels.map((label) => {
    const textMetrics =
      context.measureText(label.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: label.text,
      width:
        textWidth +
        horizontalPadding * 2,
      height:
        textHeight +
        verticalPadding * 2,
      ascent,
      descent,
    };
  });
}

function createLabelGeometries(
  measuredLabels:
    readonly MeasuredLabel[],
  axisWidth: number,
  axisHeight: number,
  side: PriceAxisSide,
  layout: ChartLayoutOptions,
): LabelGeometry[] {
  const labelsWithMeasuredHeight =
    measuredLabels.map(
      ({ label, height }) => ({
        ...label,
        height,
      }),
    );

  const laidOutLabels =
    layoutPriceAxisLabels(
      labelsWithMeasuredHeight,
      axisHeight,
    );

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

  const verticalOffset =
    getTextVerticalOffset(layout);

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

      if (!measuredLabel) {
        return [];
      }

      const {
        width,
        height,
        ascent,
        descent,
      } = measuredLabel;

      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:
            label.coordinate +
            (ascent - descent) / 2 +
            verticalOffset,
        },
      ];
    },
  );
}

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 =
            getLabelTextColor(
              context,
              label,
            );

          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,
    private readonly requestMinimumWidth:
      (width: number) => void,
  ) {}

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

        const measuredLabels =
          measureLabels(
            context,
            labels,
            layout,
          );

        const requiredWidth =
          measuredLabels.reduce(
            (maximumWidth, label) =>
              Math.max(
                maximumWidth,
                label.width,
              ),
            0,
          );

        this.requestMinimumWidth(
          requiredWidth,
        );

        geometries =
          createLabelGeometries(
            measuredLabels,
            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,
    requestMinimumWidth:
      (width: number) => void,
  ) {
    this.rendererInstance =
      new PriceAxisLabelsRenderer(
        getLabels,
        getLayout,
        getSide,
        requestMinimumWidth,
      );
  }

  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;

  private initialMinimumWidth = 0;

  private appliedMinimumWidth = 0;

  private pendingMinimumWidth:
    | number
    | null = null;

  private minimumWidthFrame:
    | number
    | null = null;

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

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

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

    this.initialMinimumWidth =
      series
        .priceScale()
        .options()
        .minimumWidth ?? 0;

    this.appliedMinimumWidth =
      this.initialMinimumWidth;

    this.requestUpdate();
  }

  public detached(): void {
    this.cancelMinimumWidthUpdate();

    this.applyMinimumWidth(
      this.initialMinimumWidth,
    );

    this.chart = null;
    this.series = null;
    this.requestUpdate = null;
    this.pendingMinimumWidth = null;
    this.initialMinimumWidth = 0;
    this.appliedMinimumWidth = 0;
  }

  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;

    if (labels.length === 0) {
      this.scheduleMinimumWidthUpdate(0);
    }

    this.requestUpdate?.();
  }

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

    this.labels = [];

    this.scheduleMinimumWidthUpdate(0);
    this.requestUpdate?.();
  }

  private scheduleMinimumWidthUpdate(
    requiredWidth: number,
  ): void {
    const nextMinimumWidth =
      Math.max(
        this.initialMinimumWidth,
        Math.ceil(requiredWidth),
      );

    if (
      nextMinimumWidth ===
        this.appliedMinimumWidth ||
      nextMinimumWidth ===
        this.pendingMinimumWidth
    ) {
      return;
    }

    this.pendingMinimumWidth =
      nextMinimumWidth;

    if (
      this.minimumWidthFrame !== null
    ) {
      return;
    }

    if (
      typeof requestAnimationFrame !==
      'function'
    ) {
      this.flushMinimumWidthUpdate();

      return;
    }

    this.minimumWidthFrame =
      requestAnimationFrame(() => {
        this.minimumWidthFrame = null;

        this.flushMinimumWidthUpdate();
      });
  }

  private flushMinimumWidthUpdate(): void {
    const nextMinimumWidth =
      this.pendingMinimumWidth;

    this.pendingMinimumWidth = null;

    if (nextMinimumWidth === null) {
      return;
    }

    this.applyMinimumWidth(
      nextMinimumWidth,
    );
  }

  private applyMinimumWidth(
    minimumWidth: number,
  ): void {
    if (
      !this.series ||
      minimumWidth ===
        this.appliedMinimumWidth
    ) {
      return;
    }

    this.series
      .priceScale()
      .applyOptions({
        minimumWidth,
      });

    this.appliedMinimumWidth =
      minimumWidth;
  }

  private cancelMinimumWidthUpdate(): void {
    if (
      this.minimumWidthFrame === null
    ) {
      return;
    }

    if (
      typeof cancelAnimationFrame ===
      'function'
    ) {
      cancelAnimationFrame(
        this.minimumWidthFrame,
      );
    }

    this.minimumWidthFrame = null;
    this.pendingMinimumWidth = null;
  }
}