react-native-chart-kit
Version:
Beautiful React Native charts for dashboards, reports, and data-rich mobile apps.
290 lines (289 loc) • 9.46 kB
JavaScript
import { solveLabelCollision } from "../../../core/index";
import { clamp } from "./utils";
export const defaultLabelRotation = -35;
export const xLabelRowGap = 6;
export const xLabelBaselineOffset = 20;
const rotatedLabelClearance = 10;
export const dedupeXLabelCandidates = (candidates) => {
const seen = new Set();
return candidates.filter((candidate) => {
if (seen.has(candidate.text)) {
return false;
}
seen.add(candidate.text);
return true;
});
};
export const getMaxSize = (sizes) => {
return sizes.reduce((max, size) => ({
width: Math.max(max.width, size.width),
height: Math.max(max.height, size.height)
}), { width: 0, height: 0 });
};
const getRotatedLabelHeight = (sizes, rotation) => {
const maxSize = getMaxSize(sizes);
const radians = (Math.abs(rotation) * Math.PI) / 180;
return (maxSize.width * Math.sin(radians) +
maxSize.height * Math.cos(radians) +
rotatedLabelClearance);
};
const getXLabelHeight = ({ strategy, sizes, rotation, rows }) => {
if (strategy === "hide" || sizes.length === 0) {
return 0;
}
if (strategy === "rotate") {
return getRotatedLabelHeight(sizes, rotation);
}
const maxHeight = getMaxSize(sizes).height;
if (strategy === "stagger") {
return maxHeight * rows + xLabelRowGap * Math.max(0, rows - 1);
}
return maxHeight;
};
const getExplicitXLabelCollision = ({ labels, strategy, availableWidth, minGap, rotation }) => {
const allIndexes = labels.map((_, index) => index);
if (strategy === "hide") {
return {
strategy,
visibleIndexes: [],
rotation: 0,
rows: 0
};
}
if (strategy === "skip") {
const result = solveLabelCollision({
labels,
availableWidth,
allowRotate: false,
allowStagger: false,
minGap
});
return {
strategy: result.strategy === "hide" ? "hide" : result.strategy,
visibleIndexes: result.visibleIndexes,
rotation: 0,
rows: 1
};
}
return {
strategy,
visibleIndexes: allIndexes,
rotation: strategy === "rotate" ? rotation : 0,
rows: strategy === "stagger" ? 2 : 1
};
};
const getShiftedXLabel = ({ candidate, chartWidth, edgeLabelPolicy, rotation, textAnchor }) => {
if (edgeLabelPolicy === "show") {
return candidate.x;
}
const margin = 4;
const width = rotation === 0
? candidate.size.width
: candidate.size.width * Math.cos((Math.abs(rotation) * Math.PI) / 180);
const leftExtent = textAnchor === "start" ? 0 : textAnchor === "end" ? width : width / 2;
const rightExtent = textAnchor === "start" ? width : textAnchor === "end" ? 0 : width / 2;
const min = margin + leftExtent;
const max = chartWidth - margin - rightExtent;
return clamp(candidate.x, min, max);
};
const getXLabelTextAnchor = ({ candidate, candidates, rotation }) => {
if (rotation === 0) {
return "middle";
}
if (rotation < 0) {
return candidate.index === candidates[0]?.index ? "start" : "end";
}
return candidate.index === candidates[candidates.length - 1]?.index
? "end"
: "start";
};
const getRotatedBaselineOffset = ({ rotation, size, textAnchor }) => {
if (rotation === 0) {
return 0;
}
const radians = (Math.abs(rotation) * Math.PI) / 180;
const widthProjection = size.width * Math.sin(radians);
if (rotation < 0 && textAnchor === "start") {
return widthProjection;
}
if (rotation > 0 && textAnchor === "end") {
return widthProjection;
}
return 0;
};
const getXLabelHorizontalBounds = (label) => {
if (label.rotation === 0) {
const leftExtent = label.textAnchor === "start"
? 0
: label.textAnchor === "end"
? label.size.width
: label.size.width / 2;
const rightExtent = label.textAnchor === "start"
? label.size.width
: label.textAnchor === "end"
? 0
: label.size.width / 2;
return {
left: label.x - leftExtent,
right: label.x + rightExtent
};
}
const radians = (Math.abs(label.rotation) * Math.PI) / 180;
const projectedWidth = label.size.width * Math.cos(radians) +
label.size.height * Math.sin(radians);
const leftExtent = label.textAnchor === "start"
? 0
: label.textAnchor === "end"
? projectedWidth
: projectedWidth / 2;
const rightExtent = label.textAnchor === "start"
? projectedWidth
: label.textAnchor === "end"
? 0
: projectedWidth / 2;
return {
left: label.x - leftExtent,
right: label.x + rightExtent
};
};
const filterOverlappingRotatedLabels = (items, minGap) => {
if (items.length <= 1) {
return items;
}
const accepted = [];
const lastIndex = items.length - 1;
items.forEach((item, index) => {
const bounds = getXLabelHorizontalBounds(item);
const overlapsPrevious = () => {
const previous = accepted[accepted.length - 1];
return previous ? previous.bounds.right + minGap > bounds.left : false;
};
if (index === lastIndex) {
while (overlapsPrevious()) {
accepted.pop();
}
accepted.push({ bounds, item });
return;
}
if (!overlapsPrevious()) {
accepted.push({ bounds, item });
}
});
return accepted.map(({ item }) => item);
};
const isXLabelInsideChart = ({ candidate, chartWidth, x, textAnchor }) => {
const margin = 4;
const leftExtent = textAnchor === "start"
? 0
: textAnchor === "end"
? candidate.size.width
: candidate.size.width / 2;
const rightExtent = textAnchor === "start"
? candidate.size.width
: textAnchor === "end"
? 0
: candidate.size.width / 2;
return x - leftExtent >= margin && x + rightExtent <= chartWidth - margin;
};
export const resolveXLabelLayout = ({ candidates, plotWidth, chartWidth, strategy, rotation, edgeLabelPolicy, minGap, baseY }) => {
const labels = candidates.map((candidate) => ({
id: String(candidate.index),
text: candidate.text,
x: candidate.x - candidate.size.width / 2,
y: 0,
width: candidate.size.width,
height: candidate.size.height
}));
const getAutoCollision = () => {
return solveLabelCollision({
labels,
availableWidth: plotWidth,
allowRotate: false,
allowStagger: false,
minGap
});
};
const collision = strategy === "auto"
? getAutoCollision()
: getExplicitXLabelCollision({
labels,
strategy,
availableWidth: plotWidth,
minGap,
rotation
});
const resolvedStrategy = collision.strategy;
const resolvedRotation = resolvedStrategy === "rotate"
? rotation < 0
? rotation
: -Math.abs(rotation)
: 0;
const rows = resolvedStrategy === "stagger" ? 2 : collision.rows;
const sizes = collision.visibleIndexes.flatMap((candidateIndex) => {
const candidate = candidates[candidateIndex];
return candidate ? [candidate.size] : [];
});
const height = getXLabelHeight({
strategy: resolvedStrategy,
sizes,
rotation: resolvedRotation,
rows
});
const items = collision.visibleIndexes.flatMap((candidateIndex) => {
const candidate = candidates[candidateIndex];
if (!candidate) {
return [];
}
const row = resolvedStrategy === "stagger" ? candidate.index % rows : 0;
const textAnchor = getXLabelTextAnchor({
candidate,
candidates,
rotation: resolvedRotation
});
const baselineOffset = resolvedStrategy === "rotate"
? getRotatedBaselineOffset({
rotation: resolvedRotation,
size: candidate.size,
textAnchor
})
: 0;
const y = baseY + row * (candidate.size.height + xLabelRowGap) + baselineOffset;
const x = getShiftedXLabel({
candidate,
chartWidth,
edgeLabelPolicy,
rotation: resolvedRotation,
textAnchor
});
if (edgeLabelPolicy === "hide" &&
!isXLabelInsideChart({
candidate,
chartWidth,
x,
textAnchor
})) {
return [];
}
return [
{
...candidate,
gridX: candidate.x,
x,
y,
row,
rotation: resolvedRotation,
textAnchor
}
];
});
const visibleItems = resolvedStrategy === "rotate"
? filterOverlappingRotatedLabels(items, minGap)
: items;
return {
strategy: resolvedStrategy,
items: visibleItems,
height,
rotation: resolvedRotation,
rows
};
};