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


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 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 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] as const),
  );

  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 = 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,
  ) {}

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