Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ const DEFAULT_DISCRETE_SETTINGS = {
* @type {boolean=}
*/
filterOverlapping: false,
/** Word break mode for wrapping long labels. `false` (default) truncates with ellipsis.
* Accepts `'break-word'` (break at word boundaries) or `'break-all'` (break at any character).
* Only effective for horizontal (top/bottom) axes in horizontal or auto mode.
* @type {boolean|string=} */
wordBreak: false,
/** Maximum number of lines when wordBreak is active. Excess text gets ellipsis on the last line.
* @type {number=} */
maxLines: 2,
},
/**
* @typedef {object}
Expand Down Expand Up @@ -170,6 +178,14 @@ const DEFAULT_CONTINUOUS_SETTINGS = {
* @type {boolean=}
*/
filterOverlapping: true,
/** Word break mode for wrapping long labels. `false` (default) truncates with ellipsis.
* Accepts `'break-word'` (break at word boundaries) or `'break-all'` (break at any character).
* Only effective for horizontal (top/bottom) axes.
* @type {boolean|string=} */
wordBreak: false,
/** Maximum number of lines when wordBreak is active. Excess text gets ellipsis on the last line.
* @type {number=} */
maxLines: 2,
},
/**
* @typedef {object}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ export default function buildNode(tick, buildOpts) {
y: 0,
maxWidth: buildOpts.maxWidth,
maxHeight: buildOpts.maxHeight,
wordBreak: buildOpts.wordBreak || undefined,
maxLines: buildOpts.wordBreak ? buildOpts.maxLines : undefined,
tickValue: tick.value ?? tick.data?.value,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export default function getSize({ isDiscrete, rect, formatter, measureText, scal

if (isDiscrete && horizontal && settings.labels.mode === 'auto') {
if (
!settings.labels.wordBreak &&
shouldAutoTilt({
majorTicks,
measure,
Expand All @@ -167,6 +168,7 @@ export default function getSize({ isDiscrete, rect, formatter, measureText, scal
) {
state.labels.activeMode = 'tilted';
} else {
// wordBreak suppresses auto-tilt; horizontal wrapping handles overflow instead
state.labels.activeMode = 'horizontal';
}
}
Expand All @@ -189,10 +191,19 @@ export default function getSize({ isDiscrete, rect, formatter, measureText, scal
let sizeFromTextRect;
if (state.labels.activeMode === 'tilted') {
const radians = Math.abs(settings.labels.tiltAngle) * (Math.PI / 180); // angle in radians
let lineMultiplier = 1;
if (settings.labels.wordBreak) {
const h = measure('M').height;
const slotPx = scale.bandwidth() * rect.inner.width;
const fitsInSlot = Math.max(1, Math.floor((slotPx * Math.sin(radians)) / h));
lineMultiplier = Math.min(settings.labels.maxLines, fitsInSlot);
}
sizeFromTextRect = (r) =>
getClampedValue({ value: r.width, maxValue, minValue }) * Math.sin(radians) + r.height * Math.cos(radians);
getClampedValue({ value: r.width, maxValue, minValue }) * Math.sin(radians) +
r.height * lineMultiplier * Math.cos(radians);
} else if (horizontal) {
sizeFromTextRect = (r) => r.height;
const lineMultiplier = settings.labels.wordBreak ? settings.labels.maxLines : 1;
sizeFromTextRect = (r) => r.height * lineMultiplier;
} else {
sizeFromTextRect = (r) => getClampedValue({ value: r.width, maxValue, minValue });
}
Expand Down Expand Up @@ -223,8 +234,11 @@ export default function getSize({ isDiscrete, rect, formatter, measureText, scal
const extendLeft = (settings.align === 'bottom') === settings.labels.tiltAngle >= 0;
const radians = Math.abs(settings.labels.tiltAngle) * (Math.PI / 180); // angle in radians
const h = measure('M').height;
const maxWidth = (textSize - h * Math.cos(radians)) / Math.sin(radians);
const labelWidth = (r) => Math.min(maxWidth, r.width) * Math.cos(radians) + r.height;
const slotPx = scale.bandwidth() * rect.inner.width;
const fitsInSlot = Math.max(1, Math.floor((slotPx * Math.sin(radians)) / h));
const lineMultiplier = settings.labels.wordBreak ? Math.min(settings.labels.maxLines, fitsInSlot) : 1;
const maxWidth = (textSize - lineMultiplier * h * Math.cos(radians)) / Math.sin(radians);
const labelWidth = (r) => Math.min(maxWidth, r.width) * Math.cos(radians) + lineMultiplier * r.height;
const adjustByPosition = (s, i) => {
const pos = majorTicks[i] ? majorTicks[i].position : 0;
if (extendLeft) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,14 +151,33 @@ function discreteCalcMaxTextRect({ textMetrics, settings, innerRect, scale, tilt
const textRect = { width: 0, height: h };
if (settings.align === 'left' || settings.align === 'right') {
textRect.width = innerRect.width - labelsSpacing(settings) - settings.paddingEnd;
if (settings.labels.wordBreak) {
// Use the full bandwidth slot so word-break computes lines dynamically:
// effectiveLines = min(maxLines, floor(bandwidthPx / lineHeight))
textRect.height = bandwidth * innerRect.height;
}
} else if (layered) {
textRect.width = bandwidth * innerRect.width * 2;
} else if (tilted) {
const radians = Math.abs(settings.labels.tiltAngle) * (Math.PI / 180);
textRect.width =
(innerRect.height - labelsSpacing(settings) - settings.paddingEnd - h * Math.cos(radians)) / Math.sin(radians);
if (settings.labels.wordBreak) {
const slotPx = tickBandwidth(scale, tick) * innerRect.width;
const fitsInSlot = Math.max(1, Math.floor((slotPx * Math.sin(radians)) / h));
const lineMultiplier = Math.min(settings.labels.maxLines, fitsInSlot);
textRect.width =
(innerRect.height - labelsSpacing(settings) - settings.paddingEnd - lineMultiplier * h * Math.cos(radians)) /
Math.sin(radians);
textRect.height = lineMultiplier * h;
} else {
textRect.width =
(innerRect.height - labelsSpacing(settings) - settings.paddingEnd - h * Math.cos(radians)) / Math.sin(radians);
}
} else {
textRect.width = bandwidth * innerRect.width;
if (settings.labels.wordBreak) {
// For top/bottom axes: reserve fixed multi-line height in the dock
textRect.height = h * settings.labels.maxLines;
}
}

textRect.width = getClampedValue({
Expand Down Expand Up @@ -291,6 +310,8 @@ export default function nodeBuilder(isDiscrete) {
});
buildOpts.maxWidth = maxSize.width;
buildOpts.maxHeight = maxSize.height;
buildOpts.wordBreak = settings.labels.wordBreak;
buildOpts.maxLines = settings.labels.maxLines;
buildOpts.stepSize = getStepSizeFn({
innerRect,
scale,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,34 @@ function generateLineNodes(result, item, halfLead, height) {
container.id = item.id;
}

let currentY = 0;
// When the text node lives in a sized slot (maxHeight is set) and is not rotated,
// center the multi-line block vertically within the reserved slot.
//
// Two coordinate models:
//
// text-before-edge (left/right axis): axis-label-node's wiggle() already shifts y
// down by (maxHeight − h) / 2 to pre-center a single line. We only need to
// compensate for the extra spread of N lines:
// centeringOffset = (height − N×lineHeight) / 2
//
// alphabetic baseline (bottom/top axis): appendPadding sets y = padding + maxHeight
// (bottom of slot); text renders upward. No wiggle pre-centering, so:
// centeringOffset = height − (maxHeight + N×lineHeight) / 2
//
// Rotated (tilted) labels have a transform set and use a different positioning model.
const lineHeight = height + 2 * halfLead;
let centeringOffset = 0;
if (!isNaN(item.maxHeight) && !item.transform) {
const N = result.lines.length;
if (item.baseline === 'text-before-edge') {
// Wiggle pre-centers; adjust only for N-line spread relative to single line.
centeringOffset = (height - N * lineHeight) / 2;
} else {
// No pre-centering; y is at slot bottom, center the full block.
centeringOffset = height - (item.maxHeight + N * lineHeight) / 2;
}
}
let currentY = centeringOffset;

result.lines.forEach((line, i) => {
const node = extend({}, item);
Expand Down
Loading