From 86177d0cef2341ea84f81aff8cffd9398f8dd0ca Mon Sep 17 00:00:00 2001 From: kurkle Date: Thu, 17 Jun 2021 09:01:25 +0300 Subject: [PATCH 1/2] Refactor radialLinear scale and renderText helper --- .codeclimate.yml | 5 +- src/helpers/helpers.canvas.js | 92 ++--- src/scales/scale.radialLinear.js | 524 ++++++++++++++--------------- test/specs/helpers.canvas.tests.js | 6 +- test/specs/plugin.title.tests.js | 18 +- 5 files changed, 324 insertions(+), 321 deletions(-) diff --git a/.codeclimate.yml b/.codeclimate.yml index b7f9662a17e..81a86a8b1cc 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -8,9 +8,12 @@ plugins: fixme: enabled: true checks: + argument-count: + config: + threshold: 5 method-complexity: config: - threshold: 6 + threshold: 7 exclude_patterns: - "dist/" - "docs/" diff --git a/src/helpers/helpers.canvas.js b/src/helpers/helpers.canvas.js index a23dce715a5..faae4e5db34 100644 --- a/src/helpers/helpers.canvas.js +++ b/src/helpers/helpers.canvas.js @@ -310,28 +310,8 @@ export function renderText(ctx, text, x, y, font, opts = {}) { let i, line; ctx.save(); - - if (opts.translation) { - ctx.translate(opts.translation[0], opts.translation[1]); - } - - if (!isNullOrUndef(opts.rotation)) { - ctx.rotate(opts.rotation); - } - ctx.font = font.string; - - if (opts.color) { - ctx.fillStyle = opts.color; - } - - if (opts.textAlign) { - ctx.textAlign = opts.textAlign; - } - - if (opts.textBaseline) { - ctx.textBaseline = opts.textBaseline; - } + setRenderOpts(ctx, opts); for (i = 0; i < lines.length; ++i) { line = lines[i]; @@ -349,35 +329,61 @@ export function renderText(ctx, text, x, y, font, opts = {}) { } ctx.fillText(line, x, y, opts.maxWidth); + decorateText(ctx, x, y, line, opts); - if (opts.strikethrough || opts.underline) { - /** - * Now that IE11 support has been dropped, we can use more - * of the TextMetrics object. The actual bounding boxes - * are unflagged in Chrome, Firefox, Edge, and Safari so they - * can be safely used. - * See https://developer.mozilla.org/en-US/docs/Web/API/TextMetrics#Browser_compatibility - */ - const metrics = ctx.measureText(line); - const left = x - metrics.actualBoundingBoxLeft; - const right = x + metrics.actualBoundingBoxRight; - const top = y - metrics.actualBoundingBoxAscent; - const bottom = y + metrics.actualBoundingBoxDescent; - const yDecoration = opts.strikethrough ? (top + bottom) / 2 : bottom; - - ctx.strokeStyle = ctx.fillStyle; - ctx.beginPath(); - ctx.lineWidth = opts.decorationWidth || 2; - ctx.moveTo(left, yDecoration); - ctx.lineTo(right, yDecoration); - ctx.stroke(); - } y += font.lineHeight; } ctx.restore(); } +function setRenderOpts(ctx, opts) { + if (opts.translation) { + ctx.translate(opts.translation[0], opts.translation[1]); + } + + if (!isNullOrUndef(opts.rotation)) { + ctx.rotate(opts.rotation); + } + + if (opts.color) { + ctx.fillStyle = opts.color; + } + + if (opts.textAlign) { + ctx.textAlign = opts.textAlign; + } + + if (opts.textBaseline) { + ctx.textBaseline = opts.textBaseline; + } +} + +function decorateText(ctx, x, y, line, opts) { + if (opts.strikethrough || opts.underline) { + /** + * Now that IE11 support has been dropped, we can use more + * of the TextMetrics object. The actual bounding boxes + * are unflagged in Chrome, Firefox, Edge, and Safari so they + * can be safely used. + * See https://developer.mozilla.org/en-US/docs/Web/API/TextMetrics#Browser_compatibility + */ + const metrics = ctx.measureText(line); + const left = x - metrics.actualBoundingBoxLeft; + const right = x + metrics.actualBoundingBoxRight; + const top = y - metrics.actualBoundingBoxAscent; + const bottom = y + metrics.actualBoundingBoxDescent; + const yDecoration = opts.strikethrough ? (top + bottom) / 2 : bottom; + + ctx.strokeStyle = ctx.fillStyle; + ctx.beginPath(); + ctx.lineWidth = opts.decorationWidth || 2; + ctx.moveTo(left, yDecoration); + ctx.lineTo(right, yDecoration); + ctx.stroke(); + } +} + /** * Add a path of a rectangle with rounded corners to the current sub-path * @param {CanvasRenderingContext2D} ctx Context diff --git a/src/scales/scale.radialLinear.js b/src/scales/scale.radialLinear.js index 7be1d220c35..4fe75837dc8 100644 --- a/src/scales/scale.radialLinear.js +++ b/src/scales/scale.radialLinear.js @@ -6,271 +6,6 @@ import Ticks from '../core/core.ticks'; import {valueOrDefault, isArray, isFinite, callback as callCallback, isNullOrUndef} from '../helpers/helpers.core'; import {toFont, toPadding} from '../helpers/helpers.options'; -function getTickBackdropHeight(opts) { - const tickOpts = opts.ticks; - - if (tickOpts.display && opts.display) { - const padding = toPadding(tickOpts.backdropPadding); - return valueOrDefault(tickOpts.font && tickOpts.font.size, defaults.font.size) + padding.height; - } - return 0; -} - -function measureLabelSize(ctx, lineHeight, label) { - if (isArray(label)) { - return { - w: _longestText(ctx, ctx.font, label), - h: label.length * lineHeight - }; - } - - return { - w: ctx.measureText(label).width, - h: lineHeight - }; -} - -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, - end: pos - }; - } - - return { - start: pos, - end: pos + size - }; -} - -/** - * Helper function to fit a radial linear scale with point labels - */ -function fitWithPointLabels(scale) { - - // Right, this is really confusing and there is a lot of maths going on here - // The gist of the problem is here: https://gist.github.com/nnnick/696cc9c55f4b0beb8fe9 - // - // Reaction: https://dl.dropboxusercontent.com/u/34601363/toomuchscience.gif - // - // Solution: - // - // We assume the radius of the polygon is half the size of the canvas at first - // at each index we check if the text overlaps. - // - // Where it does, we store that angle and that index. - // - // After finding the largest index and angle we calculate how much we need to remove - // from the shape radius to move the point inwards by that x. - // - // We average the left and right distances to get the maximum shape radius that can fit in the box - // along with labels. - // - // Once we have that, we can find the centre point for the chart, by taking the x text protrusion - // on each side, removing that from the size, halving it and adding the left x protrusion width. - // - // This will mean we have a shape fitted to the canvas, as large as it can be with the labels - // and position it in the most space efficient manner - // - // https://dl.dropboxusercontent.com/u/34601363/yeahscience.gif - - // 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 - const furthestLimits = { - l: 0, - r: scale.width, - t: 0, - b: scale.height - scale.paddingTop - }; - const furthestAngles = {}; - let i, textSize, pointPosition; - - const labelSizes = []; - const padding = []; - - const valueCount = scale.getLabels().length; - for (i = 0; i < valueCount; i++) { - const opts = scale.options.pointLabels.setContext(scale.getContext(i)); - padding[i] = opts.padding; - pointPosition = scale.getPointPosition(i, scale.drawingArea + padding[i]); - const plFont = toFont(opts.font); - scale.ctx.font = plFont.string; - textSize = measureLabelSize(scale.ctx, plFont.lineHeight, scale._pointLabels[i]); - labelSizes[i] = textSize; - - // Add quarter circle to make degree 0 mean top of circle - const angleRadians = scale.getIndexAngle(i); - const angle = toDegrees(angleRadians); - const hLimits = determineLimits(angle, pointPosition.x, textSize.w, 0, 180); - const vLimits = determineLimits(angle, pointPosition.y, textSize.h, 90, 270); - - if (hLimits.start < furthestLimits.l) { - furthestLimits.l = hLimits.start; - furthestAngles.l = angleRadians; - } - - if (hLimits.end > furthestLimits.r) { - furthestLimits.r = hLimits.end; - furthestAngles.r = angleRadians; - } - - if (vLimits.start < furthestLimits.t) { - furthestLimits.t = vLimits.start; - furthestAngles.t = angleRadians; - } - - if (vLimits.end > furthestLimits.b) { - furthestLimits.b = vLimits.end; - furthestAngles.b = angleRadians; - } - } - - scale._setReductions(scale.drawingArea, furthestLimits, furthestAngles); - - scale._pointLabelItems = []; - - // Now that text size is determined, compute the full positions - const opts = scale.options; - const tickBackdropHeight = getTickBackdropHeight(opts); - const outerDistance = scale.getDistanceFromCenterForValue(opts.ticks.reverse ? scale.min : scale.max); - - for (i = 0; i < valueCount; i++) { - // Extra pixels out for some label spacing - const extra = (i === 0 ? tickBackdropHeight / 2 : 0); - const pointLabelPosition = scale.getPointPosition(i, outerDistance + extra + padding[i]); - - const angle = toDegrees(scale.getIndexAngle(i)); - const size = labelSizes[i]; - adjustPointPositionForLabelHeight(angle, size, pointLabelPosition); - - const textAlign = getTextAlignForAngle(angle); - let left; - - if (textAlign === 'left') { - left = pointLabelPosition.x; - } else if (textAlign === 'center') { - left = pointLabelPosition.x - (size.w / 2); - } else { - left = pointLabelPosition.x - size.w; - } - - const right = left + size.w; - - scale._pointLabelItems[i] = { - // Text position - x: pointLabelPosition.x, - y: pointLabelPosition.y, - - // Text rendering data - textAlign, - - // Bounding box - left, - top: pointLabelPosition.y, - right, - bottom: pointLabelPosition.y + size.h, - }; - } -} - -function getTextAlignForAngle(angle) { - if (angle === 0 || angle === 180) { - return 'center'; - } else if (angle < 180) { - return 'left'; - } - - return 'right'; -} - -function adjustPointPositionForLabelHeight(angle, textSize, position) { - if (angle === 90 || angle === 270) { - position.y -= (textSize.h / 2); - } else if (angle > 270 || angle < 90) { - position.y -= textSize.h; - } -} - -function drawPointLabels(scale, labelCount) { - const {ctx, options: {pointLabels}} = scale; - - for (let i = labelCount - 1; i >= 0; i--) { - const optsAtIndex = pointLabels.setContext(scale.getContext(i)); - const plFont = toFont(optsAtIndex.font); - const {x, y, textAlign, left, top, right, bottom} = scale._pointLabelItems[i]; - const {backdropColor} = optsAtIndex; - - if (!isNullOrUndef(backdropColor)) { - const padding = toPadding(optsAtIndex.backdropPadding); - ctx.fillStyle = backdropColor; - ctx.fillRect(left - padding.left, top - padding.top, right - left + padding.width, bottom - top + padding.height); - } - - renderText( - ctx, - scale._pointLabels[i], - x, - y + (plFont.lineHeight / 2), - plFont, - { - color: optsAtIndex.color, - textAlign: textAlign, - textBaseline: 'middle' - } - ); - } -} - -function pathRadiusLine(scale, radius, circular, labelCount) { - const {ctx} = scale; - if (circular) { - // Draw circular arcs between the points - ctx.arc(scale.xCenter, scale.yCenter, radius, 0, TAU); - } else { - // Draw straight lines connecting each index - let pointPosition = scale.getPointPosition(0, radius); - ctx.moveTo(pointPosition.x, pointPosition.y); - - for (let i = 1; i < labelCount; i++) { - pointPosition = scale.getPointPosition(i, radius); - ctx.lineTo(pointPosition.x, pointPosition.y); - } - } -} - -function drawRadiusLine(scale, gridLineOpts, radius, labelCount) { - const ctx = scale.ctx; - const circular = gridLineOpts.circular; - - const {color, lineWidth} = gridLineOpts; - - if ((!circular && !labelCount) || !color || !lineWidth || radius < 0) { - return; - } - - ctx.save(); - ctx.strokeStyle = color; - ctx.lineWidth = lineWidth; - ctx.setLineDash(gridLineOpts.borderDash); - ctx.lineDashOffset = gridLineOpts.borderDashOffset; - - ctx.beginPath(); - pathRadiusLine(scale, radius, circular, labelCount); - ctx.closePath(); - ctx.stroke(); - ctx.restore(); -} - -function numberOrZero(param) { - return isNumber(param) ? param : 0; -} - export default class RadialLinearScale extends LinearScaleBase { constructor(cfg) { @@ -543,6 +278,7 @@ export default class RadialLinearScale extends LinearScaleBase { offset = me.getDistanceFromCenterForValue(me.ticks[index].value); if (optsAtIndex.showLabelBackdrop) { + ctx.font = tickFont.string; width = ctx.measureText(tick.label).width; ctx.fillStyle = optsAtIndex.backdropColor; @@ -637,3 +373,261 @@ RadialLinearScale.descriptors = { _fallback: 'grid' } }; + +function getTickBackdropHeight(opts) { + const tickOpts = opts.ticks; + + if (tickOpts.display && opts.display) { + const padding = toPadding(tickOpts.backdropPadding); + return valueOrDefault(tickOpts.font && tickOpts.font.size, defaults.font.size) + padding.height; + } + return 0; +} + +function measureLabelSize(ctx, font, label) { + label = isArray(label) ? label : [label]; + return { + w: _longestText(ctx, font.string, label), + h: label.length * font.lineHeight + }; +} + +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, + end: pos + }; + } + + return { + start: pos, + end: pos + size + }; +} + +/** + * Helper function to fit a radial linear scale with point labels + */ +function fitWithPointLabels(scale) { + + // Right, this is really confusing and there is a lot of maths going on here + // The gist of the problem is here: https://gist.github.com/nnnick/696cc9c55f4b0beb8fe9 + // + // Reaction: https://dl.dropboxusercontent.com/u/34601363/toomuchscience.gif + // + // Solution: + // + // We assume the radius of the polygon is half the size of the canvas at first + // at each index we check if the text overlaps. + // + // Where it does, we store that angle and that index. + // + // After finding the largest index and angle we calculate how much we need to remove + // from the shape radius to move the point inwards by that x. + // + // We average the left and right distances to get the maximum shape radius that can fit in the box + // along with labels. + // + // Once we have that, we can find the centre point for the chart, by taking the x text protrusion + // on each side, removing that from the size, halving it and adding the left x protrusion width. + // + // This will mean we have a shape fitted to the canvas, as large as it can be with the labels + // and position it in the most space efficient manner + // + // https://dl.dropboxusercontent.com/u/34601363/yeahscience.gif + + // 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 + const furthestLimits = { + l: 0, + r: scale.width, + t: 0, + b: scale.height - scale.paddingTop + }; + const furthestAngles = {}; + const labelSizes = []; + const padding = []; + + const valueCount = scale.getLabels().length; + for (let i = 0; i < valueCount; i++) { + const opts = scale.options.pointLabels.setContext(scale.getContext(i)); + padding[i] = opts.padding; + const pointPosition = scale.getPointPosition(i, scale.drawingArea + padding[i]); + const plFont = toFont(opts.font); + const textSize = measureLabelSize(scale.ctx, plFont, scale._pointLabels[i]); + labelSizes[i] = textSize; + + const angleRadians = scale.getIndexAngle(i); + const angle = toDegrees(angleRadians); + const hLimits = determineLimits(angle, pointPosition.x, textSize.w, 0, 180); + const vLimits = determineLimits(angle, pointPosition.y, textSize.h, 90, 270); + + if (hLimits.start < furthestLimits.l) { + furthestLimits.l = hLimits.start; + furthestAngles.l = angleRadians; + } + + if (hLimits.end > furthestLimits.r) { + furthestLimits.r = hLimits.end; + furthestAngles.r = angleRadians; + } + + if (vLimits.start < furthestLimits.t) { + furthestLimits.t = vLimits.start; + furthestAngles.t = angleRadians; + } + + if (vLimits.end > furthestLimits.b) { + furthestLimits.b = vLimits.end; + furthestAngles.b = angleRadians; + } + } + + scale._setReductions(scale.drawingArea, furthestLimits, furthestAngles); + + // Now that text size is determined, compute the full positions + scale._pointLabelItems = buildPointLabelItems(scale, labelSizes, padding); +} + +function buildPointLabelItems(scale, labelSizes, padding) { + const items = []; + const valueCount = scale.getLabels().length; + const opts = scale.options; + const tickBackdropHeight = getTickBackdropHeight(opts); + const outerDistance = scale.getDistanceFromCenterForValue(opts.ticks.reverse ? scale.min : scale.max); + + for (let i = 0; i < valueCount; i++) { + // Extra pixels out for some label spacing + const extra = (i === 0 ? tickBackdropHeight / 2 : 0); + const pointLabelPosition = scale.getPointPosition(i, outerDistance + extra + padding[i]); + const angle = toDegrees(scale.getIndexAngle(i)); + const size = labelSizes[i]; + const y = yForAngle(pointLabelPosition.y, size.h, angle); + const textAlign = getTextAlignForAngle(angle); + const left = leftForTextAlign(pointLabelPosition.x, size.w, textAlign); + + items.push({ + // Text position + x: pointLabelPosition.x, + y, + + // Text rendering data + textAlign, + + // Bounding box + left, + top: y, + right: left + size.w, + bottom: y + size.h + }); + } + return items; +} + +function getTextAlignForAngle(angle) { + if (angle === 0 || angle === 180) { + return 'center'; + } else if (angle < 180) { + return 'left'; + } + + return 'right'; +} + +function leftForTextAlign(x, w, align) { + if (align === 'right') { + x -= w; + } else if (align === 'center') { + x -= (w / 2); + } + return x; +} + +function yForAngle(y, h, angle) { + if (angle === 90 || angle === 270) { + y -= (h / 2); + } else if (angle > 270 || angle < 90) { + y -= h; + } + return y; +} + +function drawPointLabels(scale, labelCount) { + const {ctx, options: {pointLabels}} = scale; + + for (let i = labelCount - 1; i >= 0; i--) { + const optsAtIndex = pointLabels.setContext(scale.getContext(i)); + const plFont = toFont(optsAtIndex.font); + const {x, y, textAlign, left, top, right, bottom} = scale._pointLabelItems[i]; + const {backdropColor} = optsAtIndex; + + if (!isNullOrUndef(backdropColor)) { + const padding = toPadding(optsAtIndex.backdropPadding); + ctx.fillStyle = backdropColor; + ctx.fillRect(left - padding.left, top - padding.top, right - left + padding.width, bottom - top + padding.height); + } + + renderText( + ctx, + scale._pointLabels[i], + x, + y + (plFont.lineHeight / 2), + plFont, + { + color: optsAtIndex.color, + textAlign: textAlign, + textBaseline: 'middle' + } + ); + } +} + +function pathRadiusLine(scale, radius, circular, labelCount) { + const {ctx} = scale; + if (circular) { + // Draw circular arcs between the points + ctx.arc(scale.xCenter, scale.yCenter, radius, 0, TAU); + } else { + // Draw straight lines connecting each index + let pointPosition = scale.getPointPosition(0, radius); + ctx.moveTo(pointPosition.x, pointPosition.y); + + for (let i = 1; i < labelCount; i++) { + pointPosition = scale.getPointPosition(i, radius); + ctx.lineTo(pointPosition.x, pointPosition.y); + } + } +} + +function drawRadiusLine(scale, gridLineOpts, radius, labelCount) { + const ctx = scale.ctx; + const circular = gridLineOpts.circular; + + const {color, lineWidth} = gridLineOpts; + + if ((!circular && !labelCount) || !color || !lineWidth || radius < 0) { + return; + } + + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = lineWidth; + ctx.setLineDash(gridLineOpts.borderDash); + ctx.lineDashOffset = gridLineOpts.borderDashOffset; + + ctx.beginPath(); + pathRadiusLine(scale, radius, circular, labelCount); + ctx.closePath(); + ctx.stroke(); + ctx.restore(); +} + +function numberOrZero(param) { + return isNumber(param) ? param : 0; +} diff --git a/test/specs/helpers.canvas.tests.js b/test/specs/helpers.canvas.tests.js index e451d7f1331..297ff03ef2e 100644 --- a/test/specs/helpers.canvas.tests.js +++ b/test/specs/helpers.canvas.tests.js @@ -316,15 +316,15 @@ describe('Chart.helpers.canvas', function() { expect(context.getCalls()).toEqual([{ name: 'save', args: [], + }, { + name: 'setFont', + args: ['12px arial'], }, { name: 'translate', args: [10, 20], }, { name: 'rotate', args: [90], - }, { - name: 'setFont', - args: ['12px arial'], }, { name: 'fillText', args: ['foo', 0, 0, undefined], diff --git a/test/specs/plugin.title.tests.js b/test/specs/plugin.title.tests.js index 564c509278b..c08b9d4c902 100644 --- a/test/specs/plugin.title.tests.js +++ b/test/specs/plugin.title.tests.js @@ -131,15 +131,15 @@ describe('Plugin.title', function() { expect(context.getCalls()).toEqual([{ name: 'save', args: [] + }, { + name: 'setFont', + args: ["normal bold 12px 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif"], }, { name: 'translate', args: [300, 67.2] }, { name: 'rotate', args: [0] - }, { - name: 'setFont', - args: ["normal bold 12px 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif"], }, { name: 'setFillStyle', args: ['#666'] @@ -192,15 +192,15 @@ describe('Plugin.title', function() { expect(context.getCalls()).toEqual([{ name: 'save', args: [] + }, { + name: 'setFont', + args: ["normal bold 12px 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif"], }, { name: 'translate', args: [117.2, 250] }, { name: 'rotate', args: [-0.5 * Math.PI] - }, { - name: 'setFont', - args: ["normal bold 12px 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif"], }, { name: 'setFillStyle', args: ['#666'] @@ -234,15 +234,15 @@ describe('Plugin.title', function() { expect(context.getCalls()).toEqual([{ name: 'save', args: [] + }, { + name: 'setFont', + args: ["normal bold 12px 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif"], }, { name: 'translate', args: [117.2, 250] }, { name: 'rotate', args: [0.5 * Math.PI] - }, { - name: 'setFont', - args: ["normal bold 12px 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif"], }, { name: 'setFillStyle', args: ['#666'] From e05e76998d9e9acdfb273cbdc1f82335ead3000e Mon Sep 17 00:00:00 2001 From: kurkle Date: Thu, 17 Jun 2021 09:13:58 +0300 Subject: [PATCH 2/2] Undo the big move to make review possible --- src/scales/scale.radialLinear.js | 756 +++++++++++++++---------------- 1 file changed, 378 insertions(+), 378 deletions(-) diff --git a/src/scales/scale.radialLinear.js b/src/scales/scale.radialLinear.js index 4fe75837dc8..e1702975a04 100644 --- a/src/scales/scale.radialLinear.js +++ b/src/scales/scale.radialLinear.js @@ -6,6 +6,264 @@ import Ticks from '../core/core.ticks'; import {valueOrDefault, isArray, isFinite, callback as callCallback, isNullOrUndef} from '../helpers/helpers.core'; import {toFont, toPadding} from '../helpers/helpers.options'; +function getTickBackdropHeight(opts) { + const tickOpts = opts.ticks; + + if (tickOpts.display && opts.display) { + const padding = toPadding(tickOpts.backdropPadding); + return valueOrDefault(tickOpts.font && tickOpts.font.size, defaults.font.size) + padding.height; + } + return 0; +} + +function measureLabelSize(ctx, font, label) { + label = isArray(label) ? label : [label]; + return { + w: _longestText(ctx, font.string, label), + h: label.length * font.lineHeight + }; +} + +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, + end: pos + }; + } + + return { + start: pos, + end: pos + size + }; +} + +/** + * Helper function to fit a radial linear scale with point labels + */ +function fitWithPointLabels(scale) { + + // Right, this is really confusing and there is a lot of maths going on here + // The gist of the problem is here: https://gist.github.com/nnnick/696cc9c55f4b0beb8fe9 + // + // Reaction: https://dl.dropboxusercontent.com/u/34601363/toomuchscience.gif + // + // Solution: + // + // We assume the radius of the polygon is half the size of the canvas at first + // at each index we check if the text overlaps. + // + // Where it does, we store that angle and that index. + // + // After finding the largest index and angle we calculate how much we need to remove + // from the shape radius to move the point inwards by that x. + // + // We average the left and right distances to get the maximum shape radius that can fit in the box + // along with labels. + // + // Once we have that, we can find the centre point for the chart, by taking the x text protrusion + // on each side, removing that from the size, halving it and adding the left x protrusion width. + // + // This will mean we have a shape fitted to the canvas, as large as it can be with the labels + // and position it in the most space efficient manner + // + // https://dl.dropboxusercontent.com/u/34601363/yeahscience.gif + + // 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 + const furthestLimits = { + l: 0, + r: scale.width, + t: 0, + b: scale.height - scale.paddingTop + }; + const furthestAngles = {}; + const labelSizes = []; + const padding = []; + + const valueCount = scale.getLabels().length; + for (let i = 0; i < valueCount; i++) { + const opts = scale.options.pointLabels.setContext(scale.getContext(i)); + padding[i] = opts.padding; + const pointPosition = scale.getPointPosition(i, scale.drawingArea + padding[i]); + const plFont = toFont(opts.font); + const textSize = measureLabelSize(scale.ctx, plFont, scale._pointLabels[i]); + labelSizes[i] = textSize; + + const angleRadians = scale.getIndexAngle(i); + const angle = toDegrees(angleRadians); + const hLimits = determineLimits(angle, pointPosition.x, textSize.w, 0, 180); + const vLimits = determineLimits(angle, pointPosition.y, textSize.h, 90, 270); + + if (hLimits.start < furthestLimits.l) { + furthestLimits.l = hLimits.start; + furthestAngles.l = angleRadians; + } + + if (hLimits.end > furthestLimits.r) { + furthestLimits.r = hLimits.end; + furthestAngles.r = angleRadians; + } + + if (vLimits.start < furthestLimits.t) { + furthestLimits.t = vLimits.start; + furthestAngles.t = angleRadians; + } + + if (vLimits.end > furthestLimits.b) { + furthestLimits.b = vLimits.end; + furthestAngles.b = angleRadians; + } + } + + scale._setReductions(scale.drawingArea, furthestLimits, furthestAngles); + + // Now that text size is determined, compute the full positions + scale._pointLabelItems = buildPointLabelItems(scale, labelSizes, padding); +} + +function buildPointLabelItems(scale, labelSizes, padding) { + const items = []; + const valueCount = scale.getLabels().length; + const opts = scale.options; + const tickBackdropHeight = getTickBackdropHeight(opts); + const outerDistance = scale.getDistanceFromCenterForValue(opts.ticks.reverse ? scale.min : scale.max); + + for (let i = 0; i < valueCount; i++) { + // Extra pixels out for some label spacing + const extra = (i === 0 ? tickBackdropHeight / 2 : 0); + const pointLabelPosition = scale.getPointPosition(i, outerDistance + extra + padding[i]); + const angle = toDegrees(scale.getIndexAngle(i)); + const size = labelSizes[i]; + const y = yForAngle(pointLabelPosition.y, size.h, angle); + const textAlign = getTextAlignForAngle(angle); + const left = leftForTextAlign(pointLabelPosition.x, size.w, textAlign); + + items.push({ + // Text position + x: pointLabelPosition.x, + y, + + // Text rendering data + textAlign, + + // Bounding box + left, + top: y, + right: left + size.w, + bottom: y + size.h + }); + } + return items; +} + +function getTextAlignForAngle(angle) { + if (angle === 0 || angle === 180) { + return 'center'; + } else if (angle < 180) { + return 'left'; + } + + return 'right'; +} + +function leftForTextAlign(x, w, align) { + if (align === 'right') { + x -= w; + } else if (align === 'center') { + x -= (w / 2); + } + return x; +} + +function yForAngle(y, h, angle) { + if (angle === 90 || angle === 270) { + y -= (h / 2); + } else if (angle > 270 || angle < 90) { + y -= h; + } + return y; +} + +function drawPointLabels(scale, labelCount) { + const {ctx, options: {pointLabels}} = scale; + + for (let i = labelCount - 1; i >= 0; i--) { + const optsAtIndex = pointLabels.setContext(scale.getContext(i)); + const plFont = toFont(optsAtIndex.font); + const {x, y, textAlign, left, top, right, bottom} = scale._pointLabelItems[i]; + const {backdropColor} = optsAtIndex; + + if (!isNullOrUndef(backdropColor)) { + const padding = toPadding(optsAtIndex.backdropPadding); + ctx.fillStyle = backdropColor; + ctx.fillRect(left - padding.left, top - padding.top, right - left + padding.width, bottom - top + padding.height); + } + + renderText( + ctx, + scale._pointLabels[i], + x, + y + (plFont.lineHeight / 2), + plFont, + { + color: optsAtIndex.color, + textAlign: textAlign, + textBaseline: 'middle' + } + ); + } +} + +function pathRadiusLine(scale, radius, circular, labelCount) { + const {ctx} = scale; + if (circular) { + // Draw circular arcs between the points + ctx.arc(scale.xCenter, scale.yCenter, radius, 0, TAU); + } else { + // Draw straight lines connecting each index + let pointPosition = scale.getPointPosition(0, radius); + ctx.moveTo(pointPosition.x, pointPosition.y); + + for (let i = 1; i < labelCount; i++) { + pointPosition = scale.getPointPosition(i, radius); + ctx.lineTo(pointPosition.x, pointPosition.y); + } + } +} + +function drawRadiusLine(scale, gridLineOpts, radius, labelCount) { + const ctx = scale.ctx; + const circular = gridLineOpts.circular; + + const {color, lineWidth} = gridLineOpts; + + if ((!circular && !labelCount) || !color || !lineWidth || radius < 0) { + return; + } + + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = lineWidth; + ctx.setLineDash(gridLineOpts.borderDash); + ctx.lineDashOffset = gridLineOpts.borderDashOffset; + + ctx.beginPath(); + pathRadiusLine(scale, radius, circular, labelCount); + ctx.closePath(); + ctx.stroke(); + ctx.restore(); +} + +function numberOrZero(param) { + return isNumber(param) ? param : 0; +} + export default class RadialLinearScale extends LinearScaleBase { constructor(cfg) { @@ -220,414 +478,156 @@ export default class RadialLinearScale extends LinearScaleBase { const {color, lineWidth} = optsAtIndex; if (!lineWidth || !color) { - continue; - } - - ctx.lineWidth = lineWidth; - ctx.strokeStyle = color; - - ctx.setLineDash(optsAtIndex.borderDash); - ctx.lineDashOffset = optsAtIndex.borderDashOffset; - - offset = me.getDistanceFromCenterForValue(opts.ticks.reverse ? me.min : me.max); - position = me.getPointPosition(i, offset); - ctx.beginPath(); - ctx.moveTo(me.xCenter, me.yCenter); - ctx.lineTo(position.x, position.y); - ctx.stroke(); - } - - ctx.restore(); - } - } - - /** - * @protected - */ - drawBorder() {} - - /** - * @protected - */ - drawLabels() { - const me = this; - const ctx = me.ctx; - const opts = me.options; - const tickOpts = opts.ticks; - - if (!tickOpts.display) { - return; - } - - const startAngle = me.getIndexAngle(0); - let offset, width; - - ctx.save(); - ctx.translate(me.xCenter, me.yCenter); - ctx.rotate(startAngle); - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - - me.ticks.forEach((tick, index) => { - if (index === 0 && !opts.reverse) { - return; - } - - const optsAtIndex = tickOpts.setContext(me.getContext(index)); - const tickFont = toFont(optsAtIndex.font); - offset = me.getDistanceFromCenterForValue(me.ticks[index].value); - - if (optsAtIndex.showLabelBackdrop) { - ctx.font = tickFont.string; - width = ctx.measureText(tick.label).width; - ctx.fillStyle = optsAtIndex.backdropColor; - - const padding = toPadding(optsAtIndex.backdropPadding); - ctx.fillRect( - -width / 2 - padding.left, - -offset - tickFont.size / 2 - padding.top, - width + padding.width, - tickFont.size + padding.height - ); - } - - renderText(ctx, tick.label, 0, -offset, tickFont, { - color: optsAtIndex.color, - }); - }); - - ctx.restore(); - } - - /** - * @protected - */ - drawTitle() {} -} - -RadialLinearScale.id = 'radialLinear'; - -/** - * @type {any} - */ -RadialLinearScale.defaults = { - display: true, - - // Boolean - Whether to animate scaling the chart from the centre - animate: true, - position: 'chartArea', - - angleLines: { - display: true, - lineWidth: 1, - borderDash: [], - borderDashOffset: 0.0 - }, - - grid: { - circular: false - }, - - startAngle: 0, - - // label settings - ticks: { - // Boolean - Show a backdrop to the scale label - showLabelBackdrop: true, - - callback: Ticks.formatters.numeric - }, - - pointLabels: { - backdropColor: undefined, - - // Number - The backdrop padding above & below the label in pixels - backdropPadding: 2, - - // Boolean - if true, show point labels - display: true, - - // Number - Point label font size in pixels - font: { - size: 10 - }, - - // Function - Used to convert point labels - callback(label) { - return label; - }, - - // Number - Additionl padding between scale and pointLabel - padding: 5 - } -}; - -RadialLinearScale.defaultRoutes = { - 'angleLines.color': 'borderColor', - 'pointLabels.color': 'color', - 'ticks.color': 'color' -}; - -RadialLinearScale.descriptors = { - angleLines: { - _fallback: 'grid' - } -}; - -function getTickBackdropHeight(opts) { - const tickOpts = opts.ticks; - - if (tickOpts.display && opts.display) { - const padding = toPadding(tickOpts.backdropPadding); - return valueOrDefault(tickOpts.font && tickOpts.font.size, defaults.font.size) + padding.height; - } - return 0; -} - -function measureLabelSize(ctx, font, label) { - label = isArray(label) ? label : [label]; - return { - w: _longestText(ctx, font.string, label), - h: label.length * font.lineHeight - }; -} - -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, - end: pos - }; - } - - return { - start: pos, - end: pos + size - }; -} - -/** - * Helper function to fit a radial linear scale with point labels - */ -function fitWithPointLabels(scale) { - - // Right, this is really confusing and there is a lot of maths going on here - // The gist of the problem is here: https://gist.github.com/nnnick/696cc9c55f4b0beb8fe9 - // - // Reaction: https://dl.dropboxusercontent.com/u/34601363/toomuchscience.gif - // - // Solution: - // - // We assume the radius of the polygon is half the size of the canvas at first - // at each index we check if the text overlaps. - // - // Where it does, we store that angle and that index. - // - // After finding the largest index and angle we calculate how much we need to remove - // from the shape radius to move the point inwards by that x. - // - // We average the left and right distances to get the maximum shape radius that can fit in the box - // along with labels. - // - // Once we have that, we can find the centre point for the chart, by taking the x text protrusion - // on each side, removing that from the size, halving it and adding the left x protrusion width. - // - // This will mean we have a shape fitted to the canvas, as large as it can be with the labels - // and position it in the most space efficient manner - // - // https://dl.dropboxusercontent.com/u/34601363/yeahscience.gif - - // 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 - const furthestLimits = { - l: 0, - r: scale.width, - t: 0, - b: scale.height - scale.paddingTop - }; - const furthestAngles = {}; - const labelSizes = []; - const padding = []; - - const valueCount = scale.getLabels().length; - for (let i = 0; i < valueCount; i++) { - const opts = scale.options.pointLabels.setContext(scale.getContext(i)); - padding[i] = opts.padding; - const pointPosition = scale.getPointPosition(i, scale.drawingArea + padding[i]); - const plFont = toFont(opts.font); - const textSize = measureLabelSize(scale.ctx, plFont, scale._pointLabels[i]); - labelSizes[i] = textSize; + continue; + } - const angleRadians = scale.getIndexAngle(i); - const angle = toDegrees(angleRadians); - const hLimits = determineLimits(angle, pointPosition.x, textSize.w, 0, 180); - const vLimits = determineLimits(angle, pointPosition.y, textSize.h, 90, 270); + ctx.lineWidth = lineWidth; + ctx.strokeStyle = color; - if (hLimits.start < furthestLimits.l) { - furthestLimits.l = hLimits.start; - furthestAngles.l = angleRadians; - } + ctx.setLineDash(optsAtIndex.borderDash); + ctx.lineDashOffset = optsAtIndex.borderDashOffset; - if (hLimits.end > furthestLimits.r) { - furthestLimits.r = hLimits.end; - furthestAngles.r = angleRadians; - } + offset = me.getDistanceFromCenterForValue(opts.ticks.reverse ? me.min : me.max); + position = me.getPointPosition(i, offset); + ctx.beginPath(); + ctx.moveTo(me.xCenter, me.yCenter); + ctx.lineTo(position.x, position.y); + ctx.stroke(); + } - if (vLimits.start < furthestLimits.t) { - furthestLimits.t = vLimits.start; - furthestAngles.t = angleRadians; + ctx.restore(); } + } - if (vLimits.end > furthestLimits.b) { - furthestLimits.b = vLimits.end; - furthestAngles.b = angleRadians; + /** + * @protected + */ + drawBorder() {} + + /** + * @protected + */ + drawLabels() { + const me = this; + const ctx = me.ctx; + const opts = me.options; + const tickOpts = opts.ticks; + + if (!tickOpts.display) { + return; } - } - scale._setReductions(scale.drawingArea, furthestLimits, furthestAngles); + const startAngle = me.getIndexAngle(0); + let offset, width; - // Now that text size is determined, compute the full positions - scale._pointLabelItems = buildPointLabelItems(scale, labelSizes, padding); -} + ctx.save(); + ctx.translate(me.xCenter, me.yCenter); + ctx.rotate(startAngle); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; -function buildPointLabelItems(scale, labelSizes, padding) { - const items = []; - const valueCount = scale.getLabels().length; - const opts = scale.options; - const tickBackdropHeight = getTickBackdropHeight(opts); - const outerDistance = scale.getDistanceFromCenterForValue(opts.ticks.reverse ? scale.min : scale.max); + me.ticks.forEach((tick, index) => { + if (index === 0 && !opts.reverse) { + return; + } - for (let i = 0; i < valueCount; i++) { - // Extra pixels out for some label spacing - const extra = (i === 0 ? tickBackdropHeight / 2 : 0); - const pointLabelPosition = scale.getPointPosition(i, outerDistance + extra + padding[i]); - const angle = toDegrees(scale.getIndexAngle(i)); - const size = labelSizes[i]; - const y = yForAngle(pointLabelPosition.y, size.h, angle); - const textAlign = getTextAlignForAngle(angle); - const left = leftForTextAlign(pointLabelPosition.x, size.w, textAlign); + const optsAtIndex = tickOpts.setContext(me.getContext(index)); + const tickFont = toFont(optsAtIndex.font); + offset = me.getDistanceFromCenterForValue(me.ticks[index].value); - items.push({ - // Text position - x: pointLabelPosition.x, - y, + if (optsAtIndex.showLabelBackdrop) { + ctx.font = tickFont.string; + width = ctx.measureText(tick.label).width; + ctx.fillStyle = optsAtIndex.backdropColor; - // Text rendering data - textAlign, + const padding = toPadding(optsAtIndex.backdropPadding); + ctx.fillRect( + -width / 2 - padding.left, + -offset - tickFont.size / 2 - padding.top, + width + padding.width, + tickFont.size + padding.height + ); + } - // Bounding box - left, - top: y, - right: left + size.w, - bottom: y + size.h + renderText(ctx, tick.label, 0, -offset, tickFont, { + color: optsAtIndex.color, + }); }); - } - return items; -} -function getTextAlignForAngle(angle) { - if (angle === 0 || angle === 180) { - return 'center'; - } else if (angle < 180) { - return 'left'; + ctx.restore(); } - return 'right'; + /** + * @protected + */ + drawTitle() {} } -function leftForTextAlign(x, w, align) { - if (align === 'right') { - x -= w; - } else if (align === 'center') { - x -= (w / 2); - } - return x; -} +RadialLinearScale.id = 'radialLinear'; -function yForAngle(y, h, angle) { - if (angle === 90 || angle === 270) { - y -= (h / 2); - } else if (angle > 270 || angle < 90) { - y -= h; - } - return y; -} +/** + * @type {any} + */ +RadialLinearScale.defaults = { + display: true, -function drawPointLabels(scale, labelCount) { - const {ctx, options: {pointLabels}} = scale; + // Boolean - Whether to animate scaling the chart from the centre + animate: true, + position: 'chartArea', - for (let i = labelCount - 1; i >= 0; i--) { - const optsAtIndex = pointLabels.setContext(scale.getContext(i)); - const plFont = toFont(optsAtIndex.font); - const {x, y, textAlign, left, top, right, bottom} = scale._pointLabelItems[i]; - const {backdropColor} = optsAtIndex; + angleLines: { + display: true, + lineWidth: 1, + borderDash: [], + borderDashOffset: 0.0 + }, - if (!isNullOrUndef(backdropColor)) { - const padding = toPadding(optsAtIndex.backdropPadding); - ctx.fillStyle = backdropColor; - ctx.fillRect(left - padding.left, top - padding.top, right - left + padding.width, bottom - top + padding.height); - } + grid: { + circular: false + }, - renderText( - ctx, - scale._pointLabels[i], - x, - y + (plFont.lineHeight / 2), - plFont, - { - color: optsAtIndex.color, - textAlign: textAlign, - textBaseline: 'middle' - } - ); - } -} + startAngle: 0, -function pathRadiusLine(scale, radius, circular, labelCount) { - const {ctx} = scale; - if (circular) { - // Draw circular arcs between the points - ctx.arc(scale.xCenter, scale.yCenter, radius, 0, TAU); - } else { - // Draw straight lines connecting each index - let pointPosition = scale.getPointPosition(0, radius); - ctx.moveTo(pointPosition.x, pointPosition.y); + // label settings + ticks: { + // Boolean - Show a backdrop to the scale label + showLabelBackdrop: true, - for (let i = 1; i < labelCount; i++) { - pointPosition = scale.getPointPosition(i, radius); - ctx.lineTo(pointPosition.x, pointPosition.y); - } - } -} + callback: Ticks.formatters.numeric + }, -function drawRadiusLine(scale, gridLineOpts, radius, labelCount) { - const ctx = scale.ctx; - const circular = gridLineOpts.circular; + pointLabels: { + backdropColor: undefined, - const {color, lineWidth} = gridLineOpts; + // Number - The backdrop padding above & below the label in pixels + backdropPadding: 2, - if ((!circular && !labelCount) || !color || !lineWidth || radius < 0) { - return; - } + // Boolean - if true, show point labels + display: true, - ctx.save(); - ctx.strokeStyle = color; - ctx.lineWidth = lineWidth; - ctx.setLineDash(gridLineOpts.borderDash); - ctx.lineDashOffset = gridLineOpts.borderDashOffset; + // Number - Point label font size in pixels + font: { + size: 10 + }, - ctx.beginPath(); - pathRadiusLine(scale, radius, circular, labelCount); - ctx.closePath(); - ctx.stroke(); - ctx.restore(); -} + // Function - Used to convert point labels + callback(label) { + return label; + }, -function numberOrZero(param) { - return isNumber(param) ? param : 0; -} + // Number - Additionl padding between scale and pointLabel + padding: 5 + } +}; + +RadialLinearScale.defaultRoutes = { + 'angleLines.color': 'borderColor', + 'pointLabels.color': 'color', + 'ticks.color': 'color' +}; + +RadialLinearScale.descriptors = { + angleLines: { + _fallback: 'grid' + } +};