Skip to content

Commit d41d532

Browse files
nagixsimonbrunel
authored andcommitted
Fix tick label rotation and layout issues (chartjs#5961)
1 parent 084a99a commit d41d532

File tree

2 files changed

+200
-107
lines changed

2 files changed

+200
-107
lines changed

src/core/core.scale.js

Lines changed: 163 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -65,17 +65,6 @@ defaults._set('scale', {
6565
}
6666
});
6767

68-
function labelsFromTicks(ticks) {
69-
var labels = [];
70-
var i, ilen;
71-
72-
for (i = 0, ilen = ticks.length; i < ilen; ++i) {
73-
labels.push(ticks[i].label);
74-
}
75-
76-
return labels;
77-
}
78-
7968
function getPixelForGridLine(scale, index, offsetGridLines) {
8069
var lineValue = scale.getPixelForTick(index);
8170

@@ -93,10 +82,93 @@ function getPixelForGridLine(scale, index, offsetGridLines) {
9382
return lineValue;
9483
}
9584

96-
function computeTextSize(context, tick, font) {
97-
return helpers.isArray(tick) ?
98-
helpers.longestText(context, font, tick) :
99-
context.measureText(tick).width;
85+
function garbageCollect(caches, length) {
86+
helpers.each(caches, function(cache) {
87+
var gc = cache.gc;
88+
var gcLen = gc.length / 2;
89+
var i;
90+
if (gcLen > length) {
91+
for (i = 0; i < gcLen; ++i) {
92+
delete cache.data[gc[i]];
93+
}
94+
gc.splice(0, gcLen);
95+
}
96+
});
97+
}
98+
99+
/**
100+
* Returns {width, height, offset} objects for the first, last, widest, highest tick
101+
* labels where offset indicates the anchor point offset from the top in pixels.
102+
*/
103+
function computeLabelSizes(ctx, tickFonts, ticks, caches) {
104+
var length = ticks.length;
105+
var widths = [];
106+
var heights = [];
107+
var offsets = [];
108+
var i, j, jlen, label, tickFont, fontString, cache, lineHeight, width, height, nestedLabel, widest, highest;
109+
110+
for (i = 0; i < length; ++i) {
111+
label = ticks[i].label;
112+
tickFont = ticks[i].major ? tickFonts.major : tickFonts.minor;
113+
ctx.font = fontString = tickFont.string;
114+
cache = caches[fontString] = caches[fontString] || {data: {}, gc: []};
115+
lineHeight = tickFont.lineHeight;
116+
width = height = 0;
117+
// Undefined labels and arrays should not be measured
118+
if (!helpers.isNullOrUndef(label) && !helpers.isArray(label)) {
119+
width = helpers.measureText(ctx, cache.data, cache.gc, width, label);
120+
height = lineHeight;
121+
} else if (helpers.isArray(label)) {
122+
// if it is an array let's measure each element
123+
for (j = 0, jlen = label.length; j < jlen; ++j) {
124+
nestedLabel = label[j];
125+
// Undefined labels and arrays should not be measured
126+
if (!helpers.isNullOrUndef(nestedLabel) && !helpers.isArray(nestedLabel)) {
127+
width = helpers.measureText(ctx, cache.data, cache.gc, width, nestedLabel);
128+
height += lineHeight;
129+
}
130+
}
131+
}
132+
widths.push(width);
133+
heights.push(height);
134+
offsets.push(lineHeight / 2);
135+
}
136+
garbageCollect(caches, length);
137+
138+
widest = widths.indexOf(Math.max.apply(null, widths));
139+
highest = heights.indexOf(Math.max.apply(null, heights));
140+
141+
function valueAt(idx) {
142+
return {
143+
width: widths[idx] || 0,
144+
height: heights[idx] || 0,
145+
offset: offsets[idx] || 0
146+
};
147+
}
148+
149+
return {
150+
first: valueAt(0),
151+
last: valueAt(length - 1),
152+
widest: valueAt(widest),
153+
highest: valueAt(highest)
154+
};
155+
}
156+
157+
function getTickMarkLength(options) {
158+
return options.drawTicks ? options.tickMarkLength : 0;
159+
}
160+
161+
function getScaleLabelHeight(options) {
162+
var font, padding;
163+
164+
if (!options.display) {
165+
return 0;
166+
}
167+
168+
font = helpers.options._parseFont(options);
169+
padding = helpers.options.toPadding(options.padding);
170+
171+
return font.lineHeight + padding.height;
100172
}
101173

102174
function parseFontOptions(options, nestedOpts) {
@@ -330,39 +402,38 @@ module.exports = Element.extend({
330402
},
331403
calculateTickRotation: function() {
332404
var me = this;
333-
var context = me.ctx;
334-
var tickOpts = me.options.ticks;
335-
var labels = labelsFromTicks(me._ticks);
336-
337-
// Get the width of each grid by calculating the difference
338-
// between x offsets between 0 and 1.
339-
var tickFont = helpers.options._parseFont(tickOpts);
340-
context.font = tickFont.string;
341-
342-
var labelRotation = tickOpts.minRotation || 0;
343-
344-
if (labels.length && me.options.display && me.isHorizontal()) {
345-
var originalLabelWidth = helpers.longestText(context, tickFont.string, labels, me.longestTextCache);
346-
var labelWidth = originalLabelWidth;
347-
var cosRotation, sinRotation;
348-
349-
// Allow 3 pixels x2 padding either side for label readability
350-
var tickWidth = me.getPixelForTick(1) - me.getPixelForTick(0) - 6;
351-
352-
// Max label rotation can be set or default to 90 - also act as a loop counter
353-
while (labelWidth > tickWidth && labelRotation < tickOpts.maxRotation) {
354-
var angleRadians = helpers.toRadians(labelRotation);
355-
cosRotation = Math.cos(angleRadians);
356-
sinRotation = Math.sin(angleRadians);
357-
358-
if (sinRotation * originalLabelWidth > me.maxHeight) {
359-
// go back one step
360-
labelRotation--;
361-
break;
405+
var options = me.options;
406+
var tickOpts = options.ticks;
407+
var ticks = me.getTicks();
408+
var minRotation = tickOpts.minRotation || 0;
409+
var maxRotation = tickOpts.maxRotation;
410+
var labelRotation = minRotation;
411+
var labelSizes, maxLabelWidth, maxLabelHeight, maxWidth, tickWidth, maxHeight, maxLabelDiagonal;
412+
413+
if (me._isVisible() && tickOpts.display) {
414+
labelSizes = me._labelSizes = computeLabelSizes(me.ctx, parseTickFontOptions(tickOpts), ticks, me.longestTextCache);
415+
416+
if (minRotation < maxRotation && ticks.length > 1 && me.isHorizontal()) {
417+
maxLabelWidth = labelSizes.widest.width;
418+
maxLabelHeight = labelSizes.highest.height - labelSizes.highest.offset;
419+
420+
// Estimate the width of each grid based on the canvas width, the maximum
421+
// label width and the number of tick intervals
422+
maxWidth = Math.min(me.maxWidth, me.chart.width - maxLabelWidth);
423+
tickWidth = options.offset ? me.maxWidth / ticks.length : maxWidth / (ticks.length - 1);
424+
425+
// Allow 3 pixels x2 padding either side for label readability
426+
if (maxLabelWidth + 6 > tickWidth) {
427+
tickWidth = maxWidth / (ticks.length - (options.offset ? 0.5 : 1));
428+
maxHeight = me.maxHeight - getTickMarkLength(options.gridLines)
429+
- tickOpts.padding - getScaleLabelHeight(options.scaleLabel);
430+
maxLabelDiagonal = Math.sqrt(maxLabelWidth * maxLabelWidth + maxLabelHeight * maxLabelHeight);
431+
labelRotation = helpers.toDegrees(Math.min(
432+
Math.asin(Math.min((labelSizes.highest.height + 6) / tickWidth, 1)),
433+
Math.asin(Math.min(maxHeight / maxLabelDiagonal, 1)) - Math.asin(maxLabelHeight / maxLabelDiagonal)
434+
));
435+
labelRotation = Math.max(minRotation, Math.min(maxRotation, labelRotation));
362436
}
363-
364-
labelRotation++;
365-
labelWidth = cosRotation * originalLabelWidth;
366437
}
367438
}
368439

@@ -385,8 +456,7 @@ module.exports = Element.extend({
385456
height: 0
386457
};
387458

388-
var labels = labelsFromTicks(me._ticks);
389-
459+
var ticks = me.getTicks();
390460
var opts = me.options;
391461
var tickOpts = opts.ticks;
392462
var scaleLabelOpts = opts.scaleLabel;
@@ -395,94 +465,81 @@ module.exports = Element.extend({
395465
var position = opts.position;
396466
var isHorizontal = me.isHorizontal();
397467

398-
var parseFont = helpers.options._parseFont;
399-
var tickFont = parseFont(tickOpts);
400-
var tickMarkLength = opts.gridLines.tickMarkLength;
401-
402468
// Width
403469
if (isHorizontal) {
404470
// subtract the margins to line up with the chartArea if we are a full width scale
405471
minSize.width = me.isFullWidth() ? me.maxWidth - me.margins.left - me.margins.right : me.maxWidth;
406-
} else {
407-
minSize.width = display && gridLineOpts.drawTicks ? tickMarkLength : 0;
472+
} else if (display) {
473+
minSize.width = getTickMarkLength(gridLineOpts) + getScaleLabelHeight(scaleLabelOpts);
408474
}
409475

410476
// height
411-
if (isHorizontal) {
412-
minSize.height = display && gridLineOpts.drawTicks ? tickMarkLength : 0;
413-
} else {
477+
if (!isHorizontal) {
414478
minSize.height = me.maxHeight; // fill all the height
415-
}
416-
417-
// Are we showing a title for the scale?
418-
if (scaleLabelOpts.display && display) {
419-
var scaleLabelFont = parseFont(scaleLabelOpts);
420-
var scaleLabelPadding = helpers.options.toPadding(scaleLabelOpts.padding);
421-
var deltaHeight = scaleLabelFont.lineHeight + scaleLabelPadding.height;
422-
423-
if (isHorizontal) {
424-
minSize.height += deltaHeight;
425-
} else {
426-
minSize.width += deltaHeight;
427-
}
479+
} else if (display) {
480+
minSize.height = getTickMarkLength(gridLineOpts) + getScaleLabelHeight(scaleLabelOpts);
428481
}
429482

430483
// Don't bother fitting the ticks if we are not showing the labels
431484
if (tickOpts.display && display) {
432-
var largestTextWidth = helpers.longestText(me.ctx, tickFont.string, labels, me.longestTextCache);
433-
var tallestLabelHeightInLines = helpers.numberOfLabelLines(labels);
434-
var lineSpace = tickFont.size * 0.5;
435-
var tickPadding = me.options.ticks.padding;
436-
437-
// Store max number of lines and widest label for _autoSkip
438-
me._maxLabelLines = tallestLabelHeightInLines;
439-
me.longestLabelWidth = largestTextWidth;
485+
var tickFonts = parseTickFontOptions(tickOpts);
486+
var labelSizes = me._labelSizes;
487+
var firstLabelSize = labelSizes.first;
488+
var lastLabelSize = labelSizes.last;
489+
var widestLabelSize = labelSizes.widest;
490+
var highestLabelSize = labelSizes.highest;
491+
var lineSpace = tickFonts.minor.lineHeight * 0.4;
492+
var tickPadding = tickOpts.padding;
440493

441494
if (isHorizontal) {
495+
// A horizontal axis is more constrained by the height.
496+
me.longestLabelWidth = widestLabelSize.width;
497+
498+
var isRotated = me.labelRotation !== 0;
442499
var angleRadians = helpers.toRadians(me.labelRotation);
443500
var cosRotation = Math.cos(angleRadians);
444501
var sinRotation = Math.sin(angleRadians);
445502

446-
// TODO - improve this calculation
447-
var labelHeight = (sinRotation * largestTextWidth)
448-
+ (tickFont.lineHeight * tallestLabelHeightInLines)
449-
+ lineSpace; // padding
503+
var labelHeight = sinRotation * widestLabelSize.width
504+
+ cosRotation * (highestLabelSize.height - (isRotated ? highestLabelSize.offset : 0))
505+
+ (isRotated ? 0 : lineSpace); // padding
450506

451507
minSize.height = Math.min(me.maxHeight, minSize.height + labelHeight + tickPadding);
452508

453-
me.ctx.font = tickFont.string;
454-
var firstLabelWidth = computeTextSize(me.ctx, labels[0], tickFont.string);
455-
var lastLabelWidth = computeTextSize(me.ctx, labels[labels.length - 1], tickFont.string);
456509
var offsetLeft = me.getPixelForTick(0) - me.left;
457-
var offsetRight = me.right - me.getPixelForTick(labels.length - 1);
510+
var offsetRight = me.right - me.getPixelForTick(ticks.length - 1);
458511
var paddingLeft, paddingRight;
459512

460513
// Ensure that our ticks are always inside the canvas. When rotated, ticks are right aligned
461514
// which means that the right padding is dominated by the font height
462-
if (me.labelRotation !== 0) {
463-
paddingLeft = position === 'bottom' ? (cosRotation * firstLabelWidth) : (cosRotation * lineSpace);
464-
paddingRight = position === 'bottom' ? (cosRotation * lineSpace) : (cosRotation * lastLabelWidth);
515+
if (isRotated) {
516+
paddingLeft = position === 'bottom' ?
517+
cosRotation * firstLabelSize.width + sinRotation * firstLabelSize.offset :
518+
sinRotation * (firstLabelSize.height - firstLabelSize.offset);
519+
paddingRight = position === 'bottom' ?
520+
sinRotation * (lastLabelSize.height - lastLabelSize.offset) :
521+
cosRotation * lastLabelSize.width + sinRotation * lastLabelSize.offset;
465522
} else {
466-
paddingLeft = firstLabelWidth / 2;
467-
paddingRight = lastLabelWidth / 2;
523+
paddingLeft = firstLabelSize.width / 2;
524+
paddingRight = lastLabelSize.width / 2;
468525
}
469-
me.paddingLeft = Math.max(paddingLeft - offsetLeft, 0) + 3; // add 3 px to move away from canvas edges
470-
me.paddingRight = Math.max(paddingRight - offsetRight, 0) + 3;
526+
527+
// Adjust padding taking into account changes in offsets
528+
// and add 3 px to move away from canvas edges
529+
me.paddingLeft = Math.max((paddingLeft - offsetLeft) * me.width / (me.width - offsetLeft), 0) + 3;
530+
me.paddingRight = Math.max((paddingRight - offsetRight) * me.width / (me.width - offsetRight), 0) + 3;
471531
} else {
472532
// A vertical axis is more constrained by the width. Labels are the
473533
// dominant factor here, so get that length first and account for padding
474-
if (tickOpts.mirror) {
475-
largestTextWidth = 0;
476-
} else {
534+
var labelWidth = tickOpts.mirror ? 0 :
477535
// use lineSpace for consistency with horizontal axis
478536
// tickPadding is not implemented for horizontal
479-
largestTextWidth += tickPadding + lineSpace;
480-
}
537+
widestLabelSize.width + tickPadding + lineSpace;
481538

482-
minSize.width = Math.min(me.maxWidth, minSize.width + largestTextWidth);
539+
minSize.width = Math.min(me.maxWidth, minSize.width + labelWidth);
483540

484-
me.paddingTop = tickFont.size / 2;
485-
me.paddingBottom = tickFont.size / 2;
541+
me.paddingTop = firstLabelSize.height / 2;
542+
me.paddingBottom = lastLabelSize.height / 2;
486543
}
487544
}
488545

@@ -685,11 +742,10 @@ module.exports = Element.extend({
685742
var cos = Math.abs(Math.cos(rot));
686743
var sin = Math.abs(Math.sin(rot));
687744

745+
var labelSizes = me._labelSizes;
688746
var padding = optionTicks.autoSkipPadding || 0;
689-
var w = (me.longestLabelWidth + padding) || 0;
690-
691-
var tickFont = parseTickFontOptions(optionTicks).minor;
692-
var h = (me._maxLabelLines * tickFont.lineHeight + padding) || 0;
747+
var w = labelSizes ? labelSizes.widest.width + padding : 0;
748+
var h = labelSizes ? labelSizes.highest.height + padding : 0;
693749

694750
// Calculate space needed for 1 tick in axis direction.
695751
return isHorizontal
@@ -751,7 +807,7 @@ module.exports = Element.extend({
751807
var tickPadding = optionTicks.padding;
752808
var labelOffset = optionTicks.labelOffset;
753809

754-
var tl = gridLines.drawTicks ? gridLines.tickMarkLength : 0;
810+
var tl = getTickMarkLength(gridLines);
755811

756812
var scaleLabelFontColor = valueOrDefault(scaleLabel.fontColor, defaults.global.defaultFontColor);
757813
var scaleLabelFont = helpers.options._parseFont(scaleLabel);

test/specs/core.scale.tests.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,43 @@ describe('Core.scale', function() {
208208
});
209209
});
210210

211+
it('should add the correct padding for long tick labels', function() {
212+
var chart = window.acquireChart({
213+
type: 'line',
214+
data: {
215+
labels: [
216+
'This is a very long label',
217+
'This is a very long label'
218+
],
219+
datasets: [{
220+
data: [0, 1]
221+
}]
222+
},
223+
options: {
224+
scales: {
225+
xAxes: [{
226+
id: 'foo'
227+
}],
228+
yAxes: [{
229+
display: false
230+
}]
231+
},
232+
legend: {
233+
display: false
234+
}
235+
}
236+
}, {
237+
canvas: {
238+
height: 100,
239+
width: 200
240+
}
241+
});
242+
243+
var scale = chart.scales.foo;
244+
expect(scale.left).toBeGreaterThan(100);
245+
expect(scale.right).toBeGreaterThan(190);
246+
});
247+
211248
describe('given the axes display option is set to auto', function() {
212249
describe('for the x axes', function() {
213250
it('should draw the axes if at least one associated dataset is visible', function() {

0 commit comments

Comments
 (0)