chartjs/Chart.js

View on GitHub
src/scales/scale.radialLinear.js

Summary

Maintainability
D
3 days
Test Coverage
File `scale.radialLinear.js` has 500 lines of code (exceeds 250 allowed). Consider refactoring.
import defaults from '../core/core.defaults.js';
import {_longestText, addRoundedRectPath, renderText, _isPointInArea} from '../helpers/helpers.canvas.js';
import {HALF_PI, TAU, toDegrees, toRadians, _normalizeAngle, PI} from '../helpers/helpers.math.js';
import LinearScaleBase from './scale.linearbase.js';
import Ticks from '../core/core.ticks.js';
import {valueOrDefault, isArray, isFinite, callback as callCallback, isNullOrUndef} from '../helpers/helpers.core.js';
import {createContext, toFont, toPadding, toTRBLCorners} from '../helpers/helpers.options.js';
 
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` has 32 lines of code (exceeds 25 allowed). Consider refactoring.
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 orig = {
l: scale.left + scale._padding.left,
r: scale.right - scale._padding.right,
t: scale.top + scale._padding.top,
b: scale.bottom - scale._padding.bottom
};
const limits = Object.assign({}, orig);
const labelSizes = [];
const padding = [];
const valueCount = scale._pointLabels.length;
const pointLabelOpts = scale.options.pointLabels;
const additionalAngle = pointLabelOpts.centerPointLabels ? PI / valueCount : 0;
 
for (let i = 0; i < valueCount; i++) {
const opts = pointLabelOpts.setContext(scale.getPointLabelContext(i));
padding[i] = opts.padding;
const pointPosition = scale.getPointPosition(i, scale.drawingArea + padding[i], additionalAngle);
const plFont = toFont(opts.font);
const textSize = measureLabelSize(scale.ctx, plFont, scale._pointLabels[i]);
labelSizes[i] = textSize;
 
const angleRadians = _normalizeAngle(scale.getIndexAngle(i) + additionalAngle);
const angle = Math.round(toDegrees(angleRadians));
const hLimits = determineLimits(angle, pointPosition.x, textSize.w, 0, 180);
const vLimits = determineLimits(angle, pointPosition.y, textSize.h, 90, 270);
updateLimits(limits, orig, angleRadians, hLimits, vLimits);
}
 
scale.setCenterPoint(
orig.l - limits.l,
limits.r - orig.r,
orig.t - limits.t,
limits.b - orig.b
);
 
// Now that text size is determined, compute the full positions
scale._pointLabelItems = buildPointLabelItems(scale, labelSizes, padding);
}
 
function updateLimits(limits, orig, angle, hLimits, vLimits) {
const sin = Math.abs(Math.sin(angle));
const cos = Math.abs(Math.cos(angle));
let x = 0;
let y = 0;
Similar blocks of code found in 2 locations. Consider refactoring.
if (hLimits.start < orig.l) {
x = (orig.l - hLimits.start) / sin;
limits.l = Math.min(limits.l, orig.l - x);
} else if (hLimits.end > orig.r) {
x = (hLimits.end - orig.r) / sin;
limits.r = Math.max(limits.r, orig.r + x);
}
Similar blocks of code found in 2 locations. Consider refactoring.
if (vLimits.start < orig.t) {
y = (orig.t - vLimits.start) / cos;
limits.t = Math.min(limits.t, orig.t - y);
} else if (vLimits.end > orig.b) {
y = (vLimits.end - orig.b) / cos;
limits.b = Math.max(limits.b, orig.b + y);
}
}
 
function createPointLabelItem(scale, index, itemOpts) {
const outerDistance = scale.drawingArea;
const {extra, additionalAngle, padding, size} = itemOpts;
const pointLabelPosition = scale.getPointPosition(index, outerDistance + extra + padding, additionalAngle);
const angle = Math.round(toDegrees(_normalizeAngle(pointLabelPosition.angle + HALF_PI)));
const y = yForAngle(pointLabelPosition.y, size.h, angle);
const textAlign = getTextAlignForAngle(angle);
const left = leftForTextAlign(pointLabelPosition.x, size.w, textAlign);
return {
// if to draw or overlapped
visible: true,
 
// Text position
x: pointLabelPosition.x,
y,
 
// Text rendering data
textAlign,
 
// Bounding box
left,
top: y,
right: left + size.w,
bottom: y + size.h
};
}
 
function isNotOverlapped(item, area) {
if (!area) {
return true;
}
const {left, top, right, bottom} = item;
const apexesInArea = _isPointInArea({x: left, y: top}, area) || _isPointInArea({x: left, y: bottom}, area) ||
_isPointInArea({x: right, y: top}, area) || _isPointInArea({x: right, y: bottom}, area);
return !apexesInArea;
}
 
function buildPointLabelItems(scale, labelSizes, padding) {
const items = [];
const valueCount = scale._pointLabels.length;
const opts = scale.options;
const {centerPointLabels, display} = opts.pointLabels;
const itemOpts = {
extra: getTickBackdropHeight(opts) / 2,
additionalAngle: centerPointLabels ? PI / valueCount : 0
};
let area;
 
for (let i = 0; i < valueCount; i++) {
itemOpts.padding = padding[i];
itemOpts.size = labelSizes[i];
 
const item = createPointLabelItem(scale, i, itemOpts);
items.push(item);
if (display === 'auto') {
item.visible = isNotOverlapped(item, area);
if (item.visible) {
area = item;
}
}
}
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 drawPointLabelBox(ctx, opts, item) {
const {left, top, right, bottom} = item;
const {backdropColor} = opts;
 
if (!isNullOrUndef(backdropColor)) {
const borderRadius = toTRBLCorners(opts.borderRadius);
const padding = toPadding(opts.backdropPadding);
ctx.fillStyle = backdropColor;
 
const backdropLeft = left - padding.left;
const backdropTop = top - padding.top;
const backdropWidth = right - left + padding.width;
const backdropHeight = bottom - top + padding.height;
 
if (Object.values(borderRadius).some(v => v !== 0)) {
ctx.beginPath();
addRoundedRectPath(ctx, {
x: backdropLeft,
y: backdropTop,
w: backdropWidth,
h: backdropHeight,
radius: borderRadius,
});
ctx.fill();
} else {
ctx.fillRect(backdropLeft, backdropTop, backdropWidth, backdropHeight);
}
}
}
 
function drawPointLabels(scale, labelCount) {
const {ctx, options: {pointLabels}} = scale;
 
for (let i = labelCount - 1; i >= 0; i--) {
const item = scale._pointLabelItems[i];
if (!item.visible) {
// overlapping
continue;
}
const optsAtIndex = pointLabels.setContext(scale.getPointLabelContext(i));
drawPointLabelBox(ctx, optsAtIndex, item);
const plFont = toFont(optsAtIndex.font);
const {x, y, textAlign} = item;
 
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, borderOpts) {
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(borderOpts.dash || []);
ctx.lineDashOffset = borderOpts.dashOffset;
 
ctx.beginPath();
pathRadiusLine(scale, radius, circular, labelCount);
ctx.closePath();
ctx.stroke();
ctx.restore();
}
 
function createPointLabelContext(parent, index, label) {
return createContext(parent, {
label,
index,
type: 'pointLabel'
});
}
 
export default class RadialLinearScale extends LinearScaleBase {
 
static id = 'radialLinear';
 
/**
* @type {any}
*/
static 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,
 
// Boolean - if true, center point labels to slices in polar chart
centerPointLabels: false
}
};
 
static defaultRoutes = {
'angleLines.color': 'borderColor',
'pointLabels.color': 'color',
'ticks.color': 'color'
};
 
static descriptors = {
angleLines: {
_fallback: 'grid'
}
};
 
constructor(cfg) {
super(cfg);
 
/** @type {number} */
this.xCenter = undefined;
/** @type {number} */
this.yCenter = undefined;
/** @type {number} */
this.drawingArea = undefined;
/** @type {string[]} */
this._pointLabels = [];
this._pointLabelItems = [];
}
 
setDimensions() {
// Set the unconstrained dimension before label rotation
const padding = this._padding = toPadding(getTickBackdropHeight(this.options) / 2);
const w = this.width = this.maxWidth - padding.width;
const h = this.height = this.maxHeight - padding.height;
this.xCenter = Math.floor(this.left + w / 2 + padding.left);
this.yCenter = Math.floor(this.top + h / 2 + padding.top);
this.drawingArea = Math.floor(Math.min(w, h) / 2);
}
 
determineDataLimits() {
const {min, max} = this.getMinMax(false);
 
this.min = isFinite(min) && !isNaN(min) ? min : 0;
this.max = isFinite(max) && !isNaN(max) ? max : 0;
 
// Common base implementation to handle min, max, beginAtZero
this.handleTickRangeOptions();
}
 
/**
* Returns the maximum number of ticks based on the scale dimension
* @protected
*/
computeTickLimit() {
return Math.ceil(this.drawingArea / getTickBackdropHeight(this.options));
}
 
generateTickLabels(ticks) {
LinearScaleBase.prototype.generateTickLabels.call(this, ticks);
 
// Point labels
this._pointLabels = this.getLabels()
.map((value, index) => {
const label = callCallback(this.options.pointLabels.callback, [value, index], this);
return label || label === 0 ? label : '';
})
.filter((v, i) => this.chart.getDataVisibility(i));
}
 
fit() {
const opts = this.options;
 
if (opts.display && opts.pointLabels.display) {
fitWithPointLabels(this);
} else {
this.setCenterPoint(0, 0, 0, 0);
}
}
 
setCenterPoint(leftMovement, rightMovement, topMovement, bottomMovement) {
this.xCenter += Math.floor((leftMovement - rightMovement) / 2);
this.yCenter += Math.floor((topMovement - bottomMovement) / 2);
this.drawingArea -= Math.min(this.drawingArea / 2, Math.max(leftMovement, rightMovement, topMovement, bottomMovement));
}
 
getIndexAngle(index) {
const angleMultiplier = TAU / (this._pointLabels.length || 1);
const startAngle = this.options.startAngle || 0;
 
return _normalizeAngle(index * angleMultiplier + toRadians(startAngle));
}
 
getDistanceFromCenterForValue(value) {
if (isNullOrUndef(value)) {
return NaN;
}
 
// Take into account half font size + the yPadding of the top value
const scalingFactor = this.drawingArea / (this.max - this.min);
if (this.options.reverse) {
return (this.max - value) * scalingFactor;
}
return (value - this.min) * scalingFactor;
}
 
getValueForDistanceFromCenter(distance) {
if (isNullOrUndef(distance)) {
return NaN;
}
 
const scaledDistance = distance / (this.drawingArea / (this.max - this.min));
return this.options.reverse ? this.max - scaledDistance : this.min + scaledDistance;
}
 
getPointLabelContext(index) {
const pointLabels = this._pointLabels || [];
 
if (index >= 0 && index < pointLabels.length) {
const pointLabel = pointLabels[index];
return createPointLabelContext(this.getContext(), index, pointLabel);
}
}
 
getPointPosition(index, distanceFromCenter, additionalAngle = 0) {
const angle = this.getIndexAngle(index) - HALF_PI + additionalAngle;
return {
x: Math.cos(angle) * distanceFromCenter + this.xCenter,
y: Math.sin(angle) * distanceFromCenter + this.yCenter,
angle
};
}
 
getPointPositionForValue(index, value) {
return this.getPointPosition(index, this.getDistanceFromCenterForValue(value));
}
 
getBasePosition(index) {
return this.getPointPositionForValue(index || 0, this.getBaseValue());
}
 
getPointLabelPosition(index) {
const {left, top, right, bottom} = this._pointLabelItems[index];
return {
left,
top,
right,
bottom,
};
}
 
/**
* @protected
*/
drawBackground() {
const {backgroundColor, grid: {circular}} = this.options;
if (backgroundColor) {
const ctx = this.ctx;
ctx.save();
ctx.beginPath();
pathRadiusLine(this, this.getDistanceFromCenterForValue(this._endValue), circular, this._pointLabels.length);
ctx.closePath();
ctx.fillStyle = backgroundColor;
ctx.fill();
ctx.restore();
}
}
 
/**
* @protected
*/
Function `drawGrid` has 40 lines of code (exceeds 25 allowed). Consider refactoring.
Function `drawGrid` has a Cognitive Complexity of 11 (exceeds 7 allowed). Consider refactoring.
drawGrid() {
const ctx = this.ctx;
const opts = this.options;
const {angleLines, grid, border} = opts;
const labelCount = this._pointLabels.length;
 
let i, offset, position;
 
if (opts.pointLabels.display) {
drawPointLabels(this, labelCount);
}
 
if (grid.display) {
this.ticks.forEach((tick, index) => {
if (index !== 0 || (index === 0 && this.min < 0)) {
offset = this.getDistanceFromCenterForValue(tick.value);
const context = this.getContext(index);
const optsAtIndex = grid.setContext(context);
const optsAtIndexBorder = border.setContext(context);
 
drawRadiusLine(this, optsAtIndex, offset, labelCount, optsAtIndexBorder);
}
});
}
 
if (angleLines.display) {
ctx.save();
 
for (i = labelCount - 1; i >= 0; i--) {
const optsAtIndex = angleLines.setContext(this.getPointLabelContext(i));
const {color, lineWidth} = optsAtIndex;
 
if (!lineWidth || !color) {
continue;
}
 
ctx.lineWidth = lineWidth;
ctx.strokeStyle = color;
 
ctx.setLineDash(optsAtIndex.borderDash);
ctx.lineDashOffset = optsAtIndex.borderDashOffset;
 
offset = this.getDistanceFromCenterForValue(opts.reverse ? this.min : this.max);
position = this.getPointPosition(i, offset);
ctx.beginPath();
ctx.moveTo(this.xCenter, this.yCenter);
ctx.lineTo(position.x, position.y);
ctx.stroke();
}
 
ctx.restore();
}
}
 
/**
* @protected
*/
drawBorder() {}
 
/**
* @protected
*/
Function `drawLabels` has 39 lines of code (exceeds 25 allowed). Consider refactoring.
drawLabels() {
const ctx = this.ctx;
const opts = this.options;
const tickOpts = opts.ticks;
 
if (!tickOpts.display) {
return;
}
 
const startAngle = this.getIndexAngle(0);
let offset, width;
 
ctx.save();
ctx.translate(this.xCenter, this.yCenter);
ctx.rotate(startAngle);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
 
this.ticks.forEach((tick, index) => {
if ((index === 0 && this.min >= 0) && !opts.reverse) {
return;
}
 
const optsAtIndex = tickOpts.setContext(this.getContext(index));
const tickFont = toFont(optsAtIndex.font);
offset = this.getDistanceFromCenterForValue(this.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,
strokeColor: optsAtIndex.textStrokeColor,
strokeWidth: optsAtIndex.textStrokeWidth,
});
});
 
ctx.restore();
}
 
/**
* @protected
*/
drawTitle() {}
}