"use strict"; module.exports = function(Chart) { var helpers = Chart.helpers; Chart.defaults.scale = { display: true, // grid line settings gridLines: { display: true, color: "rgba(0, 0, 0, 0.1)", lineWidth: 1, drawOnChartArea: true, drawTicks: true, zeroLineWidth: 1, zeroLineColor: "rgba(0,0,0,0.25)", offsetGridLines: false }, // scale label scaleLabel: { fontColor: Chart.defaults.global.defaultFontColor, fontFamily: Chart.defaults.global.defaultFontFamily, fontSize: Chart.defaults.global.defaultFontSize, fontStyle: Chart.defaults.global.defaultFontStyle, // actual label labelString: '', // display property display: false }, // label settings ticks: { beginAtZero: false, fontSize: Chart.defaults.global.defaultFontSize, fontStyle: Chart.defaults.global.defaultFontStyle, fontColor: Chart.defaults.global.defaultFontColor, fontFamily: Chart.defaults.global.defaultFontFamily, maxRotation: 90, mirror: false, padding: 10, reverse: false, display: true, autoSkip: true, autoSkipPadding: 20, callback: function(value) { return '' + value; } } }; Chart.Scale = Chart.Element.extend({ // These methods are ordered by lifecyle. Utilities then follow. // Any function defined here is inherited by all scale types. // Any function can be extended by the scale type beforeUpdate: function() { helpers.callCallback(this.options.beforeUpdate, [this]); }, update: function(maxWidth, maxHeight, margins) { // Update Lifecycle - Probably don't want to ever extend or overwrite this function ;) this.beforeUpdate(); // Absorb the master measurements this.maxWidth = maxWidth; this.maxHeight = maxHeight; this.margins = helpers.extend({ left: 0, right: 0, top: 0, bottom: 0 }, margins); // Dimensions this.beforeSetDimensions(); this.setDimensions(); this.afterSetDimensions(); // Data min/max this.beforeDataLimits(); this.determineDataLimits(); this.afterDataLimits(); // Ticks this.beforeBuildTicks(); this.buildTicks(); this.afterBuildTicks(); this.beforeTickToLabelConversion(); this.convertTicksToLabels(); this.afterTickToLabelConversion(); // Tick Rotation this.beforeCalculateTickRotation(); this.calculateTickRotation(); this.afterCalculateTickRotation(); // Fit this.beforeFit(); this.fit(); this.afterFit(); // this.afterUpdate(); return this.minSize; }, afterUpdate: function() { helpers.callCallback(this.options.afterUpdate, [this]); }, // beforeSetDimensions: function() { helpers.callCallback(this.options.beforeSetDimensions, [this]); }, setDimensions: function() { // Set the unconstrained dimension before label rotation if (this.isHorizontal()) { // Reset position before calculating rotation this.width = this.maxWidth; this.left = 0; this.right = this.width; } else { this.height = this.maxHeight; // Reset position before calculating rotation this.top = 0; this.bottom = this.height; } // Reset padding this.paddingLeft = 0; this.paddingTop = 0; this.paddingRight = 0; this.paddingBottom = 0; }, afterSetDimensions: function() { helpers.callCallback(this.options.afterSetDimensions, [this]); }, // Data limits beforeDataLimits: function() { helpers.callCallback(this.options.beforeDataLimits, [this]); }, determineDataLimits: helpers.noop, afterDataLimits: function() { helpers.callCallback(this.options.afterDataLimits, [this]); }, // beforeBuildTicks: function() { helpers.callCallback(this.options.beforeBuildTicks, [this]); }, buildTicks: helpers.noop, afterBuildTicks: function() { helpers.callCallback(this.options.afterBuildTicks, [this]); }, beforeTickToLabelConversion: function() { helpers.callCallback(this.options.beforeTickToLabelConversion, [this]); }, convertTicksToLabels: function() { // Convert ticks to strings this.ticks = this.ticks.map(function(numericalTick, index, ticks) { if (this.options.ticks.userCallback) { return this.options.ticks.userCallback(numericalTick, index, ticks); } return this.options.ticks.callback(numericalTick, index, ticks); }, this); }, afterTickToLabelConversion: function() { helpers.callCallback(this.options.afterTickToLabelConversion, [this]); }, // beforeCalculateTickRotation: function() { helpers.callCallback(this.options.beforeCalculateTickRotation, [this]); }, calculateTickRotation: function() { //Get the width of each grid by calculating the difference //between x offsets between 0 and 1. var labelFont = helpers.fontString(this.options.ticks.fontSize, this.options.ticks.fontStyle, this.options.ticks.fontFamily); this.ctx.font = labelFont; var firstWidth = this.ctx.measureText(this.ticks[0]).width; var lastWidth = this.ctx.measureText(this.ticks[this.ticks.length - 1]).width; var firstRotated; this.labelRotation = 0; this.paddingRight = 0; this.paddingLeft = 0; if (this.options.display) { if (this.isHorizontal()) { this.paddingRight = lastWidth / 2 + 3; this.paddingLeft = firstWidth / 2 + 3; if (!this.longestTextCache) { this.longestTextCache = {}; } var originalLabelWidth = helpers.longestText(this.ctx, labelFont, this.ticks, this.longestTextCache); var labelWidth = originalLabelWidth; var cosRotation; var sinRotation; // Allow 3 pixels x2 padding either side for label readability // only the index matters for a dataset scale, but we want a consistent interface between scales var tickWidth = this.getPixelForTick(1) - this.getPixelForTick(0) - 6; //Max label rotation can be set or default to 90 - also act as a loop counter while (labelWidth > tickWidth && this.labelRotation < this.options.ticks.maxRotation) { cosRotation = Math.cos(helpers.toRadians(this.labelRotation)); sinRotation = Math.sin(helpers.toRadians(this.labelRotation)); firstRotated = cosRotation * firstWidth; // We're right aligning the text now. if (firstRotated + this.options.ticks.fontSize / 2 > this.yLabelWidth) { this.paddingLeft = firstRotated + this.options.ticks.fontSize / 2; } this.paddingRight = this.options.ticks.fontSize / 2; if (sinRotation * originalLabelWidth > this.maxHeight) { // go back one step this.labelRotation--; break; } this.labelRotation++; labelWidth = cosRotation * originalLabelWidth; } } } if (this.margins) { this.paddingLeft = Math.max(this.paddingLeft - this.margins.left, 0); this.paddingRight = Math.max(this.paddingRight - this.margins.right, 0); } }, afterCalculateTickRotation: function() { helpers.callCallback(this.options.afterCalculateTickRotation, [this]); }, // beforeFit: function() { helpers.callCallback(this.options.beforeFit, [this]); }, fit: function() { this.minSize = { width: 0, height: 0 }; // Width if (this.isHorizontal()) { // subtract the margins to line up with the chartArea if we are a full width scale this.minSize.width = this.isFullWidth() ? this.maxWidth - this.margins.left - this.margins.right : this.maxWidth; } else { this.minSize.width = this.options.gridLines.display && this.options.display ? 10 : 0; } // height if (this.isHorizontal()) { this.minSize.height = this.options.gridLines.display && this.options.display ? 10 : 0; } else { this.minSize.height = this.maxHeight; // fill all the height } // Are we showing a title for the scale? if (this.options.scaleLabel.display) { if (this.isHorizontal()) { this.minSize.height += (this.options.scaleLabel.fontSize * 1.5); } else { this.minSize.width += (this.options.scaleLabel.fontSize * 1.5); } } if (this.options.ticks.display && this.options.display) { // Don't bother fitting the ticks if we are not showing them var labelFont = helpers.fontString(this.options.ticks.fontSize, this.options.ticks.fontStyle, this.options.ticks.fontFamily); if (!this.longestTextCache) { this.longestTextCache = {}; } var largestTextWidth = helpers.longestText(this.ctx, labelFont, this.ticks, this.longestTextCache); if (this.isHorizontal()) { // A horizontal axis is more constrained by the height. this.longestLabelWidth = largestTextWidth; // TODO - improve this calculation var labelHeight = (Math.sin(helpers.toRadians(this.labelRotation)) * this.longestLabelWidth) + 1.5 * this.options.ticks.fontSize; this.minSize.height = Math.min(this.maxHeight, this.minSize.height + labelHeight); labelFont = helpers.fontString(this.options.ticks.fontSize, this.options.ticks.fontStyle, this.options.ticks.fontFamily); this.ctx.font = labelFont; var firstLabelWidth = this.ctx.measureText(this.ticks[0]).width; var lastLabelWidth = this.ctx.measureText(this.ticks[this.ticks.length - 1]).width; // Ensure that our ticks are always inside the canvas. When rotated, ticks are right aligned which means that the right padding is dominated // by the font height var cosRotation = Math.cos(helpers.toRadians(this.labelRotation)); var sinRotation = Math.sin(helpers.toRadians(this.labelRotation)); this.paddingLeft = this.labelRotation !== 0 ? (cosRotation * firstLabelWidth) + 3 : firstLabelWidth / 2 + 3; // add 3 px to move away from canvas edges this.paddingRight = this.labelRotation !== 0 ? (sinRotation * (this.options.ticks.fontSize / 2)) + 3 : lastLabelWidth / 2 + 3; // when rotated } else { // A vertical axis is more constrained by the width. Labels are the dominant factor here, so get that length first var maxLabelWidth = this.maxWidth - this.minSize.width; // Account for padding if (!this.options.ticks.mirror) { largestTextWidth += this.options.ticks.padding; } if (largestTextWidth < maxLabelWidth) { // We don't need all the room this.minSize.width += largestTextWidth; } else { // Expand to max size this.minSize.width = this.maxWidth; } this.paddingTop = this.options.ticks.fontSize / 2; this.paddingBottom = this.options.ticks.fontSize / 2; } } if (this.margins) { this.paddingLeft = Math.max(this.paddingLeft - this.margins.left, 0); this.paddingTop = Math.max(this.paddingTop - this.margins.top, 0); this.paddingRight = Math.max(this.paddingRight - this.margins.right, 0); this.paddingBottom = Math.max(this.paddingBottom - this.margins.bottom, 0); } this.width = this.minSize.width; this.height = this.minSize.height; }, afterFit: function() { helpers.callCallback(this.options.afterFit, [this]); }, // Shared Methods isHorizontal: function() { return this.options.position === "top" || this.options.position === "bottom"; }, isFullWidth: function() { return (this.options.fullWidth); }, // Get the correct value. NaN bad inputs, If the value type is object get the x or y based on whether we are horizontal or not getRightValue: function getRightValue(rawValue) { // Null and undefined values first if (rawValue === null || typeof(rawValue) === 'undefined') { return NaN; } // isNaN(object) returns true, so make sure NaN is checking for a number if (typeof(rawValue) === 'number' && isNaN(rawValue)) { return NaN; } // If it is in fact an object, dive in one more level if (typeof(rawValue) === "object") { if (rawValue instanceof Date) { return rawValue; } else { return getRightValue(this.isHorizontal() ? rawValue.x : rawValue.y); } } // Value is good, return it return rawValue; }, // Used to get the value to display in the tooltip for the data at the given index // function getLabelForIndex(index, datasetIndex) getLabelForIndex: helpers.noop, // Used to get data value locations. Value can either be an index or a numerical value getPixelForValue: helpers.noop, // Used for tick location, should getPixelForTick: function(index, includeOffset) { if (this.isHorizontal()) { var innerWidth = this.width - (this.paddingLeft + this.paddingRight); var tickWidth = innerWidth / Math.max((this.ticks.length - ((this.options.gridLines.offsetGridLines) ? 0 : 1)), 1); var pixel = (tickWidth * index) + this.paddingLeft; if (includeOffset) { pixel += tickWidth / 2; } var finalVal = this.left + Math.round(pixel); finalVal += this.isFullWidth() ? this.margins.left : 0; return finalVal; } else { var innerHeight = this.height - (this.paddingTop + this.paddingBottom); return this.top + (index * (innerHeight / (this.ticks.length - 1))); } }, // Utility for getting the pixel location of a percentage of scale getPixelForDecimal: function(decimal /*, includeOffset*/ ) { if (this.isHorizontal()) { var innerWidth = this.width - (this.paddingLeft + this.paddingRight); var valueOffset = (innerWidth * decimal) + this.paddingLeft; var finalVal = this.left + Math.round(valueOffset); finalVal += this.isFullWidth() ? this.margins.left : 0; return finalVal; } else { return this.top + (decimal * this.height); } }, // Actualy draw the scale on the canvas // @param {rectangle} chartArea : the area of the chart to draw full grid lines on draw: function(chartArea) { if (this.options.display) { var setContextLineSettings; var isRotated = this.labelRotation !== 0; var skipRatio; var scaleLabelX; var scaleLabelY; var useAutoskipper = this.options.ticks.autoSkip; // figure out the maximum number of gridlines to show var maxTicks; if (this.options.ticks.maxTicksLimit) { maxTicks = this.options.ticks.maxTicksLimit; } // Make sure we draw text in the correct color and font this.ctx.fillStyle = this.options.ticks.fontColor; var labelFont = helpers.fontString(this.options.ticks.fontSize, this.options.ticks.fontStyle, this.options.ticks.fontFamily); var cosRotation = Math.cos(helpers.toRadians(this.labelRotation)); var sinRotation = Math.sin(helpers.toRadians(this.labelRotation)); var longestRotatedLabel = this.longestLabelWidth * cosRotation; var rotatedLabelHeight = this.options.ticks.fontSize * sinRotation; if (this.isHorizontal()) { setContextLineSettings = true; var yTickStart = this.options.position === "bottom" ? this.top : this.bottom - 10; var yTickEnd = this.options.position === "bottom" ? this.top + 10 : this.bottom; skipRatio = false; if (((longestRotatedLabel / 2) + this.options.ticks.autoSkipPadding) * this.ticks.length > (this.width - (this.paddingLeft + this.paddingRight))) { skipRatio = 1 + Math.floor((((longestRotatedLabel / 2) + this.options.ticks.autoSkipPadding) * this.ticks.length) / (this.width - (this.paddingLeft + this.paddingRight))); } if (!useAutoskipper) { skipRatio = false; } // if they defined a max number of ticks, // increase skipRatio until that number is met if (maxTicks && this.ticks.length > maxTicks) { while (!skipRatio || this.ticks.length / (skipRatio || 1) > maxTicks) { if (!skipRatio) { skipRatio = 1; } skipRatio += 1; } } helpers.each(this.ticks, function(label, index) { // Blank ticks var isLastTick = this.ticks.length === index + 1; // Since we always show the last tick,we need may need to hide the last shown one before var shouldSkip = (skipRatio > 1 && index % skipRatio > 0) || (index % skipRatio === 0 && index + skipRatio > this.ticks.length); if (shouldSkip && !isLastTick || (label === undefined || label === null)) { return; } var xLineValue = this.getPixelForTick(index); // xvalues for grid lines var xLabelValue = this.getPixelForTick(index, this.options.gridLines.offsetGridLines); // x values for ticks (need to consider offsetLabel option) if (this.options.gridLines.display) { if (index === (typeof this.zeroLineIndex !== 'undefined' ? this.zeroLineIndex : 0)) { // Draw the first index specially this.ctx.lineWidth = this.options.gridLines.zeroLineWidth; this.ctx.strokeStyle = this.options.gridLines.zeroLineColor; setContextLineSettings = true; // reset next time } else if (setContextLineSettings) { this.ctx.lineWidth = this.options.gridLines.lineWidth; this.ctx.strokeStyle = this.options.gridLines.color; setContextLineSettings = false; } xLineValue += helpers.aliasPixel(this.ctx.lineWidth); // Draw the label area this.ctx.beginPath(); if (this.options.gridLines.drawTicks) { this.ctx.moveTo(xLineValue, yTickStart); this.ctx.lineTo(xLineValue, yTickEnd); } // Draw the chart area if (this.options.gridLines.drawOnChartArea) { this.ctx.moveTo(xLineValue, chartArea.top); this.ctx.lineTo(xLineValue, chartArea.bottom); } // Need to stroke in the loop because we are potentially changing line widths & colours this.ctx.stroke(); } if (this.options.ticks.display) { this.ctx.save(); this.ctx.translate(xLabelValue, (isRotated) ? this.top + 12 : this.options.position === "top" ? this.bottom - 10 : this.top + 10); this.ctx.rotate(helpers.toRadians(this.labelRotation) * -1); this.ctx.font = labelFont; this.ctx.textAlign = (isRotated) ? "right" : "center"; this.ctx.textBaseline = (isRotated) ? "middle" : this.options.position === "top" ? "bottom" : "top"; this.ctx.fillText(label, 0, 0); this.ctx.restore(); } }, this); if (this.options.scaleLabel.display) { // Draw the scale label this.ctx.textAlign = "center"; this.ctx.textBaseline = 'middle'; this.ctx.fillStyle = this.options.scaleLabel.fontColor; // render in correct colour this.ctx.font = helpers.fontString(this.options.scaleLabel.fontSize, this.options.scaleLabel.fontStyle, this.options.scaleLabel.fontFamily); scaleLabelX = this.left + ((this.right - this.left) / 2); // midpoint of the width scaleLabelY = this.options.position === 'bottom' ? this.bottom - (this.options.scaleLabel.fontSize / 2) : this.top + (this.options.scaleLabel.fontSize / 2); this.ctx.fillText(this.options.scaleLabel.labelString, scaleLabelX, scaleLabelY); } } else { setContextLineSettings = true; var xTickStart = this.options.position === "right" ? this.left : this.right - 5; var xTickEnd = this.options.position === "right" ? this.left + 5 : this.right; helpers.each(this.ticks, function(label, index) { // If the callback returned a null or undefined value, do not draw this line if (label === undefined || label === null) { return; } var yLineValue = this.getPixelForTick(index); // xvalues for grid lines if (this.options.gridLines.display) { if (index === (typeof this.zeroLineIndex !== 'undefined' ? this.zeroLineIndex : 0)) { // Draw the first index specially this.ctx.lineWidth = this.options.gridLines.zeroLineWidth; this.ctx.strokeStyle = this.options.gridLines.zeroLineColor; setContextLineSettings = true; // reset next time } else if (setContextLineSettings) { this.ctx.lineWidth = this.options.gridLines.lineWidth; this.ctx.strokeStyle = this.options.gridLines.color; setContextLineSettings = false; } yLineValue += helpers.aliasPixel(this.ctx.lineWidth); // Draw the label area this.ctx.beginPath(); if (this.options.gridLines.drawTicks) { this.ctx.moveTo(xTickStart, yLineValue); this.ctx.lineTo(xTickEnd, yLineValue); } // Draw the chart area if (this.options.gridLines.drawOnChartArea) { this.ctx.moveTo(chartArea.left, yLineValue); this.ctx.lineTo(chartArea.right, yLineValue); } // Need to stroke in the loop because we are potentially changing line widths & colours this.ctx.stroke(); } if (this.options.ticks.display) { var xLabelValue; var yLabelValue = this.getPixelForTick(index, this.options.gridLines.offsetGridLines); // x values for ticks (need to consider offsetLabel option) this.ctx.save(); if (this.options.position === "left") { if (this.options.ticks.mirror) { xLabelValue = this.right + this.options.ticks.padding; this.ctx.textAlign = "left"; } else { xLabelValue = this.right - this.options.ticks.padding; this.ctx.textAlign = "right"; } } else { // right side if (this.options.ticks.mirror) { xLabelValue = this.left - this.options.ticks.padding; this.ctx.textAlign = "right"; } else { xLabelValue = this.left + this.options.ticks.padding; this.ctx.textAlign = "left"; } } this.ctx.translate(xLabelValue, yLabelValue); this.ctx.rotate(helpers.toRadians(this.labelRotation) * -1); this.ctx.font = labelFont; this.ctx.textBaseline = "middle"; this.ctx.fillText(label, 0, 0); this.ctx.restore(); } }, this); if (this.options.scaleLabel.display) { // Draw the scale label scaleLabelX = this.options.position === 'left' ? this.left + (this.options.scaleLabel.fontSize / 2) : this.right - (this.options.scaleLabel.fontSize / 2); scaleLabelY = this.top + ((this.bottom - this.top) / 2); var rotation = this.options.position === 'left' ? -0.5 * Math.PI : 0.5 * Math.PI; this.ctx.save(); this.ctx.translate(scaleLabelX, scaleLabelY); this.ctx.rotate(rotation); this.ctx.textAlign = "center"; this.ctx.fillStyle = this.options.scaleLabel.fontColor; // render in correct colour this.ctx.font = helpers.fontString(this.options.scaleLabel.fontSize, this.options.scaleLabel.fontStyle, this.options.scaleLabel.fontFamily); this.ctx.textBaseline = 'middle'; this.ctx.fillText(this.options.scaleLabel.labelString, 0, 0); this.ctx.restore(); } } // Draw the line at the edge of the axis this.ctx.lineWidth = this.options.gridLines.lineWidth; this.ctx.strokeStyle = this.options.gridLines.color; var x1 = this.left, x2 = this.right, y1 = this.top, y2 = this.bottom; if (this.isHorizontal()) { y1 = y2 = this.options.position === 'top' ? this.bottom : this.top; y1 += helpers.aliasPixel(this.ctx.lineWidth); y2 += helpers.aliasPixel(this.ctx.lineWidth); } else { x1 = x2 = this.options.position === 'left' ? this.right : this.left; x1 += helpers.aliasPixel(this.ctx.lineWidth); x2 += helpers.aliasPixel(this.ctx.lineWidth); } this.ctx.beginPath(); this.ctx.moveTo(x1, y1); this.ctx.lineTo(x2, y2); this.ctx.stroke(); } } }); };