diff --git a/src/helpers/helpers.canvas.js b/src/helpers/helpers.canvas.js index 60fb6e1a299..ea0c6f1cefe 100644 --- a/src/helpers/helpers.canvas.js +++ b/src/helpers/helpers.canvas.js @@ -2,6 +2,13 @@ var helpers = require('./helpers.core'); +var PI = Math.PI; +var RAD_PER_DEG = PI / 180; +var DOUBLE_PI = PI * 2; +var HALF_PI = PI / 2; +var QUARTER_PI = PI / 4; +var TWO_THIRDS_PI = PI * 2 / 3; + /** * @namespace Chart.helpers.canvas */ @@ -27,20 +34,28 @@ var exports = module.exports = { */ roundedRect: function(ctx, x, y, width, height, radius) { if (radius) { - // NOTE(SB) `epsilon` helps to prevent minor artifacts appearing - // on Chrome when `r` is exactly half the height or the width. - var epsilon = 0.0000001; - var r = Math.min(radius, (height / 2) - epsilon, (width / 2) - epsilon); - - ctx.moveTo(x + r, y); - ctx.lineTo(x + width - r, y); - ctx.arcTo(x + width, y, x + width, y + r, r); - ctx.lineTo(x + width, y + height - r); - ctx.arcTo(x + width, y + height, x + width - r, y + height, r); - ctx.lineTo(x + r, y + height); - ctx.arcTo(x, y + height, x, y + height - r, r); - ctx.lineTo(x, y + r); - ctx.arcTo(x, y, x + r, y, r); + var r = Math.min(radius, height / 2, width / 2); + var left = x + r; + var top = y + r; + var right = x + width - r; + var bottom = y + height - r; + + ctx.moveTo(x, top); + if (left < right && top < bottom) { + ctx.arc(left, top, r, -PI, -HALF_PI); + ctx.arc(right, top, r, -HALF_PI, 0); + ctx.arc(right, bottom, r, 0, HALF_PI); + ctx.arc(left, bottom, r, HALF_PI, PI); + } else if (left < right) { + ctx.moveTo(left, y); + ctx.arc(right, top, r, -HALF_PI, HALF_PI); + ctx.arc(left, top, r, HALF_PI, PI + HALF_PI); + } else if (top < bottom) { + ctx.arc(left, top, r, -PI, 0); + ctx.arc(left, bottom, r, 0, PI); + } else { + ctx.arc(left, top, r, -PI, PI); + } ctx.closePath(); ctx.moveTo(x, y); } else { @@ -49,8 +64,8 @@ var exports = module.exports = { }, drawPoint: function(ctx, style, radius, x, y, rotation) { - var type, edgeLength, xOffset, yOffset, height, size; - rotation = rotation || 0; + var type, xOffset, yOffset, size, cornerRadius; + var rad = (rotation || 0) * RAD_PER_DEG; if (style && typeof style === 'object') { type = style.toString(); @@ -64,88 +79,97 @@ var exports = module.exports = { return; } - ctx.save(); - ctx.translate(x, y); - ctx.rotate(rotation * Math.PI / 180); ctx.beginPath(); switch (style) { // Default includes circle default: - ctx.arc(0, 0, radius, 0, Math.PI * 2); + ctx.arc(x, y, radius, 0, DOUBLE_PI); ctx.closePath(); break; case 'triangle': - edgeLength = 3 * radius / Math.sqrt(3); - height = edgeLength * Math.sqrt(3) / 2; - ctx.moveTo(-edgeLength / 2, height / 3); - ctx.lineTo(edgeLength / 2, height / 3); - ctx.lineTo(0, -2 * height / 3); + ctx.moveTo(x + Math.sin(rad) * radius, y - Math.cos(rad) * radius); + rad += TWO_THIRDS_PI; + ctx.lineTo(x + Math.sin(rad) * radius, y - Math.cos(rad) * radius); + rad += TWO_THIRDS_PI; + ctx.lineTo(x + Math.sin(rad) * radius, y - Math.cos(rad) * radius); ctx.closePath(); break; - case 'rect': - size = 1 / Math.SQRT2 * radius; - ctx.rect(-size, -size, 2 * size, 2 * size); - break; case 'rectRounded': - var offset = radius / Math.SQRT2; - var leftX = -offset; - var topY = -offset; - var sideSize = Math.SQRT2 * radius; - - // NOTE(SB) the rounded rect implementation changed to use `arcTo` - // instead of `quadraticCurveTo` since it generates better results - // when rect is almost a circle. 0.425 (instead of 0.5) produces - // results visually closer to the previous impl. - this.roundedRect(ctx, leftX, topY, sideSize, sideSize, radius * 0.425); + // NOTE: the rounded rect implementation changed to use `arc` instead of + // `quadraticCurveTo` since it generates better results when rect is + // almost a circle. 0.516 (instead of 0.5) produces results with visually + // closer proportion to the previous impl and it is inscribed in the + // circle with `radius`. For more details, see the following PRs: + // https://github.com/chartjs/Chart.js/issues/5597 + // https://github.com/chartjs/Chart.js/issues/5858 + cornerRadius = radius * 0.516; + size = radius - cornerRadius; + xOffset = Math.cos(rad + QUARTER_PI) * size; + yOffset = Math.sin(rad + QUARTER_PI) * size; + ctx.arc(x - xOffset, y - yOffset, cornerRadius, rad - PI, rad - HALF_PI); + ctx.arc(x + yOffset, y - xOffset, cornerRadius, rad - HALF_PI, rad); + ctx.arc(x + xOffset, y + yOffset, cornerRadius, rad, rad + HALF_PI); + ctx.arc(x - yOffset, y + xOffset, cornerRadius, rad + HALF_PI, rad + PI); + ctx.closePath(); break; + case 'rect': + if (!rotation) { + size = Math.SQRT1_2 * radius; + ctx.rect(x - size, y - size, 2 * size, 2 * size); + break; + } + rad += QUARTER_PI; + /* falls through */ case 'rectRot': - size = 1 / Math.SQRT2 * radius; - ctx.moveTo(-size, 0); - ctx.lineTo(0, size); - ctx.lineTo(size, 0); - ctx.lineTo(0, -size); + xOffset = Math.cos(rad) * radius; + yOffset = Math.sin(rad) * radius; + ctx.moveTo(x - xOffset, y - yOffset); + ctx.lineTo(x + yOffset, y - xOffset); + ctx.lineTo(x + xOffset, y + yOffset); + ctx.lineTo(x - yOffset, y + xOffset); ctx.closePath(); break; - case 'cross': - ctx.moveTo(0, radius); - ctx.lineTo(0, -radius); - ctx.moveTo(-radius, 0); - ctx.lineTo(radius, 0); - break; case 'crossRot': - xOffset = Math.cos(Math.PI / 4) * radius; - yOffset = Math.sin(Math.PI / 4) * radius; - ctx.moveTo(-xOffset, -yOffset); - ctx.lineTo(xOffset, yOffset); - ctx.moveTo(-xOffset, yOffset); - ctx.lineTo(xOffset, -yOffset); + rad += QUARTER_PI; + /* falls through */ + case 'cross': + xOffset = Math.cos(rad) * radius; + yOffset = Math.sin(rad) * radius; + ctx.moveTo(x - xOffset, y - yOffset); + ctx.lineTo(x + xOffset, y + yOffset); + ctx.moveTo(x + yOffset, y - xOffset); + ctx.lineTo(x - yOffset, y + xOffset); break; case 'star': - ctx.moveTo(0, radius); - ctx.lineTo(0, -radius); - ctx.moveTo(-radius, 0); - ctx.lineTo(radius, 0); - xOffset = Math.cos(Math.PI / 4) * radius; - yOffset = Math.sin(Math.PI / 4) * radius; - ctx.moveTo(-xOffset, -yOffset); - ctx.lineTo(xOffset, yOffset); - ctx.moveTo(-xOffset, yOffset); - ctx.lineTo(xOffset, -yOffset); + xOffset = Math.cos(rad) * radius; + yOffset = Math.sin(rad) * radius; + ctx.moveTo(x - xOffset, y - yOffset); + ctx.lineTo(x + xOffset, y + yOffset); + ctx.moveTo(x + yOffset, y - xOffset); + ctx.lineTo(x - yOffset, y + xOffset); + rad += QUARTER_PI; + xOffset = Math.cos(rad) * radius; + yOffset = Math.sin(rad) * radius; + ctx.moveTo(x - xOffset, y - yOffset); + ctx.lineTo(x + xOffset, y + yOffset); + ctx.moveTo(x + yOffset, y - xOffset); + ctx.lineTo(x - yOffset, y + xOffset); break; case 'line': - ctx.moveTo(-radius, 0); - ctx.lineTo(radius, 0); + xOffset = Math.cos(rad) * radius; + yOffset = Math.sin(rad) * radius; + ctx.moveTo(x - xOffset, y - yOffset); + ctx.lineTo(x + xOffset, y + yOffset); break; case 'dash': - ctx.moveTo(0, 0); - ctx.lineTo(radius, 0); + ctx.moveTo(x, y); + ctx.lineTo(x + Math.cos(rad) * radius, y + Math.sin(rad) * radius); break; } ctx.fill(); ctx.stroke(); - ctx.restore(); }, clipArea: function(ctx, area) { diff --git a/test/fixtures/controller.bubble/point-style.png b/test/fixtures/controller.bubble/point-style.png index f1d3b8168c0..d949141d81d 100644 Binary files a/test/fixtures/controller.bubble/point-style.png and b/test/fixtures/controller.bubble/point-style.png differ diff --git a/test/fixtures/controller.line/point-style.png b/test/fixtures/controller.line/point-style.png index f177fbfe15e..d8b6ed6b475 100644 Binary files a/test/fixtures/controller.line/point-style.png and b/test/fixtures/controller.line/point-style.png differ diff --git a/test/fixtures/controller.radar/point-style.png b/test/fixtures/controller.radar/point-style.png index 562cb620b05..3f73ff96f91 100644 Binary files a/test/fixtures/controller.radar/point-style.png and b/test/fixtures/controller.radar/point-style.png differ diff --git a/test/fixtures/element.point/point-style-rect-rot.png b/test/fixtures/element.point/point-style-rect-rot.png index 09c0adac3d3..a7c12885589 100644 Binary files a/test/fixtures/element.point/point-style-rect-rot.png and b/test/fixtures/element.point/point-style-rect-rot.png differ diff --git a/test/fixtures/element.point/point-style-rect-rounded.png b/test/fixtures/element.point/point-style-rect-rounded.png index a58e9e62361..8b58b44303a 100644 Binary files a/test/fixtures/element.point/point-style-rect-rounded.png and b/test/fixtures/element.point/point-style-rect-rounded.png differ diff --git a/test/fixtures/element.point/rotation.js b/test/fixtures/element.point/rotation.js new file mode 100644 index 00000000000..b713f5d6bf4 --- /dev/null +++ b/test/fixtures/element.point/rotation.js @@ -0,0 +1,56 @@ +var gradient; + +var datasets = ['circle', 'cross', 'crossRot', 'dash', 'line', 'rect', 'rectRounded', 'rectRot', 'star', 'triangle'].map(function(style, y) { + return { + pointStyle: style, + data: Array.apply(null, Array(17)).map(function(v, x) { + return {x: x, y: 10 - y}; + }) + }; +}); + +var angles = Array.apply(null, Array(17)).map(function(v, i) { + return -180 + i * 22.5; +}); + +module.exports = { + config: { + type: 'bubble', + data: { + datasets: datasets + }, + options: { + responsive: false, + legend: false, + title: false, + elements: { + point: { + rotation: angles, + radius: 10, + backgroundColor: function(context) { + if (!gradient) { + gradient = context.chart.ctx.createLinearGradient(0, 0, 512, 256); + gradient.addColorStop(0, '#ff0000'); + gradient.addColorStop(1, '#0000ff'); + } + return gradient; + }, + borderColor: '#cccccc' + } + }, + layout: { + padding: 20 + }, + scales: { + xAxes: [{display: false}], + yAxes: [{display: false}] + } + } + }, + options: { + canvas: { + height: 256, + width: 512 + } + } +}; diff --git a/test/fixtures/element.point/rotation.png b/test/fixtures/element.point/rotation.png new file mode 100644 index 00000000000..579c712ecf6 Binary files /dev/null and b/test/fixtures/element.point/rotation.png differ diff --git a/test/fixtures/helpers.canvas/rounded-rect.js b/test/fixtures/helpers.canvas/rounded-rect.js new file mode 100644 index 00000000000..cbdedacc32f --- /dev/null +++ b/test/fixtures/helpers.canvas/rounded-rect.js @@ -0,0 +1,39 @@ +var roundedRect = Chart.helpers.canvas.roundedRect; + +module.exports = { + config: { + type: 'line', + plugins: [{ + afterDraw: function(chart) { + var ctx = chart.ctx; + ctx.strokeStyle = '#0000ff'; + ctx.lineWidth = 4; + ctx.fillStyle = '#00ff00'; + ctx.beginPath(); + roundedRect(ctx, 10, 10, 50, 50, 25); + roundedRect(ctx, 70, 10, 100, 50, 25); + roundedRect(ctx, 10, 70, 50, 100, 25); + roundedRect(ctx, 70, 70, 100, 100, 25); + roundedRect(ctx, 180, 10, 50, 50, 100); + roundedRect(ctx, 240, 10, 100, 50, 100); + roundedRect(ctx, 180, 70, 50, 100, 100); + roundedRect(ctx, 240, 70, 100, 100, 100); + roundedRect(ctx, 350, 10, 50, 50, 0); + ctx.fill(); + ctx.stroke(); + } + }], + options: { + scales: { + xAxes: [{display: false}], + yAxes: [{display: false}] + } + } + }, + options: { + canvas: { + height: 256, + width: 512 + } + } +}; diff --git a/test/fixtures/helpers.canvas/rounded-rect.png b/test/fixtures/helpers.canvas/rounded-rect.png new file mode 100644 index 00000000000..8973c9d30a0 Binary files /dev/null and b/test/fixtures/helpers.canvas/rounded-rect.png differ diff --git a/test/specs/element.point.tests.js b/test/specs/element.point.tests.js index 0f3a03e319d..887250acd8d 100644 --- a/test/specs/element.point.tests.js +++ b/test/specs/element.point.tests.js @@ -124,21 +124,12 @@ describe('Chart.elements.Point', function() { }, { name: 'setFillStyle', args: ['rgba(0,0,0,0.1)'] - }, { - name: 'save', - args: [] - }, { - name: 'translate', - args: [10, 15] - }, { - name: 'rotate', - args: [0] }, { name: 'beginPath', args: [] }, { name: 'arc', - args: [0, 0, 2, 0, 2 * Math.PI] + args: [10, 15, 2, 0, 2 * Math.PI] }, { name: 'closePath', args: [], @@ -148,9 +139,6 @@ describe('Chart.elements.Point', function() { }, { name: 'stroke', args: [] - }, { - name: 'restore', - args: [] }]); }); diff --git a/test/specs/helpers.canvas.tests.js b/test/specs/helpers.canvas.tests.js index 1a342c1cb3b..ee42a414e6f 100644 --- a/test/specs/helpers.canvas.tests.js +++ b/test/specs/helpers.canvas.tests.js @@ -1,6 +1,8 @@ 'use strict'; describe('Chart.helpers.canvas', function() { + describe('auto', jasmine.fixture.specs('helpers.canvas')); + var helpers = Chart.helpers; describe('clear', function() { @@ -28,15 +30,50 @@ describe('Chart.helpers.canvas', function() { helpers.canvas.roundedRect(context, 10, 20, 30, 40, 5); expect(context.getCalls()).toEqual([ - {name: 'moveTo', args: [15, 20]}, - {name: 'lineTo', args: [35, 20]}, - {name: 'arcTo', args: [40, 20, 40, 25, 5]}, - {name: 'lineTo', args: [40, 55]}, - {name: 'arcTo', args: [40, 60, 35, 60, 5]}, - {name: 'lineTo', args: [15, 60]}, - {name: 'arcTo', args: [10, 60, 10, 55, 5]}, - {name: 'lineTo', args: [10, 25]}, - {name: 'arcTo', args: [10, 20, 15, 20, 5]}, + {name: 'moveTo', args: [10, 25]}, + {name: 'arc', args: [15, 25, 5, -Math.PI, -Math.PI / 2]}, + {name: 'arc', args: [35, 25, 5, -Math.PI / 2, 0]}, + {name: 'arc', args: [35, 55, 5, 0, Math.PI / 2]}, + {name: 'arc', args: [15, 55, 5, Math.PI / 2, Math.PI]}, + {name: 'closePath', args: []}, + {name: 'moveTo', args: [10, 20]} + ]); + }); + it('should optimize path if radius is exactly half of height', function() { + var context = window.createMockContext(); + + helpers.canvas.roundedRect(context, 10, 20, 40, 30, 15); + + expect(context.getCalls()).toEqual([ + {name: 'moveTo', args: [10, 35]}, + {name: 'moveTo', args: [25, 20]}, + {name: 'arc', args: [35, 35, 15, -Math.PI / 2, Math.PI / 2]}, + {name: 'arc', args: [25, 35, 15, Math.PI / 2, Math.PI * 3 / 2]}, + {name: 'closePath', args: []}, + {name: 'moveTo', args: [10, 20]} + ]); + }); + it('should optimize path if radius is exactly half of width', function() { + var context = window.createMockContext(); + + helpers.canvas.roundedRect(context, 10, 20, 30, 40, 15); + + expect(context.getCalls()).toEqual([ + {name: 'moveTo', args: [10, 35]}, + {name: 'arc', args: [25, 35, 15, -Math.PI, 0]}, + {name: 'arc', args: [25, 45, 15, 0, Math.PI]}, + {name: 'closePath', args: []}, + {name: 'moveTo', args: [10, 20]} + ]); + }); + it('should optimize path if radius is exactly half of width and height', function() { + var context = window.createMockContext(); + + helpers.canvas.roundedRect(context, 10, 20, 30, 30, 15); + + expect(context.getCalls()).toEqual([ + {name: 'moveTo', args: [10, 35]}, + {name: 'arc', args: [25, 35, 15, -Math.PI, Math.PI]}, {name: 'closePath', args: []}, {name: 'moveTo', args: [10, 20]} ]);