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


import { LaidOutPriceAxisLabel, PRICE_AXIS_LABEL_HEIGHT, PriceAxisLabel, PriceAxisLabelsLayoutOptions } from './types';

const DEFAULT_LABEL_GAP = 2;
const DEFAULT_DUPLICATE_TOLERANCE = 1;

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

  const priorityDiff = (right.priority ?? 0) - (left.priority ?? 0);

  if (priorityDiff !== 0) {
    return priorityDiff;
  }

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

function isDuplicate(left: PriceAxisLabel, right: PriceAxisLabel, tolerance: number): boolean {
  return (
    left.text === right.text &&
    left.symbol === right.symbol &&
    Math.abs(left.desiredCoordinate - right.desiredCoordinate) <= tolerance
  );
}

function deduplicateLabels(labels: readonly PriceAxisLabel[], tolerance: number): PriceAxisLabel[] {
  const sortedLabels = [...labels].sort((left, right) => {
    const priorityDiff = (right.priority ?? 0) - (left.priority ?? 0);

    if (priorityDiff !== 0) {
      return priorityDiff;
    }

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

  const result: PriceAxisLabel[] = [];

  sortedLabels.forEach((label) => {
    const duplicateExists = result.some((currentLabel) => isDuplicate(currentLabel, label, tolerance));

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

  return result;
}

function createLaidOutLabel(label: PriceAxisLabel, coordinate: number): LaidOutPriceAxisLabel {
  return {
    ...label,
    coordinate,
    height: label.height ?? PRICE_AXIS_LABEL_HEIGHT,
  };
}

function distributeLabelsInsideSmallAxis(
  labels: readonly PriceAxisLabel[],
  axisHeight: number,
): LaidOutPriceAxisLabel[] {
  if (labels.length === 0) {
    return [];
  }

  if (labels.length === 1) {
    const label = labels[0];
    const height = label.height ?? PRICE_AXIS_LABEL_HEIGHT;
    const minCoordinate = height / 2;
    const maxCoordinate = Math.max(minCoordinate, axisHeight - height / 2);

    return [createLaidOutLabel(label, Math.min(Math.max(label.desiredCoordinate, minCoordinate), maxCoordinate))];
  }

  const firstHeight = labels[0].height ?? PRICE_AXIS_LABEL_HEIGHT;
  const lastHeight = labels[labels.length - 1].height ?? PRICE_AXIS_LABEL_HEIGHT;
  const minCoordinate = firstHeight / 2;
  const maxCoordinate = Math.max(minCoordinate, axisHeight - lastHeight / 2);
  const step = Math.max(0, (maxCoordinate - minCoordinate) / (labels.length - 1));

  return labels.map((label, index) => createLaidOutLabel(label, minCoordinate + step * index));
}

function resolveCollisionGroup(
  labels: readonly PriceAxisLabel[],
  axisHeight: number,
  gap: number,
): LaidOutPriceAxisLabel[] {
  const sortedLabels = [...labels].sort(compareLabels);

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

  const totalLabelsHeight = sortedLabels.reduce((total, label) => total + (label.height ?? PRICE_AXIS_LABEL_HEIGHT), 0);

  if (totalLabelsHeight > axisHeight) {
    return distributeLabelsInsideSmallAxis(sortedLabels, axisHeight);
  }

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

  const resolvedLabels = sortedLabels.map((label) => {
    const height = label.height ?? PRICE_AXIS_LABEL_HEIGHT;
    const minCoordinate = height / 2;
    const maxCoordinate = Math.max(minCoordinate, axisHeight - height / 2);

    return createLaidOutLabel(label, Math.min(Math.max(label.desiredCoordinate, minCoordinate), maxCoordinate));
  });

  for (let index = 1; index < resolvedLabels.length; index += 1) {
    const previousLabel = resolvedLabels[index - 1];
    const currentLabel = resolvedLabels[index];
    const minCoordinate = previousLabel.coordinate + previousLabel.height / 2 + currentLabel.height / 2 + effectiveGap;

    if (currentLabel.coordinate < minCoordinate) {
      currentLabel.coordinate = minCoordinate;
    }
  }

  const lastLabel = resolvedLabels[resolvedLabels.length - 1];
  const lastMaxCoordinate = axisHeight - lastLabel.height / 2;

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

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

  for (let index = resolvedLabels.length - 2; index >= 0; index -= 1) {
    const currentLabel = resolvedLabels[index];
    const nextLabel = resolvedLabels[index + 1];
    const maxCoordinate = nextLabel.coordinate - nextLabel.height / 2 - currentLabel.height / 2 - effectiveGap;

    if (currentLabel.coordinate > maxCoordinate) {
      currentLabel.coordinate = maxCoordinate;
    }
  }

  const firstLabel = resolvedLabels[0];
  const firstMinCoordinate = firstLabel.height / 2;

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

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

  return resolvedLabels;
}

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

  const gap = options.gap ?? DEFAULT_LABEL_GAP;
  const duplicateTolerance = options.duplicateTolerance ?? DEFAULT_DUPLICATE_TOLERANCE;
  const labelsByCollisionGroup = new Map<string, PriceAxisLabel[]>();

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

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

    if (group) {
      group.push(label);
      return;
    }

    labelsByCollisionGroup.set(label.collisionGroup, [label]);
  });

  const result: LaidOutPriceAxisLabel[] = [];

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

    result.push(...resolveCollisionGroup(uniqueLabels, axisHeight, gap));
  });

  return result.sort((left, right) => {
    const priorityDiff = (left.priority ?? 0) - (right.priority ?? 0);

    if (priorityDiff !== 0) {
      return priorityDiff;
    }

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