]> git.ipfire.org Git - thirdparty/Chart.js.git/commitdiff
Fix tick label rotation and layout issues (#5961)
authorAkihiko Kusanagi <nagi@nagi-p.com>
Tue, 30 Apr 2019 06:55:04 +0000 (14:55 +0800)
committerSimon Brunel <simonbrunel@users.noreply.github.com>
Tue, 30 Apr 2019 06:55:04 +0000 (08:55 +0200)
src/core/core.scale.js
test/specs/core.scale.tests.js

index 196ca4a684315b113556bbfd031281950ec39256..a4cd62317ccaa8d38076be6658fb3ff38ef0c400 100644 (file)
@@ -65,17 +65,6 @@ defaults._set('scale', {
        }
 });
 
-function labelsFromTicks(ticks) {
-       var labels = [];
-       var i, ilen;
-
-       for (i = 0, ilen = ticks.length; i < ilen; ++i) {
-               labels.push(ticks[i].label);
-       }
-
-       return labels;
-}
-
 function getPixelForGridLine(scale, index, offsetGridLines) {
        var lineValue = scale.getPixelForTick(index);
 
@@ -93,10 +82,93 @@ function getPixelForGridLine(scale, index, offsetGridLines) {
        return lineValue;
 }
 
-function computeTextSize(context, tick, font) {
-       return helpers.isArray(tick) ?
-               helpers.longestText(context, font, tick) :
-               context.measureText(tick).width;
+function garbageCollect(caches, length) {
+       helpers.each(caches, function(cache) {
+               var gc = cache.gc;
+               var gcLen = gc.length / 2;
+               var i;
+               if (gcLen > length) {
+                       for (i = 0; i < gcLen; ++i) {
+                               delete cache.data[gc[i]];
+                       }
+                       gc.splice(0, gcLen);
+               }
+       });
+}
+
+/**
+ * Returns {width, height, offset} objects for the first, last, widest, highest tick
+ * labels where offset indicates the anchor point offset from the top in pixels.
+ */
+function computeLabelSizes(ctx, tickFonts, ticks, caches) {
+       var length = ticks.length;
+       var widths = [];
+       var heights = [];
+       var offsets = [];
+       var i, j, jlen, label, tickFont, fontString, cache, lineHeight, width, height, nestedLabel, widest, highest;
+
+       for (i = 0; i < length; ++i) {
+               label = ticks[i].label;
+               tickFont = ticks[i].major ? tickFonts.major : tickFonts.minor;
+               ctx.font = fontString = tickFont.string;
+               cache = caches[fontString] = caches[fontString] || {data: {}, gc: []};
+               lineHeight = tickFont.lineHeight;
+               width = height = 0;
+               // Undefined labels and arrays should not be measured
+               if (!helpers.isNullOrUndef(label) && !helpers.isArray(label)) {
+                       width = helpers.measureText(ctx, cache.data, cache.gc, width, label);
+                       height = lineHeight;
+               } else if (helpers.isArray(label)) {
+                       // if it is an array let's measure each element
+                       for (j = 0, jlen = label.length; j < jlen; ++j) {
+                               nestedLabel = label[j];
+                               // Undefined labels and arrays should not be measured
+                               if (!helpers.isNullOrUndef(nestedLabel) && !helpers.isArray(nestedLabel)) {
+                                       width = helpers.measureText(ctx, cache.data, cache.gc, width, nestedLabel);
+                                       height += lineHeight;
+                               }
+                       }
+               }
+               widths.push(width);
+               heights.push(height);
+               offsets.push(lineHeight / 2);
+       }
+       garbageCollect(caches, length);
+
+       widest = widths.indexOf(Math.max.apply(null, widths));
+       highest = heights.indexOf(Math.max.apply(null, heights));
+
+       function valueAt(idx) {
+               return {
+                       width: widths[idx] || 0,
+                       height: heights[idx] || 0,
+                       offset: offsets[idx] || 0
+               };
+       }
+
+       return {
+               first: valueAt(0),
+               last: valueAt(length - 1),
+               widest: valueAt(widest),
+               highest: valueAt(highest)
+       };
+}
+
+function getTickMarkLength(options) {
+       return options.drawTicks ? options.tickMarkLength : 0;
+}
+
+function getScaleLabelHeight(options) {
+       var font, padding;
+
+       if (!options.display) {
+               return 0;
+       }
+
+       font = helpers.options._parseFont(options);
+       padding = helpers.options.toPadding(options.padding);
+
+       return font.lineHeight + padding.height;
 }
 
 function parseFontOptions(options, nestedOpts) {
@@ -330,39 +402,38 @@ module.exports = Element.extend({
        },
        calculateTickRotation: function() {
                var me = this;
-               var context = me.ctx;
-               var tickOpts = me.options.ticks;
-               var labels = labelsFromTicks(me._ticks);
-
-               // Get the width of each grid by calculating the difference
-               // between x offsets between 0 and 1.
-               var tickFont = helpers.options._parseFont(tickOpts);
-               context.font = tickFont.string;
-
-               var labelRotation = tickOpts.minRotation || 0;
-
-               if (labels.length && me.options.display && me.isHorizontal()) {
-                       var originalLabelWidth = helpers.longestText(context, tickFont.string, labels, me.longestTextCache);
-                       var labelWidth = originalLabelWidth;
-                       var cosRotation, sinRotation;
-
-                       // Allow 3 pixels x2 padding either side for label readability
-                       var tickWidth = me.getPixelForTick(1) - me.getPixelForTick(0) - 6;
-
-                       // Max label rotation can be set or default to 90 - also act as a loop counter
-                       while (labelWidth > tickWidth && labelRotation < tickOpts.maxRotation) {
-                               var angleRadians = helpers.toRadians(labelRotation);
-                               cosRotation = Math.cos(angleRadians);
-                               sinRotation = Math.sin(angleRadians);
-
-                               if (sinRotation * originalLabelWidth > me.maxHeight) {
-                                       // go back one step
-                                       labelRotation--;
-                                       break;
+               var options = me.options;
+               var tickOpts = options.ticks;
+               var ticks = me.getTicks();
+               var minRotation = tickOpts.minRotation || 0;
+               var maxRotation = tickOpts.maxRotation;
+               var labelRotation = minRotation;
+               var labelSizes, maxLabelWidth, maxLabelHeight, maxWidth, tickWidth, maxHeight, maxLabelDiagonal;
+
+               if (me._isVisible() && tickOpts.display) {
+                       labelSizes = me._labelSizes = computeLabelSizes(me.ctx, parseTickFontOptions(tickOpts), ticks, me.longestTextCache);
+
+                       if (minRotation < maxRotation && ticks.length > 1 && me.isHorizontal()) {
+                               maxLabelWidth = labelSizes.widest.width;
+                               maxLabelHeight = labelSizes.highest.height - labelSizes.highest.offset;
+
+                               // Estimate the width of each grid based on the canvas width, the maximum
+                               // label width and the number of tick intervals
+                               maxWidth = Math.min(me.maxWidth, me.chart.width - maxLabelWidth);
+                               tickWidth = options.offset ? me.maxWidth / ticks.length : maxWidth / (ticks.length - 1);
+
+                               // Allow 3 pixels x2 padding either side for label readability
+                               if (maxLabelWidth + 6 > tickWidth) {
+                                       tickWidth = maxWidth / (ticks.length - (options.offset ? 0.5 : 1));
+                                       maxHeight = me.maxHeight - getTickMarkLength(options.gridLines)
+                                               - tickOpts.padding - getScaleLabelHeight(options.scaleLabel);
+                                       maxLabelDiagonal = Math.sqrt(maxLabelWidth * maxLabelWidth + maxLabelHeight * maxLabelHeight);
+                                       labelRotation = helpers.toDegrees(Math.min(
+                                               Math.asin(Math.min((labelSizes.highest.height + 6) / tickWidth, 1)),
+                                               Math.asin(Math.min(maxHeight / maxLabelDiagonal, 1)) - Math.asin(maxLabelHeight / maxLabelDiagonal)
+                                       ));
+                                       labelRotation = Math.max(minRotation, Math.min(maxRotation, labelRotation));
                                }
-
-                               labelRotation++;
-                               labelWidth = cosRotation * originalLabelWidth;
                        }
                }
 
@@ -385,8 +456,7 @@ module.exports = Element.extend({
                        height: 0
                };
 
-               var labels = labelsFromTicks(me._ticks);
-
+               var ticks = me.getTicks();
                var opts = me.options;
                var tickOpts = opts.ticks;
                var scaleLabelOpts = opts.scaleLabel;
@@ -395,94 +465,81 @@ module.exports = Element.extend({
                var position = opts.position;
                var isHorizontal = me.isHorizontal();
 
-               var parseFont = helpers.options._parseFont;
-               var tickFont = parseFont(tickOpts);
-               var tickMarkLength = opts.gridLines.tickMarkLength;
-
                // Width
                if (isHorizontal) {
                        // subtract the margins to line up with the chartArea if we are a full width scale
                        minSize.width = me.isFullWidth() ? me.maxWidth - me.margins.left - me.margins.right : me.maxWidth;
-               } else {
-                       minSize.width = display && gridLineOpts.drawTicks ? tickMarkLength : 0;
+               } else if (display) {
+                       minSize.width = getTickMarkLength(gridLineOpts) + getScaleLabelHeight(scaleLabelOpts);
                }
 
                // height
-               if (isHorizontal) {
-                       minSize.height = display && gridLineOpts.drawTicks ? tickMarkLength : 0;
-               } else {
+               if (!isHorizontal) {
                        minSize.height = me.maxHeight; // fill all the height
-               }
-
-               // Are we showing a title for the scale?
-               if (scaleLabelOpts.display && display) {
-                       var scaleLabelFont = parseFont(scaleLabelOpts);
-                       var scaleLabelPadding = helpers.options.toPadding(scaleLabelOpts.padding);
-                       var deltaHeight = scaleLabelFont.lineHeight + scaleLabelPadding.height;
-
-                       if (isHorizontal) {
-                               minSize.height += deltaHeight;
-                       } else {
-                               minSize.width += deltaHeight;
-                       }
+               } else if (display) {
+                       minSize.height = getTickMarkLength(gridLineOpts) + getScaleLabelHeight(scaleLabelOpts);
                }
 
                // Don't bother fitting the ticks if we are not showing the labels
                if (tickOpts.display && display) {
-                       var largestTextWidth = helpers.longestText(me.ctx, tickFont.string, labels, me.longestTextCache);
-                       var tallestLabelHeightInLines = helpers.numberOfLabelLines(labels);
-                       var lineSpace = tickFont.size * 0.5;
-                       var tickPadding = me.options.ticks.padding;
-
-                       // Store max number of lines and widest label for _autoSkip
-                       me._maxLabelLines = tallestLabelHeightInLines;
-                       me.longestLabelWidth = largestTextWidth;
+                       var tickFonts = parseTickFontOptions(tickOpts);
+                       var labelSizes = me._labelSizes;
+                       var firstLabelSize = labelSizes.first;
+                       var lastLabelSize = labelSizes.last;
+                       var widestLabelSize = labelSizes.widest;
+                       var highestLabelSize = labelSizes.highest;
+                       var lineSpace = tickFonts.minor.lineHeight * 0.4;
+                       var tickPadding = tickOpts.padding;
 
                        if (isHorizontal) {
+                               // A horizontal axis is more constrained by the height.
+                               me.longestLabelWidth = widestLabelSize.width;
+
+                               var isRotated = me.labelRotation !== 0;
                                var angleRadians = helpers.toRadians(me.labelRotation);
                                var cosRotation = Math.cos(angleRadians);
                                var sinRotation = Math.sin(angleRadians);
 
-                               // TODO - improve this calculation
-                               var labelHeight = (sinRotation * largestTextWidth)
-                                       + (tickFont.lineHeight * tallestLabelHeightInLines)
-                                       + lineSpace; // padding
+                               var labelHeight = sinRotation * widestLabelSize.width
+                                       + cosRotation * (highestLabelSize.height - (isRotated ? highestLabelSize.offset : 0))
+                                       + (isRotated ? 0 : lineSpace); // padding
 
                                minSize.height = Math.min(me.maxHeight, minSize.height + labelHeight + tickPadding);
 
-                               me.ctx.font = tickFont.string;
-                               var firstLabelWidth = computeTextSize(me.ctx, labels[0], tickFont.string);
-                               var lastLabelWidth = computeTextSize(me.ctx, labels[labels.length - 1], tickFont.string);
                                var offsetLeft = me.getPixelForTick(0) - me.left;
-                               var offsetRight = me.right - me.getPixelForTick(labels.length - 1);
+                               var offsetRight = me.right - me.getPixelForTick(ticks.length - 1);
                                var paddingLeft, paddingRight;
 
                                // Ensure that our ticks are always inside the canvas. When rotated, ticks are right aligned
                                // which means that the right padding is dominated by the font height
-                               if (me.labelRotation !== 0) {
-                                       paddingLeft = position === 'bottom' ? (cosRotation * firstLabelWidth) : (cosRotation * lineSpace);
-                                       paddingRight = position === 'bottom' ? (cosRotation * lineSpace) : (cosRotation * lastLabelWidth);
+                               if (isRotated) {
+                                       paddingLeft = position === 'bottom' ?
+                                               cosRotation * firstLabelSize.width + sinRotation * firstLabelSize.offset :
+                                               sinRotation * (firstLabelSize.height - firstLabelSize.offset);
+                                       paddingRight = position === 'bottom' ?
+                                               sinRotation * (lastLabelSize.height - lastLabelSize.offset) :
+                                               cosRotation * lastLabelSize.width + sinRotation * lastLabelSize.offset;
                                } else {
-                                       paddingLeft = firstLabelWidth / 2;
-                                       paddingRight = lastLabelWidth / 2;
+                                       paddingLeft = firstLabelSize.width / 2;
+                                       paddingRight = lastLabelSize.width / 2;
                                }
-                               me.paddingLeft = Math.max(paddingLeft - offsetLeft, 0) + 3; // add 3 px to move away from canvas edges
-                               me.paddingRight = Math.max(paddingRight - offsetRight, 0) + 3;
+
+                               // Adjust padding taking into account changes in offsets
+                               // and add 3 px to move away from canvas edges
+                               me.paddingLeft = Math.max((paddingLeft - offsetLeft) * me.width / (me.width - offsetLeft), 0) + 3;
+                               me.paddingRight = Math.max((paddingRight - offsetRight) * me.width / (me.width - offsetRight), 0) + 3;
                        } else {
                                // A vertical axis is more constrained by the width. Labels are the
                                // dominant factor here, so get that length first and account for padding
-                               if (tickOpts.mirror) {
-                                       largestTextWidth = 0;
-                               } else {
+                               var labelWidth = tickOpts.mirror ? 0 :
                                        // use lineSpace for consistency with horizontal axis
                                        // tickPadding is not implemented for horizontal
-                                       largestTextWidth += tickPadding + lineSpace;
-                               }
+                                       widestLabelSize.width + tickPadding + lineSpace;
 
-                               minSize.width = Math.min(me.maxWidth, minSize.width + largestTextWidth);
+                               minSize.width = Math.min(me.maxWidth, minSize.width + labelWidth);
 
-                               me.paddingTop = tickFont.size / 2;
-                               me.paddingBottom = tickFont.size / 2;
+                               me.paddingTop = firstLabelSize.height / 2;
+                               me.paddingBottom = lastLabelSize.height / 2;
                        }
                }
 
@@ -685,11 +742,10 @@ module.exports = Element.extend({
                var cos = Math.abs(Math.cos(rot));
                var sin = Math.abs(Math.sin(rot));
 
+               var labelSizes = me._labelSizes;
                var padding = optionTicks.autoSkipPadding || 0;
-               var w = (me.longestLabelWidth + padding) || 0;
-
-               var tickFont = parseTickFontOptions(optionTicks).minor;
-               var h = (me._maxLabelLines * tickFont.lineHeight + padding) || 0;
+               var w = labelSizes ? labelSizes.widest.width + padding : 0;
+               var h = labelSizes ? labelSizes.highest.height + padding : 0;
 
                // Calculate space needed for 1 tick in axis direction.
                return isHorizontal
@@ -751,7 +807,7 @@ module.exports = Element.extend({
                var tickPadding = optionTicks.padding;
                var labelOffset = optionTicks.labelOffset;
 
-               var tl = gridLines.drawTicks ? gridLines.tickMarkLength : 0;
+               var tl = getTickMarkLength(gridLines);
 
                var scaleLabelFontColor = valueOrDefault(scaleLabel.fontColor, defaults.global.defaultFontColor);
                var scaleLabelFont = helpers.options._parseFont(scaleLabel);
index 130232caa1bf16bcaa68f73c5ac212be18277353..2f2c3b1dece3c40ef33ecd04cb887afba42be1a1 100644 (file)
@@ -208,6 +208,43 @@ describe('Core.scale', function() {
                });
        });
 
+       it('should add the correct padding for long tick labels', function() {
+               var chart = window.acquireChart({
+                       type: 'line',
+                       data: {
+                               labels: [
+                                       'This is a very long label',
+                                       'This is a very long label'
+                               ],
+                               datasets: [{
+                                       data: [0, 1]
+                               }]
+                       },
+                       options: {
+                               scales: {
+                                       xAxes: [{
+                                               id: 'foo'
+                                       }],
+                                       yAxes: [{
+                                               display: false
+                                       }]
+                               },
+                               legend: {
+                                       display: false
+                               }
+                       }
+               }, {
+                       canvas: {
+                               height: 100,
+                               width: 200
+                       }
+               });
+
+               var scale = chart.scales.foo;
+               expect(scale.left).toBeGreaterThan(100);
+               expect(scale.right).toBeGreaterThan(190);
+       });
+
        describe('given the axes display option is set to auto', function() {
                describe('for the x axes', function() {
                        it('should draw the axes if at least one associated dataset is visible', function() {