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


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,
  MeasuredPriceAxisLabel,
  PriceAxisLabel,
} from './types';

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

interface MeasuredLabel
  extends MeasuredPriceAxisLabel {
  width: number;
}

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

const HORIZONTAL_PADDING_RATIO = 7.25 / 12;
const VERTICAL_PADDING_RATIO = 3 / 12;
const TEXT_VERTICAL_OFFSET_RATIO = 1 / 12;
const CONTRAST_THRESHOLD = 160;

function getFont(
  layout: ChartLayoutOptions,
): string {
  return `${layout.fontSize}px ${layout.fontFamily}`;
}

function getLabelTextColor(
  label: PriceAxisLabel,
): string {
  if (label.style === 'outlined') {
    return label.color;
  }

  const hex = label.color
    .replace('#', '')
    .slice(0, 6);

  const color = Number.parseInt(hex, 16);

  if (Number.isNaN(color)) {
    return '#FFFFFF';
  }

  const red = (color >> 16) & 255;
  const green = (color >> 8) & 255;
  const blue = color & 255;

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

  return brightness > CONTRAST_THRESHOLD
    ? '#000000'
    : '#FFFFFF';
}

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 measureLabels(
  context: CanvasRenderingContext2D,
  labels: readonly PriceAxisLabel[],
  layout: ChartLayoutOptions,
): MeasuredLabel[] {
  const horizontalPadding =
    layout.fontSize * HORIZONTAL_PADDING_RATIO;

  const verticalPadding =
    layout.fontSize * VERTICAL_PADDING_RATIO;

  return labels.map((label) => {
    const textMetrics = context.measureText(
      label.text,
    );

    const textHeight =
      textMetrics.actualBoundingBoxAscent +
        textMetrics.actualBoundingBoxDescent ||
      layout.fontSize;

    return {
      ...label,
      width:
        Math.ceil(textMetrics.width) +
        horizontalPadding * 2,
      height:
        textHeight +
        verticalPadding * 2,
    };
  });
}

function createLabelGeometries(
  labels: readonly MeasuredLabel[],
  axisWidth: number,
  axisHeight: number,
  side: PriceAxisSide,
  layout: ChartLayoutOptions,
  reservedCoordinate: number | null,
): LabelGeometry[] {
  const labelsById = new Map<
    string,
    MeasuredLabel
  >();

  labels.forEach((label) => {
    labelsById.set(label.id, label);
  });

  const reservedHeight = labels.reduce(
    (maximumHeight, label) =>
      Math.max(maximumHeight, label.height),
    layout.fontSize +
      layout.fontSize *
        VERTICAL_PADDING_RATIO *
        2,
  );

  const laidOutLabels = layoutPriceAxisLabels(
    labels,
    axisHeight,
    {
      reservedCoordinate:
        reservedCoordinate ?? undefined,
      reservedHeight,
    },
  );

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

    if (!measuredLabel) {
      return [];
    }

    const left =
      side === 'right'
        ? 0
        : Math.max(
            0,
            axisWidth - measuredLabel.width,
          );

    return [
      {
        label,
        left,
        top:
          label.coordinate -
          measuredLabel.height / 2,
        width: measuredLabel.width,
        textX:
          left + measuredLabel.width / 2,
        textY:
          label.coordinate +
          layout.fontSize *
            TEXT_VERTICAL_OFFSET_RATIO,
      },
    ];
  });
}

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

      context.save();

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

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

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

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

          context.fillStyle =
            label.style === 'outlined'
              ? colors.chartBackground
              : label.color;

          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 = 'middle';

    geometries.forEach(
      ({ label, textX, textY }) => {
        context.fillStyle =
          getLabelTextColor(label);

        context.fillText(
          label.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 getReservedCoordinate:
      () => number | null,
    private readonly setMinimumWidth:
      (width: number) => void,
  ) {}

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

    if (!layout || labels.length === 0) {
      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,
        );

        this.setMinimumWidth(
          measuredLabels.reduce(
            (width, label) =>
              Math.max(width, label.width),
            0,
          ),
        );

        geometries = createLabelGeometries(
          measuredLabels,
          mediaSize.width,
          mediaSize.height,
          this.getSide(),
          layout,
          this.getReservedCoordinate(),
        );

        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,
    getReservedCoordinate:
      () => number | null,
    setMinimumWidth:
      (width: number) => void,
  ) {
    this.rendererInstance =
      new PriceAxisLabelsRenderer(
        getLabels,
        getLayout,
        getSide,
        getReservedCoordinate,
        setMinimumWidth,
      );
  }

  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 reservedCoordinate:
    number | null = null;

  private chart:
    | SeriesAttachedParameter<Time>['chart']
    | null = null;

  private series:
    | SeriesAttachedParameter<Time>['series']
    | null = null;

  private requestUpdate:
    | (() => void)
    | null = null;

  private initialMinimumWidth = 0;

  private minimumWidth = 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',
        () => this.reservedCoordinate,
        (width) => {
          this.scheduleMinimumWidth(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.minimumWidth =
      this.initialMinimumWidth;

    this.requestUpdate();
  }

  public detached(): void {
    this.cancelMinimumWidthUpdate();
    this.applyMinimumWidth(
      this.initialMinimumWidth,
    );

    this.chart = null;
    this.series = null;
    this.requestUpdate = null;
    this.labels = [];
    this.reservedCoordinate = null;
  }

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

  public updateAllViews(): void {}

  public setLabels(
    labels: readonly PriceAxisLabel[],
    reservedCoordinate: number | null = null,
  ): void {
    if (
      areLabelsEqual(this.labels, labels) &&
      this.reservedCoordinate ===
        reservedCoordinate
    ) {
      return;
    }

    this.labels = labels;
    this.reservedCoordinate =
      reservedCoordinate;

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

    this.requestUpdate?.();
  }

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

    this.labels = [];
    this.reservedCoordinate = null;
    this.scheduleMinimumWidth(0);
    this.requestUpdate?.();
  }

  private scheduleMinimumWidth(
    width: number,
  ): void {
    const minimumWidth = Math.max(
      this.initialMinimumWidth,
      Math.ceil(width),
    );

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

    this.pendingMinimumWidth = minimumWidth;

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

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

        if (
          this.pendingMinimumWidth !== null
        ) {
          this.applyMinimumWidth(
            this.pendingMinimumWidth,
          );
        }

        this.pendingMinimumWidth = null;
      });
  }

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

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

    this.minimumWidth = minimumWidth;
  }

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

    cancelAnimationFrame(
      this.minimumWidthFrame,
    );

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