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


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

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 LABEL_EDGE_GAP = 1;
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);

  if (hex.length !== 6) {
    return '#FFFFFF';
  }

  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 '#FFFFFF';
  }

  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.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 measuredTextHeight =
      textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent;

    return {
      ...label,
      width: Math.ceil(textMetrics.width) + horizontalPadding * 2,
      height: (measuredTextHeight || layout.fontSize) + 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'
        ? LABEL_EDGE_GAP
        : Math.max(LABEL_EDGE_GAP, axisWidth - measuredLabel.width - LABEL_EDGE_GAP);

    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(
          (maximumWidth, label) => Math.max(maximumWidth, label.width + LABEL_EDGE_GAP * 2),
          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;
    this.initialMinimumWidth = 0;
    this.minimumWidth = 0;
  }

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