From e1606f88ed4805815038cba4fdcd6211d7490356 Mon Sep 17 00:00:00 2001 From: etimberg Date: Sat, 19 Nov 2016 21:51:01 -0500 Subject: [PATCH] Radial Linear Scale supports multiple lines for point labels --- samples/radar/radar.html | 2 +- src/scales/scale.radialLinear.js | 266 +++++++++++++++++------------ test/controller.polarArea.tests.js | 14 +- test/scale.radialLinear.tests.js | 18 +- 4 files changed, 178 insertions(+), 122 deletions(-) diff --git a/samples/radar/radar.html b/samples/radar/radar.html index 507d5bb47d7..586e2df7aec 100644 --- a/samples/radar/radar.html +++ b/samples/radar/radar.html @@ -32,7 +32,7 @@ var config = { type: 'radar', data: { - labels: ["Eating", "Drinking", "Sleeping", "Designing", "Coding", "Cycling", "Running"], + labels: [["Eating", "Dinner"], ["Drinking", "Water"], "Sleeping", ["Designing", "Graphics"], "Coding", "Cycling", "Running"], datasets: [{ label: "My First dataset", backgroundColor: color(window.chartColors.red).alpha(0.2).rgbString(), diff --git a/src/scales/scale.radialLinear.js b/src/scales/scale.radialLinear.js index 1c4d2144287..b51fa698bd9 100644 --- a/src/scales/scale.radialLinear.js +++ b/src/scales/scale.radialLinear.js @@ -51,6 +51,54 @@ module.exports = function(Chart) { return !scale.options.lineArc ? scale.chart.data.labels.length : 0; } + function getPointLabelFontOptions(scale) { + var pointLabelOptions = scale.options.pointLabels; + var fontSize = helpers.getValueOrDefault(pointLabelOptions.fontSize, globalDefaults.defaultFontSize); + var fontStyle = helpers.getValueOrDefault(pointLabelOptions.fontStyle, globalDefaults.defaultFontStyle); + var fontFamily = helpers.getValueOrDefault(pointLabelOptions.fontFamily, globalDefaults.defaultFontFamily); + var font = helpers.fontString(fontSize, fontStyle, fontFamily); + + return { + size: fontSize, + style: fontStyle, + family: fontFamily, + font: font + }; + } + + function measureLabelSize(ctx, fontSize, label) { + if (helpers.isArray(label)) { + return { + w: helpers.longestText(ctx, ctx.font, label), + h: (label.length * fontSize) + ((label.length - 1) * 1.5 * fontSize) + }; + } + + return { + w: ctx.measureText(label).width, + h: fontSize + }; + } + + function determineLimits(angle, pos, size, min, max) { + if (angle === min || angle === max) { + return { + start: pos - (size / 2), + end: pos + (size / 2) + }; + } else if (angle < min || angle > max) { + return { + start: pos - size - 5, + end: pos + }; + } + + return { + start: pos, + end: pos + size + 5 + }; + } + /** * Helper function to fit a radial linear scale with point labels */ @@ -83,82 +131,59 @@ module.exports = function(Chart) { * https://dl.dropboxusercontent.com/u/34601363/yeahscience.gif */ - var pointLabels = scale.options.pointLabels; - var pointLabelFontSize = helpers.getValueOrDefault(pointLabels.fontSize, globalDefaults.defaultFontSize); - var pointLabeFontStyle = helpers.getValueOrDefault(pointLabels.fontStyle, globalDefaults.defaultFontStyle); - var pointLabeFontFamily = helpers.getValueOrDefault(pointLabels.fontFamily, globalDefaults.defaultFontFamily); - var pointLabeFont = helpers.fontString(pointLabelFontSize, pointLabeFontStyle, pointLabeFontFamily); + var plFont = getPointLabelFontOptions(scale); // Get maximum radius of the polygon. Either half the height (minus the text width) or half the width. // Use this to calculate the offset + change. - Make sure L/R protrusion is at least 0 to stop issues with centre points - var largestPossibleRadius = Math.min(scale.height / 2 - pointLabelFontSize - 5, scale.width / 2), - pointPosition, - i, - textWidth, - halfTextWidth, - furthestRight = scale.width, - furthestRightIndex, - furthestRightAngle, - furthestLeft = 0, - furthestLeftIndex, - furthestLeftAngle, - xProtrusionLeft, - xProtrusionRight, - radiusReductionRight, - radiusReductionLeft; - scale.ctx.font = pointLabeFont; + var largestPossibleRadius = Math.min(scale.height / 2, scale.width / 2); + var furthestLimits = { + l: scale.width, + r: 0, + t: scale.height, + b: 0 + }; + var furthestAngles = {}; + var i; + var textSize; + var pointPosition; + + scale.ctx.font = plFont.font; + scale._pointLabelSizes = []; var valueCount = getValueCount(scale); for (i = 0; i < valueCount; i++) { - // 5px to space the text slightly out - similar to what we do in the draw function. pointPosition = scale.getPointPosition(i, largestPossibleRadius); - textWidth = scale.ctx.measureText(scale.pointLabels[i] ? scale.pointLabels[i] : '').width + 5; + textSize = measureLabelSize(scale.ctx, plFont.size, scale.pointLabels[i] || ''); + scale._pointLabelSizes[i] = textSize; // Add quarter circle to make degree 0 mean top of circle - var angleRadians = scale.getIndexAngle(i) + (Math.PI / 2); + var angleRadians = scale.getIndexAngle(i); var angle = helpers.toDegrees(angleRadians) % 360; + var hLimits = determineLimits(angle, pointPosition.x, textSize.w, 0, 180); + var vLimits = determineLimits(angle, pointPosition.y, textSize.h, 90, 270); - if (angle === 0 || angle === 180) { - // At angle 0 and 180, we're at exactly the top/bottom - // of the radar chart, so text will be aligned centrally, so we'll half it and compare - // w/left and right text sizes - halfTextWidth = textWidth / 2; - if (pointPosition.x + halfTextWidth > furthestRight) { - furthestRight = pointPosition.x + halfTextWidth; - furthestRightIndex = i; - } - if (pointPosition.x - halfTextWidth < furthestLeft) { - furthestLeft = pointPosition.x - halfTextWidth; - furthestLeftIndex = i; - } - } else if (angle < 180) { - // Less than half the values means we'll left align the text - if (pointPosition.x + textWidth > furthestRight) { - furthestRight = pointPosition.x + textWidth; - furthestRightIndex = i; - } - // More than half the values means we'll right align the text - } else if (pointPosition.x - textWidth < furthestLeft) { - furthestLeft = pointPosition.x - textWidth; - furthestLeftIndex = i; + if (hLimits.start < furthestLimits.l) { + furthestLimits.l = hLimits.start; + furthestAngles.l = angleRadians; } - } - xProtrusionLeft = furthestLeft; - xProtrusionRight = Math.ceil(furthestRight - scale.width); - - furthestRightAngle = scale.getIndexAngle(furthestRightIndex); - furthestLeftAngle = scale.getIndexAngle(furthestLeftIndex); + if (hLimits.end > furthestLimits.r) { + furthestLimits.r = hLimits.end; + furthestAngles.r = angleRadians; + } - radiusReductionRight = xProtrusionRight / Math.sin(furthestRightAngle + Math.PI / 2); - radiusReductionLeft = xProtrusionLeft / Math.sin(furthestLeftAngle + Math.PI / 2); + if (vLimits.start < furthestLimits.t) { + furthestLimits.t = vLimits.start; + furthestAngles.t = angleRadians; + } - // Ensure we actually need to reduce the size of the chart - radiusReductionRight = helpers.isNumber(radiusReductionRight) ? radiusReductionRight : 0; - radiusReductionLeft = helpers.isNumber(radiusReductionLeft) ? radiusReductionLeft : 0; + if (vLimits.end > furthestLimits.b) { + furthestLimits.b = vLimits.end; + furthestAngles.b = angleRadians; + } + } - scale.drawingArea = Math.round(largestPossibleRadius - (radiusReductionLeft + radiusReductionRight) / 2); - scale.setCenterPoint(radiusReductionLeft, radiusReductionRight); + scale.setReductions(largestPossibleRadius, furthestLimits, furthestAngles); } /** @@ -167,32 +192,39 @@ module.exports = function(Chart) { function fit(scale) { var largestPossibleRadius = Math.min(scale.height / 2, scale.width / 2); scale.drawingArea = Math.round(largestPossibleRadius); - scale.setCenterPoint(0, 0); + scale.setCenterPoint(0, 0, 0, 0); } function getTextAlignForAngle(angle) { - var textAlign; if (angle === 0 || angle === 180) { - textAlign = 'center'; + return 'center'; } else if (angle < 180) { - textAlign = 'left'; - } else { - textAlign = 'right'; + return 'left'; } - return textAlign; + return 'right'; } - function getTextBaselineForAngle(angle) { - var textBaseline; + function fillText(ctx, text, position, fontSize) { + if (helpers.isArray(text)) { + var y = position.y; + var spacing = 1.5 * fontSize; + + for (var i = 0; i < text.length; ++i) { + ctx.fillText(text[i], position.x, y); + y+= spacing; + } + } else { + ctx.fillText(text, position.x, position.y); + } + } + + function adjustPointPositionForLabelHeight(angle, textSize, position) { if (angle === 90 || angle === 270) { - textBaseline = 'middle'; + position.y -= (textSize.h / 2); } else if (angle > 270 || angle < 90) { - textBaseline = 'bottom'; - } else { - textBaseline = 'top'; + position.y -= textSize.h; } - return textBaseline; } function drawPointLabels(scale) { @@ -208,10 +240,9 @@ module.exports = function(Chart) { var outerDistance = scale.getDistanceFromCenterForValue(opts.reverse ? scale.min : scale.max); // Point Label Font - var pointLabelFontSize = getValueOrDefault(pointLabelOpts.fontSize, globalDefaults.defaultFontSize); - var pointLabeFontStyle = getValueOrDefault(pointLabelOpts.fontStyle, globalDefaults.defaultFontStyle); - var pointLabeFontFamily = getValueOrDefault(pointLabelOpts.fontFamily, globalDefaults.defaultFontFamily); - var pointLabeFont = helpers.fontString(pointLabelFontSize, pointLabeFontStyle, pointLabeFontFamily); + var plFont = getPointLabelFontOptions(scale); + + ctx.textBaseline = 'top'; for (var i = getValueCount(scale) - 1; i >= 0; i--) { if (angleLineOpts.display) { @@ -227,19 +258,14 @@ module.exports = function(Chart) { // Keep this in loop since we may support array properties here var pointLabelFontColor = getValueOrDefault(pointLabelOpts.fontColor, globalDefaults.defaultFontColor); - ctx.font = pointLabeFont; + ctx.font = plFont.font; ctx.fillStyle = pointLabelFontColor; - var pointLabels = scale.pointLabels; - - // Add quarter circle to make degree 0 mean top of circle - var angleRadians = scale.getIndexAngle(i) + (Math.PI / 2); - var angle = (angleRadians * 360 / (2 * Math.PI)) % 360; - + var angleRadians = scale.getIndexAngle(i); + var angle = helpers.toDegrees(angleRadians); ctx.textAlign = getTextAlignForAngle(angle); - ctx.textBaseline = getTextBaselineForAngle(angle); - - ctx.fillText(pointLabels[i] || '', pointLabelPosition.x, pointLabelPosition.y); + adjustPointPositionForLabelHeight(angle, scale._pointLabelSizes[i], pointLabelPosition); + fillText(ctx, scale.pointLabels[i] || '', pointLabelPosition, plFont.size); } } @@ -256,21 +282,30 @@ module.exports = function(Chart) { ctx.stroke(); } else { // Draw straight lines connecting each index - ctx.beginPath(); var valueCount = getValueCount(scale); - for (var i = 0; i < valueCount; i++) { - var pointPosition = scale.getPointPosition(i, radius); - if (i === 0) { - ctx.moveTo(pointPosition.x, pointPosition.y); - } else { - ctx.lineTo(pointPosition.x, pointPosition.y); - } + + if (valueCount === 0) { + return; } + + ctx.beginPath(); + var pointPosition = scale.getPointPosition(0, radius); + ctx.moveTo(pointPosition.x, pointPosition.y); + + for (var i = 1; i < valueCount; i++) { + pointPosition = scale.getPointPosition(i, radius); + ctx.lineTo(pointPosition.x, pointPosition.y); + } + ctx.closePath(); ctx.stroke(); } } + function numberOrZero(param) { + return helpers.isNumber(param) ? param : 0; + } + var LinearRadialScale = Chart.LinearScaleBase.extend({ setDimensions: function() { var me = this; @@ -292,7 +327,6 @@ module.exports = function(Chart) { var min = Number.POSITIVE_INFINITY; var max = Number.NEGATIVE_INFINITY; - helpers.each(chart.data.datasets, function(dataset, datasetIndex) { if (chart.isDatasetVisible(datasetIndex)) { var meta = chart.getDatasetMeta(datasetIndex); @@ -309,8 +343,8 @@ module.exports = function(Chart) { } }); - me.min = min; - me.max = max; + me.min = (min === Number.POSITIVE_INFINITY ? 0 : min); + me.max = (max === Number.NEGATIVE_INFINITY ? 0 : max); // Common base implementation to handle ticks.min, ticks.max, ticks.beginAtZero me.handleTickRangeOptions(); @@ -337,14 +371,36 @@ module.exports = function(Chart) { fitWithPointLabels(this); } }, - setCenterPoint: function(leftMovement, rightMovement) { + /** + * Set radius reductions and determine new radius and center point + * @private + */ + setReductions: function(largestPossibleRadius, furthestLimits, furthestAngles) { + var me = this; + var radiusReductionLeft = furthestLimits.l / Math.sin(furthestAngles.l); + var radiusReductionRight = Math.max(furthestLimits.r - me.width, 0) / Math.sin(furthestAngles.r); + var radiusReductionTop = -furthestLimits.t / Math.cos(furthestAngles.t); + var radiusReductionBottom = -Math.max(furthestLimits.b - me.height, 0) / Math.cos(furthestAngles.b); + + radiusReductionLeft = numberOrZero(radiusReductionLeft); + radiusReductionRight = numberOrZero(radiusReductionRight); + radiusReductionTop = numberOrZero(radiusReductionTop); + radiusReductionBottom = numberOrZero(radiusReductionBottom); + + me.drawingArea = Math.min( + Math.round(largestPossibleRadius - (radiusReductionLeft + radiusReductionRight) / 2), + Math.round(largestPossibleRadius - (radiusReductionTop + radiusReductionBottom) / 2)); + me.setCenterPoint(radiusReductionLeft, radiusReductionRight, radiusReductionTop, radiusReductionBottom); + }, + setCenterPoint: function(leftMovement, rightMovement, topMovement, bottomMovement) { var me = this; var maxRight = me.width - rightMovement - me.drawingArea, - maxLeft = leftMovement + me.drawingArea; + maxLeft = leftMovement + me.drawingArea, + maxTop = topMovement + me.drawingArea, + maxBottom = me.height - bottomMovement - me.drawingArea; me.xCenter = Math.round(((maxLeft + maxRight) / 2) + me.left); - // Always vertically in the centre as the text height doesn't change - me.yCenter = Math.round((me.height / 2) + me.top); + me.yCenter = Math.round(((maxTop + maxBottom) / 2) + me.top); }, getIndexAngle: function(index) { @@ -356,7 +412,7 @@ module.exports = function(Chart) { var startAngleRadians = startAngle * Math.PI * 2 / 360; // Start from the top instead of right, so remove a quarter of the circle - return index * angleMultiplier - (Math.PI / 2) + startAngleRadians; + return index * angleMultiplier + startAngleRadians; }, getDistanceFromCenterForValue: function(value) { var me = this; @@ -374,7 +430,7 @@ module.exports = function(Chart) { }, getPointPosition: function(index, distanceFromCenter) { var me = this; - var thisAngle = me.getIndexAngle(index); + var thisAngle = me.getIndexAngle(index) - (Math.PI / 2); return { x: Math.round(Math.cos(thisAngle) * distanceFromCenter) + me.xCenter, y: Math.round(Math.sin(thisAngle) * distanceFromCenter) + me.yCenter diff --git a/test/controller.polarArea.tests.js b/test/controller.polarArea.tests.js index 3bd95eb231e..de7d4acfaac 100644 --- a/test/controller.polarArea.tests.js +++ b/test/controller.polarArea.tests.js @@ -96,9 +96,9 @@ describe('Polar area controller tests', function() { expect(meta.data.length).toBe(4); [ - {o: 156, s: -0.5 * Math.PI, e: 0}, - {o: 211, s: 0, e: 0.5 * Math.PI}, - {o: 45, s: 0.5 * Math.PI, e: Math.PI}, + {o: 168, s: -0.5 * Math.PI, e: 0}, + {o: 228, s: 0, e: 0.5 * Math.PI}, + {o: 48, s: 0.5 * Math.PI, e: Math.PI}, {o: 0, s: Math.PI, e: 1.5 * Math.PI} ].forEach(function(expected, i) { expect(meta.data[i]._model.x).toBeCloseToPixel(256); @@ -140,7 +140,7 @@ describe('Polar area controller tests', function() { expect(meta.data[0]._model.x).toBeCloseToPixel(256); expect(meta.data[0]._model.y).toBeCloseToPixel(272); expect(meta.data[0]._model.innerRadius).toBeCloseToPixel(0); - expect(meta.data[0]._model.outerRadius).toBeCloseToPixel(156); + expect(meta.data[0]._model.outerRadius).toBeCloseToPixel(168); expect(meta.data[0]._model).toEqual(jasmine.objectContaining({ startAngle: -0.5 * Math.PI, endAngle: 0, @@ -178,9 +178,9 @@ describe('Polar area controller tests', function() { expect(meta.data.length).toBe(4); [ - {o: 156, s: 0, e: 0.5 * Math.PI}, - {o: 211, s: 0.5 * Math.PI, e: Math.PI}, - {o: 45, s: Math.PI, e: 1.5 * Math.PI}, + {o: 168, s: 0, e: 0.5 * Math.PI}, + {o: 228, s: 0.5 * Math.PI, e: Math.PI}, + {o: 48, s: Math.PI, e: 1.5 * Math.PI}, {o: 0, s: 1.5 * Math.PI, e: 2.0 * Math.PI} ].forEach(function(expected, i) { expect(meta.data[i]._model.x).toBeCloseToPixel(256); diff --git a/test/scale.radialLinear.tests.js b/test/scale.radialLinear.tests.js index 7ac95ac39a6..6056831bde3 100644 --- a/test/scale.radialLinear.tests.js +++ b/test/scale.radialLinear.tests.js @@ -342,9 +342,9 @@ describe('Test the radial linear scale', function() { } }); - expect(chart.scale.drawingArea).toBe(225); - expect(chart.scale.xCenter).toBe(256); - expect(chart.scale.yCenter).toBe(272); + expect(chart.scale.drawingArea).toBe(233); + expect(chart.scale.xCenter).toBe(247); + expect(chart.scale.yCenter).toBe(280); }); it('should correctly get the label for a given data index', function() { @@ -390,16 +390,16 @@ describe('Test the radial linear scale', function() { }); expect(chart.scale.getDistanceFromCenterForValue(chart.scale.min)).toBe(0); - expect(chart.scale.getDistanceFromCenterForValue(chart.scale.max)).toBe(225); + expect(chart.scale.getDistanceFromCenterForValue(chart.scale.max)).toBe(233); expect(chart.scale.getPointPositionForValue(1, 5)).toEqual({ - x: 269, - y: 268, + x: 261, + y: 275, }); chart.scale.options.reverse = true; chart.update(); - expect(chart.scale.getDistanceFromCenterForValue(chart.scale.min)).toBe(225); + expect(chart.scale.getDistanceFromCenterForValue(chart.scale.min)).toBe(233); expect(chart.scale.getDistanceFromCenterForValue(chart.scale.max)).toBe(0); }); @@ -431,14 +431,14 @@ describe('Test the radial linear scale', function() { var slice = 72; // (360 / 5) for (var i = 0; i < 5; i++) { - expect(radToNearestDegree(chart.scale.getIndexAngle(i))).toBe(15 + (slice * i) - 90); + expect(radToNearestDegree(chart.scale.getIndexAngle(i))).toBe(15 + (slice * i)); } chart.options.startAngle = 0; chart.update(); for (var x = 0; x < 5; x++) { - expect(radToNearestDegree(chart.scale.getIndexAngle(x))).toBe((slice * x) - 90); + expect(radToNearestDegree(chart.scale.getIndexAngle(x))).toBe((slice * x)); } }); });