Загрузка данных
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);
});
}