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


import type {
  LaidOutPriceAxisLabel,
  MeasuredPriceAxisLabel,
  PriceAxisLabelsLayoutOptions,
} from './types';

const DEFAULT_GAP = 2;
const DEFAULT_DUPLICATE_TOLERANCE = 1;

function compareLabels(
  left: MeasuredPriceAxisLabel,
  right: MeasuredPriceAxisLabel,
): number {
  if (left.desiredCoordinate !== right.desiredCoordinate) {
    return left.desiredCoordinate - right.desiredCoordinate;
  }

  if (left.priority !== right.priority) {
    return right.priority - left.priority;
  }

  return left.id.localeCompare(right.id);
}

function clamp(value: number, min: number, max: number): number {
  return Math.min(Math.max(value, min), max);
}

function deduplicateLabels(
  labels: readonly MeasuredPriceAxisLabel[],
  tolerance: number,
): MeasuredPriceAxisLabel[] {
  const result: MeasuredPriceAxisLabel[] = [];

  [...labels]
    .sort((left, right) => {
      if (left.priority !== right.priority) {
        return right.priority - left.priority;
      }

      return left.id.localeCompare(right.id);
    })
    .forEach((label) => {
      const duplicate = result.some(
        (currentLabel) =>
          currentLabel.text === label.text &&
          Math.abs(
            currentLabel.desiredCoordinate -
              label.desiredCoordinate,
          ) <= tolerance,
      );

      if (!duplicate) {
        result.push(label);
      }
    });

  return result;
}

function distributeLabels(
  labels: readonly MeasuredPriceAxisLabel[],
  minCoordinate: number,
  maxCoordinate: number,
): LaidOutPriceAxisLabel[] {
  if (labels.length === 0) {
    return [];
  }

  if (labels.length === 1) {
    const label = labels[0];

    return [
      {
        ...label,
        coordinate: clamp(
          label.desiredCoordinate,
          minCoordinate + label.height / 2,
          maxCoordinate - label.height / 2,
        ),
      },
    ];
  }

  const firstCoordinate =
    minCoordinate + labels[0].height / 2;

  const lastCoordinate = Math.max(
    firstCoordinate,
    maxCoordinate -
      labels[labels.length - 1].height / 2,
  );

  const step =
    (lastCoordinate - firstCoordinate) /
    (labels.length - 1);

  return labels.map((label, index) => ({
    ...label,
    coordinate: firstCoordinate + step * index,
  }));
}

function layoutRange(
  labels: readonly MeasuredPriceAxisLabel[],
  minCoordinate: number,
  maxCoordinate: number,
  gap: number,
): LaidOutPriceAxisLabel[] {
  const sortedLabels = [...labels].sort(compareLabels);

  if (sortedLabels.length === 0) {
    return [];
  }

  const availableHeight = Math.max(
    0,
    maxCoordinate - minCoordinate,
  );

  const labelsHeight = sortedLabels.reduce(
    (total, label) => total + label.height,
    0,
  );

  if (labelsHeight > availableHeight) {
    return distributeLabels(
      sortedLabels,
      minCoordinate,
      maxCoordinate,
    );
  }

  const effectiveGap =
    sortedLabels.length > 1
      ? Math.min(
          gap,
          Math.max(
            0,
            (availableHeight - labelsHeight) /
              (sortedLabels.length - 1),
          ),
        )
      : 0;

  const result = sortedLabels.map((label) => ({
    ...label,
    coordinate: clamp(
      label.desiredCoordinate,
      minCoordinate + label.height / 2,
      maxCoordinate - label.height / 2,
    ),
  }));

  for (let index = 1; index < result.length; index += 1) {
    const previousLabel = result[index - 1];
    const currentLabel = result[index];

    const minimumCoordinate =
      previousLabel.coordinate +
      previousLabel.height / 2 +
      effectiveGap +
      currentLabel.height / 2;

    currentLabel.coordinate = Math.max(
      currentLabel.coordinate,
      minimumCoordinate,
    );
  }

  const lastLabel = result[result.length - 1];
  const lastMaximumCoordinate =
    maxCoordinate - lastLabel.height / 2;

  if (lastLabel.coordinate > lastMaximumCoordinate) {
    const offset =
      lastLabel.coordinate - lastMaximumCoordinate;

    result.forEach((label) => {
      label.coordinate -= offset;
    });
  }

  for (
    let index = result.length - 2;
    index >= 0;
    index -= 1
  ) {
    const currentLabel = result[index];
    const nextLabel = result[index + 1];

    const maximumCoordinate =
      nextLabel.coordinate -
      nextLabel.height / 2 -
      effectiveGap -
      currentLabel.height / 2;

    currentLabel.coordinate = Math.min(
      currentLabel.coordinate,
      maximumCoordinate,
    );
  }

  const firstLabel = result[0];
  const firstMinimumCoordinate =
    minCoordinate + firstLabel.height / 2;

  if (firstLabel.coordinate < firstMinimumCoordinate) {
    const offset =
      firstMinimumCoordinate - firstLabel.coordinate;

    result.forEach((label) => {
      label.coordinate += offset;
    });
  }

  return result;
}

function layoutCollisionGroup(
  labels: readonly MeasuredPriceAxisLabel[],
  axisHeight: number,
  gap: number,
  reservedCoordinate?: number,
  reservedHeight = 0,
): LaidOutPriceAxisLabel[] {
  if (reservedCoordinate === undefined) {
    return layoutRange(labels, 0, axisHeight, gap);
  }

  const coordinate = clamp(
    reservedCoordinate,
    0,
    axisHeight,
  );

  const halfReservedHeight =
    Math.max(0, reservedHeight) / 2;

  const reservedTop = Math.max(
    0,
    coordinate - halfReservedHeight,
  );

  const reservedBottom = Math.min(
    axisHeight,
    coordinate + halfReservedHeight,
  );

  const labelsAbove = labels.filter(
    (label) =>
      label.desiredCoordinate <= coordinate,
  );

  const labelsBelow = labels.filter(
    (label) =>
      label.desiredCoordinate > coordinate,
  );

  return [
    ...layoutRange(
      labelsAbove,
      0,
      Math.max(0, reservedTop - gap),
      gap,
    ),
    ...layoutRange(
      labelsBelow,
      Math.min(axisHeight, reservedBottom + gap),
      axisHeight,
      gap,
    ),
  ];
}

export function layoutPriceAxisLabels(
  labels: readonly MeasuredPriceAxisLabel[],
  axisHeight: number,
  options: PriceAxisLabelsLayoutOptions = {},
): LaidOutPriceAxisLabel[] {
  if (axisHeight <= 0 || labels.length === 0) {
    return [];
  }

  const {
    gap = DEFAULT_GAP,
    duplicateTolerance = DEFAULT_DUPLICATE_TOLERANCE,
    reservedCoordinate,
    reservedHeight,
  } = options;

  const groups = new Map<
    string,
    MeasuredPriceAxisLabel[]
  >();

  labels.forEach((label) => {
    if (!Number.isFinite(label.desiredCoordinate)) {
      return;
    }

    const group = groups.get(label.collisionGroup);

    if (group) {
      group.push(label);
    } else {
      groups.set(label.collisionGroup, [label]);
    }
  });

  const result: LaidOutPriceAxisLabel[] = [];

  groups.forEach((groupLabels) => {
    const uniqueLabels = deduplicateLabels(
      groupLabels,
      duplicateTolerance,
    );

    result.push(
      ...layoutCollisionGroup(
        uniqueLabels,
        axisHeight,
        gap,
        reservedCoordinate,
        reservedHeight,
      ),
    );
  });

  return result.sort((left, right) => {
    if (left.priority !== right.priority) {
      return left.priority - right.priority;
    }

    return left.id.localeCompare(right.id);
  });
}