559 lines
16 KiB
JavaScript
559 lines
16 KiB
JavaScript
'use strict';
|
|
|
|
var defaults = require('../core/core.defaults');
|
|
var helpers = require('../helpers/index');
|
|
var LinearScaleBase = require('./scale.linearbase');
|
|
var Ticks = require('../core/core.ticks');
|
|
|
|
var valueOrDefault = helpers.valueOrDefault;
|
|
var valueAtIndexOrDefault = helpers.valueAtIndexOrDefault;
|
|
var resolve = helpers.options.resolve;
|
|
|
|
var defaultConfig = {
|
|
display: true,
|
|
|
|
// Boolean - Whether to animate scaling the chart from the centre
|
|
animate: true,
|
|
position: 'chartArea',
|
|
|
|
angleLines: {
|
|
display: true,
|
|
color: 'rgba(0,0,0,0.1)',
|
|
lineWidth: 1,
|
|
borderDash: [],
|
|
borderDashOffset: 0.0
|
|
},
|
|
|
|
gridLines: {
|
|
circular: false
|
|
},
|
|
|
|
// label settings
|
|
ticks: {
|
|
// Boolean - Show a backdrop to the scale label
|
|
showLabelBackdrop: true,
|
|
|
|
// String - The colour of the label backdrop
|
|
backdropColor: 'rgba(255,255,255,0.75)',
|
|
|
|
// Number - The backdrop padding above & below the label in pixels
|
|
backdropPaddingY: 2,
|
|
|
|
// Number - The backdrop padding to the side of the label in pixels
|
|
backdropPaddingX: 2,
|
|
|
|
callback: Ticks.formatters.linear
|
|
},
|
|
|
|
pointLabels: {
|
|
// Boolean - if true, show point labels
|
|
display: true,
|
|
|
|
// Number - Point label font size in pixels
|
|
fontSize: 10,
|
|
|
|
// Function - Used to convert point labels
|
|
callback: function(label) {
|
|
return label;
|
|
}
|
|
}
|
|
};
|
|
|
|
function getTickBackdropHeight(opts) {
|
|
var tickOpts = opts.ticks;
|
|
|
|
if (tickOpts.display && opts.display) {
|
|
return valueOrDefault(tickOpts.fontSize, defaults.global.defaultFontSize) + tickOpts.backdropPaddingY * 2;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function measureLabelSize(ctx, lineHeight, label) {
|
|
if (helpers.isArray(label)) {
|
|
return {
|
|
w: helpers.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
|
|
|
|
var plFont = helpers.options._parseFont(scale.options.pointLabels);
|
|
|
|
// 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 furthestLimits = {
|
|
l: 0,
|
|
r: scale.width,
|
|
t: 0,
|
|
b: scale.height - scale.paddingTop
|
|
};
|
|
var furthestAngles = {};
|
|
var i, textSize, pointPosition;
|
|
|
|
scale.ctx.font = plFont.string;
|
|
scale._pointLabelSizes = [];
|
|
|
|
var valueCount = scale.chart.data.labels.length;
|
|
for (i = 0; i < valueCount; i++) {
|
|
pointPosition = scale.getPointPosition(i, scale.drawingArea + 5);
|
|
textSize = measureLabelSize(scale.ctx, plFont.lineHeight, scale.pointLabels[i]);
|
|
scale._pointLabelSizes[i] = textSize;
|
|
|
|
// Add quarter circle to make degree 0 mean top of circle
|
|
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 (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);
|
|
}
|
|
|
|
function getTextAlignForAngle(angle) {
|
|
if (angle === 0 || angle === 180) {
|
|
return 'center';
|
|
} else if (angle < 180) {
|
|
return 'left';
|
|
}
|
|
|
|
return 'right';
|
|
}
|
|
|
|
function fillText(ctx, text, position, lineHeight) {
|
|
var y = position.y + lineHeight / 2;
|
|
var i, ilen;
|
|
|
|
if (helpers.isArray(text)) {
|
|
for (i = 0, ilen = text.length; i < ilen; ++i) {
|
|
ctx.fillText(text[i], position.x, y);
|
|
y += lineHeight;
|
|
}
|
|
} else {
|
|
ctx.fillText(text, position.x, y);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
var ctx = scale.ctx;
|
|
var opts = scale.options;
|
|
var pointLabelOpts = opts.pointLabels;
|
|
var tickBackdropHeight = getTickBackdropHeight(opts);
|
|
var outerDistance = scale.getDistanceFromCenterForValue(opts.ticks.reverse ? scale.min : scale.max);
|
|
var plFont = helpers.options._parseFont(pointLabelOpts);
|
|
|
|
ctx.save();
|
|
|
|
ctx.font = plFont.string;
|
|
ctx.textBaseline = 'middle';
|
|
|
|
for (var i = scale.chart.data.labels.length - 1; i >= 0; i--) {
|
|
// Extra pixels out for some label spacing
|
|
var extra = (i === 0 ? tickBackdropHeight / 2 : 0);
|
|
var pointLabelPosition = scale.getPointPosition(i, outerDistance + extra + 5);
|
|
|
|
// Keep this in loop since we may support array properties here
|
|
var pointLabelFontColor = valueAtIndexOrDefault(pointLabelOpts.fontColor, i, defaults.global.defaultFontColor);
|
|
ctx.fillStyle = pointLabelFontColor;
|
|
|
|
var angleRadians = scale.getIndexAngle(i);
|
|
var angle = helpers.toDegrees(angleRadians);
|
|
ctx.textAlign = getTextAlignForAngle(angle);
|
|
adjustPointPositionForLabelHeight(angle, scale._pointLabelSizes[i], pointLabelPosition);
|
|
fillText(ctx, scale.pointLabels[i], pointLabelPosition, plFont.lineHeight);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawRadiusLine(scale, gridLineOpts, radius, index) {
|
|
var ctx = scale.ctx;
|
|
var circular = gridLineOpts.circular;
|
|
var valueCount = scale.chart.data.labels.length;
|
|
var lineColor = valueAtIndexOrDefault(gridLineOpts.color, index - 1);
|
|
var lineWidth = valueAtIndexOrDefault(gridLineOpts.lineWidth, index - 1);
|
|
var pointPosition;
|
|
|
|
if ((!circular && !valueCount) || !lineColor || !lineWidth) {
|
|
return;
|
|
}
|
|
|
|
ctx.save();
|
|
ctx.strokeStyle = lineColor;
|
|
ctx.lineWidth = lineWidth;
|
|
if (ctx.setLineDash) {
|
|
ctx.setLineDash(gridLineOpts.borderDash || []);
|
|
ctx.lineDashOffset = gridLineOpts.borderDashOffset || 0.0;
|
|
}
|
|
|
|
ctx.beginPath();
|
|
if (circular) {
|
|
// Draw circular arcs between the points
|
|
ctx.arc(scale.xCenter, scale.yCenter, radius, 0, Math.PI * 2);
|
|
} else {
|
|
// Draw straight lines connecting each index
|
|
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();
|
|
ctx.restore();
|
|
}
|
|
|
|
function numberOrZero(param) {
|
|
return helpers.isNumber(param) ? param : 0;
|
|
}
|
|
|
|
module.exports = LinearScaleBase.extend({
|
|
setDimensions: function() {
|
|
var me = this;
|
|
|
|
// Set the unconstrained dimension before label rotation
|
|
me.width = me.maxWidth;
|
|
me.height = me.maxHeight;
|
|
me.paddingTop = getTickBackdropHeight(me.options) / 2;
|
|
me.xCenter = Math.floor(me.width / 2);
|
|
me.yCenter = Math.floor((me.height - me.paddingTop) / 2);
|
|
me.drawingArea = Math.min(me.height - me.paddingTop, me.width) / 2;
|
|
},
|
|
|
|
determineDataLimits: function() {
|
|
var me = this;
|
|
var chart = me.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);
|
|
|
|
helpers.each(dataset.data, function(rawValue, index) {
|
|
var value = +me.getRightValue(rawValue);
|
|
if (isNaN(value) || meta.data[index].hidden) {
|
|
return;
|
|
}
|
|
|
|
min = Math.min(value, min);
|
|
max = Math.max(value, 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();
|
|
},
|
|
|
|
// Returns the maximum number of ticks based on the scale dimension
|
|
_computeTickLimit: function() {
|
|
return Math.ceil(this.drawingArea / getTickBackdropHeight(this.options));
|
|
},
|
|
|
|
convertTicksToLabels: function() {
|
|
var me = this;
|
|
|
|
LinearScaleBase.prototype.convertTicksToLabels.call(me);
|
|
|
|
// Point labels
|
|
me.pointLabels = me.chart.data.labels.map(function() {
|
|
var label = helpers.callback(me.options.pointLabels.callback, arguments, me);
|
|
return label || label === 0 ? label : '';
|
|
});
|
|
},
|
|
|
|
getLabelForIndex: function(index, datasetIndex) {
|
|
return +this.getRightValue(this.chart.data.datasets[datasetIndex].data[index]);
|
|
},
|
|
|
|
fit: function() {
|
|
var me = this;
|
|
var opts = me.options;
|
|
|
|
if (opts.display && opts.pointLabels.display) {
|
|
fitWithPointLabels(me);
|
|
} else {
|
|
me.setCenterPoint(0, 0, 0, 0);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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 - me.paddingTop), 0) / Math.cos(furthestAngles.b);
|
|
|
|
radiusReductionLeft = numberOrZero(radiusReductionLeft);
|
|
radiusReductionRight = numberOrZero(radiusReductionRight);
|
|
radiusReductionTop = numberOrZero(radiusReductionTop);
|
|
radiusReductionBottom = numberOrZero(radiusReductionBottom);
|
|
|
|
me.drawingArea = Math.min(
|
|
Math.floor(largestPossibleRadius - (radiusReductionLeft + radiusReductionRight) / 2),
|
|
Math.floor(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;
|
|
var maxLeft = leftMovement + me.drawingArea;
|
|
var maxTop = topMovement + me.drawingArea;
|
|
var maxBottom = (me.height - me.paddingTop) - bottomMovement - me.drawingArea;
|
|
|
|
me.xCenter = Math.floor(((maxLeft + maxRight) / 2) + me.left);
|
|
me.yCenter = Math.floor(((maxTop + maxBottom) / 2) + me.top + me.paddingTop);
|
|
},
|
|
|
|
getIndexAngle: function(index) {
|
|
var chart = this.chart;
|
|
var angleMultiplier = 360 / chart.data.labels.length;
|
|
var options = chart.options || {};
|
|
var startAngle = options.startAngle || 0;
|
|
|
|
// Start from the top instead of right, so remove a quarter of the circle
|
|
var angle = (index * angleMultiplier + startAngle) % 360;
|
|
|
|
return (angle < 0 ? angle + 360 : angle) * Math.PI * 2 / 360;
|
|
},
|
|
|
|
getDistanceFromCenterForValue: function(value) {
|
|
var me = this;
|
|
|
|
if (helpers.isNullOrUndef(value)) {
|
|
return NaN;
|
|
}
|
|
|
|
// Take into account half font size + the yPadding of the top value
|
|
var scalingFactor = me.drawingArea / (me.max - me.min);
|
|
if (me.options.ticks.reverse) {
|
|
return (me.max - value) * scalingFactor;
|
|
}
|
|
return (value - me.min) * scalingFactor;
|
|
},
|
|
|
|
getPointPosition: function(index, distanceFromCenter) {
|
|
var me = this;
|
|
var thisAngle = me.getIndexAngle(index) - (Math.PI / 2);
|
|
return {
|
|
x: Math.cos(thisAngle) * distanceFromCenter + me.xCenter,
|
|
y: Math.sin(thisAngle) * distanceFromCenter + me.yCenter
|
|
};
|
|
},
|
|
|
|
getPointPositionForValue: function(index, value) {
|
|
return this.getPointPosition(index, this.getDistanceFromCenterForValue(value));
|
|
},
|
|
|
|
getBasePosition: function(index) {
|
|
var me = this;
|
|
var min = me.min;
|
|
var max = me.max;
|
|
|
|
return me.getPointPositionForValue(index || 0,
|
|
me.beginAtZero ? 0 :
|
|
min < 0 && max < 0 ? max :
|
|
min > 0 && max > 0 ? min :
|
|
0);
|
|
},
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_drawGrid: function() {
|
|
var me = this;
|
|
var ctx = me.ctx;
|
|
var opts = me.options;
|
|
var gridLineOpts = opts.gridLines;
|
|
var angleLineOpts = opts.angleLines;
|
|
var lineWidth = valueOrDefault(angleLineOpts.lineWidth, gridLineOpts.lineWidth);
|
|
var lineColor = valueOrDefault(angleLineOpts.color, gridLineOpts.color);
|
|
var i, offset, position;
|
|
|
|
if (opts.pointLabels.display) {
|
|
drawPointLabels(me);
|
|
}
|
|
|
|
if (gridLineOpts.display) {
|
|
helpers.each(me.ticks, function(label, index) {
|
|
if (index !== 0) {
|
|
offset = me.getDistanceFromCenterForValue(me.ticksAsNumbers[index]);
|
|
drawRadiusLine(me, gridLineOpts, offset, index);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (angleLineOpts.display && lineWidth && lineColor) {
|
|
ctx.save();
|
|
ctx.lineWidth = lineWidth;
|
|
ctx.strokeStyle = lineColor;
|
|
if (ctx.setLineDash) {
|
|
ctx.setLineDash(resolve([angleLineOpts.borderDash, gridLineOpts.borderDash, []]));
|
|
ctx.lineDashOffset = resolve([angleLineOpts.borderDashOffset, gridLineOpts.borderDashOffset, 0.0]);
|
|
}
|
|
|
|
for (i = me.chart.data.labels.length - 1; i >= 0; i--) {
|
|
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();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_drawLabels: function() {
|
|
var me = this;
|
|
var ctx = me.ctx;
|
|
var opts = me.options;
|
|
var tickOpts = opts.ticks;
|
|
|
|
if (!tickOpts.display) {
|
|
return;
|
|
}
|
|
|
|
var startAngle = me.getIndexAngle(0);
|
|
var tickFont = helpers.options._parseFont(tickOpts);
|
|
var tickFontColor = valueOrDefault(tickOpts.fontColor, defaults.global.defaultFontColor);
|
|
var offset, width;
|
|
|
|
ctx.save();
|
|
ctx.font = tickFont.string;
|
|
ctx.translate(me.xCenter, me.yCenter);
|
|
ctx.rotate(startAngle);
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
|
|
helpers.each(me.ticks, function(label, index) {
|
|
if (index === 0 && !tickOpts.reverse) {
|
|
return;
|
|
}
|
|
|
|
offset = me.getDistanceFromCenterForValue(me.ticksAsNumbers[index]);
|
|
|
|
if (tickOpts.showLabelBackdrop) {
|
|
width = ctx.measureText(label).width;
|
|
ctx.fillStyle = tickOpts.backdropColor;
|
|
|
|
ctx.fillRect(
|
|
-width / 2 - tickOpts.backdropPaddingX,
|
|
-offset - tickFont.size / 2 - tickOpts.backdropPaddingY,
|
|
width + tickOpts.backdropPaddingX * 2,
|
|
tickFont.size + tickOpts.backdropPaddingY * 2
|
|
);
|
|
}
|
|
|
|
ctx.fillStyle = tickFontColor;
|
|
ctx.fillText(label, 0, -offset);
|
|
});
|
|
|
|
ctx.restore();
|
|
},
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_drawTitle: helpers.noop
|
|
});
|
|
|
|
// INTERNAL: static default options, registered in src/index.js
|
|
module.exports._defaults = defaultConfig;
|