'use strict'; module.exports = function(Chart) { var helpers = Chart.helpers; Chart.defaults.scale = { display: true, position: 'left', // grid line settings gridLines: { display: true, color: 'rgba(0, 0, 0, 0.1)', lineWidth: 1, drawBorder: true, drawOnChartArea: true, drawTicks: true, tickMarkLength: 10, zeroLineWidth: 1, zeroLineColor: 'rgba(0,0,0,0.25)', offsetGridLines: false, borderDash: [], borderDashOffset: 0.0 }, // scale label scaleLabel: { // actual label labelString: '', // display property display: false }, // label settings ticks: { beginAtZero: false, minRotation: 0, maxRotation: 50, mirror: false, padding: 0, reverse: false, display: true, autoSkip: true, autoSkipPadding: 0, labelOffset: 0, // We pass through arrays to be rendered as multiline labels, we convert Others to strings here. callback: Chart.Ticks.formatters.values } }; function computeTextSize(context, tick, font) { return helpers.isArray(tick) ? helpers.longestText(context, font, tick) : context.measureText(tick).width; } function parseFontOptions(options) { var getValueOrDefault = helpers.getValueOrDefault; var globalDefaults = Chart.defaults.global; var size = getValueOrDefault(options.fontSize, globalDefaults.defaultFontSize); var style = getValueOrDefault(options.fontStyle, globalDefaults.defaultFontStyle); var family = getValueOrDefault(options.fontFamily, globalDefaults.defaultFontFamily); return { size: size, style: style, family: family, font: helpers.fontString(size, style, family) }; } Chart.Scale = Chart.Element.extend({ /** * Get the padding needed for the scale * @method getPadding * @private * @returns {Padding} the necessary padding */ getPadding: function() { var me = this; return { left: me.paddingLeft || 0, top: me.paddingTop || 0, right: me.paddingRight || 0, bottom: me.paddingBottom || 0 }; }, // 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) { var me = this; // Update Lifecycle - Probably don't want to ever extend or overwrite this function ;) me.beforeUpdate(); // Absorb the master measurements me.maxWidth = maxWidth; me.maxHeight = maxHeight; me.margins = helpers.extend({ left: 0, right: 0, top: 0, bottom: 0 }, margins); me.longestTextCache = me.longestTextCache || {}; // Dimensions me.beforeSetDimensions(); me.setDimensions(); me.afterSetDimensions(); // Data min/max me.beforeDataLimits(); me.determineDataLimits(); me.afterDataLimits(); // Ticks me.beforeBuildTicks(); me.buildTicks(); me.afterBuildTicks(); me.beforeTickToLabelConversion(); me.convertTicksToLabels(); me.afterTickToLabelConversion(); // Tick Rotation me.beforeCalculateTickRotation(); me.calculateTickRotation(); me.afterCalculateTickRotation(); // Fit me.beforeFit(); me.fit(); me.afterFit(); // me.afterUpdate(); return me.minSize; }, afterUpdate: function() { helpers.callCallback(this.options.afterUpdate, [this]); }, // beforeSetDimensions: function() { helpers.callCallback(this.options.beforeSetDimensions, [this]); }, setDimensions: function() { var me = this; // Set the unconstrained dimension before label rotation if (me.isHorizontal()) { // Reset position before calculating rotation me.width = me.maxWidth; me.left = 0; me.right = me.width; } else { me.height = me.maxHeight; // Reset position before calculating rotation me.top = 0; me.bottom = me.height; } // Reset padding me.paddingLeft = 0; me.paddingTop = 0; me.paddingRight = 0; me.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() { var me = this; // Convert ticks to strings var tickOpts = me.options.ticks; me.ticks = me.ticks.map(tickOpts.userCallback || tickOpts.callback); }, afterTickToLabelConversion: function() { helpers.callCallback(this.options.afterTickToLabelConversion, [this]); }, // beforeCalculateTickRotation: function() { helpers.callCallback(this.options.beforeCalculateTickRotation, [this]); }, calculateTickRotation: function() { var me = this; var context = me.ctx; var tickOpts = me.options.ticks; // Get the width of each grid by calculating the difference // between x offsets between 0 and 1. var tickFont = parseFontOptions(tickOpts); context.font = tickFont.font; var labelRotation = tickOpts.minRotation || 0; if (me.options.display && me.isHorizontal()) { var originalLabelWidth = helpers.longestText(context, tickFont.font, me.ticks, me.longestTextCache); var labelWidth = originalLabelWidth; var cosRotation; var sinRotation; // Allow 3 pixels x2 padding either side for label readability var tickWidth = me.getPixelForTick(1) - me.getPixelForTick(0) - 6; // Max label rotation can be set or default to 90 - also act as a loop counter while (labelWidth > tickWidth && labelRotation < tickOpts.maxRotation) { var angleRadians = helpers.toRadians(labelRotation); cosRotation = Math.cos(angleRadians); sinRotation = Math.sin(angleRadians); if (sinRotation * originalLabelWidth > me.maxHeight) { // go back one step labelRotation--; break; } labelRotation++; labelWidth = cosRotation * originalLabelWidth; } } me.labelRotation = labelRotation; }, afterCalculateTickRotation: function() { helpers.callCallback(this.options.afterCalculateTickRotation, [this]); }, // beforeFit: function() { helpers.callCallback(this.options.beforeFit, [this]); }, fit: function() { var me = this; // Reset var minSize = me.minSize = { width: 0, height: 0 }; var opts = me.options; var tickOpts = opts.ticks; var scaleLabelOpts = opts.scaleLabel; var gridLineOpts = opts.gridLines; var display = opts.display; var isHorizontal = me.isHorizontal(); var tickFont = parseFontOptions(tickOpts); var scaleLabelFontSize = parseFontOptions(scaleLabelOpts).size * 1.5; var tickMarkLength = opts.gridLines.tickMarkLength; // Width if (isHorizontal) { // subtract the margins to line up with the chartArea if we are a full width scale minSize.width = me.isFullWidth() ? me.maxWidth - me.margins.left - me.margins.right : me.maxWidth; } else { minSize.width = display && gridLineOpts.drawTicks ? tickMarkLength : 0; } // height if (isHorizontal) { minSize.height = display && gridLineOpts.drawTicks ? tickMarkLength : 0; } else { minSize.height = me.maxHeight; // fill all the height } // Are we showing a title for the scale? if (scaleLabelOpts.display && display) { if (isHorizontal) { minSize.height += scaleLabelFontSize; } else { minSize.width += scaleLabelFontSize; } } // Don't bother fitting the ticks if we are not showing them if (tickOpts.display && display) { var largestTextWidth = helpers.longestText(me.ctx, tickFont.font, me.ticks, me.longestTextCache); var tallestLabelHeightInLines = helpers.numberOfLabelLines(me.ticks); var lineSpace = tickFont.size * 0.5; if (isHorizontal) { // A horizontal axis is more constrained by the height. me.longestLabelWidth = largestTextWidth; var angleRadians = helpers.toRadians(me.labelRotation); var cosRotation = Math.cos(angleRadians); var sinRotation = Math.sin(angleRadians); // TODO - improve this calculation var labelHeight = (sinRotation * largestTextWidth) + (tickFont.size * tallestLabelHeightInLines) + (lineSpace * tallestLabelHeightInLines); minSize.height = Math.min(me.maxHeight, minSize.height + labelHeight); me.ctx.font = tickFont.font; var firstTick = me.ticks[0]; var firstLabelWidth = computeTextSize(me.ctx, firstTick, tickFont.font); var lastTick = me.ticks[me.ticks.length - 1]; var lastLabelWidth = computeTextSize(me.ctx, lastTick, tickFont.font); // 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 me.paddingLeft = me.labelRotation !== 0 ? (cosRotation * firstLabelWidth) + 3 : firstLabelWidth / 2 + 3; // add 3 px to move away from canvas edges me.paddingRight = me.labelRotation !== 0 ? (sinRotation * lineSpace) + 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 // Account for padding if (tickOpts.mirror) { largestTextWidth = 0; } else { largestTextWidth += me.options.ticks.padding; } minSize.width += largestTextWidth; me.paddingTop = tickFont.size / 2; me.paddingBottom = tickFont.size / 2; } } me.handleMargins(); me.width = minSize.width; me.height = minSize.height; }, /** * Handle margins and padding interactions * @private */ handleMargins: function() { var me = this; if (me.margins) { me.paddingLeft = Math.max(me.paddingLeft - me.margins.left, 0); me.paddingTop = Math.max(me.paddingTop - me.margins.top, 0); me.paddingRight = Math.max(me.paddingRight - me.margins.right, 0); me.paddingBottom = Math.max(me.paddingBottom - me.margins.bottom, 0); } }, 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(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; Discard Infinite values if (typeof(rawValue) === 'number' && !isFinite(rawValue)) { return NaN; } // If it is in fact an object, dive in one more level if (typeof(rawValue) === 'object') { if ((rawValue instanceof Date) || (rawValue.isValid)) { return rawValue; } return this.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 to get the data value from a given pixel. This is the inverse of getPixelForValue getValueForPixel: helpers.noop, // Used for tick location, should getPixelForTick: function(index, includeOffset) { var me = this; if (me.isHorizontal()) { var innerWidth = me.width - (me.paddingLeft + me.paddingRight); var tickWidth = innerWidth / Math.max((me.ticks.length - ((me.options.gridLines.offsetGridLines) ? 0 : 1)), 1); var pixel = (tickWidth * index) + me.paddingLeft; if (includeOffset) { pixel += tickWidth / 2; } var finalVal = me.left + Math.round(pixel); finalVal += me.isFullWidth() ? me.margins.left : 0; return finalVal; } var innerHeight = me.height - (me.paddingTop + me.paddingBottom); return me.top + (index * (innerHeight / (me.ticks.length - 1))); }, // Utility for getting the pixel location of a percentage of scale getPixelForDecimal: function(decimal /* , includeOffset*/) { var me = this; if (me.isHorizontal()) { var innerWidth = me.width - (me.paddingLeft + me.paddingRight); var valueOffset = (innerWidth * decimal) + me.paddingLeft; var finalVal = me.left + Math.round(valueOffset); finalVal += me.isFullWidth() ? me.margins.left : 0; return finalVal; } return me.top + (decimal * me.height); }, getBasePixel: function() { var me = this; var min = me.min; var max = me.max; return me.getPixelForValue( me.beginAtZero? 0: min < 0 && max < 0? max : min > 0 && max > 0? min : 0); }, // Actually draw the scale on the canvas // @param {rectangle} chartArea : the area of the chart to draw full grid lines on draw: function(chartArea) { var me = this; var options = me.options; if (!options.display) { return; } var context = me.ctx; var globalDefaults = Chart.defaults.global; var optionTicks = options.ticks; var gridLines = options.gridLines; var scaleLabel = options.scaleLabel; var isRotated = me.labelRotation !== 0; var skipRatio; var useAutoskipper = optionTicks.autoSkip; var isHorizontal = me.isHorizontal(); // figure out the maximum number of gridlines to show var maxTicks; if (optionTicks.maxTicksLimit) { maxTicks = optionTicks.maxTicksLimit; } var tickFontColor = helpers.getValueOrDefault(optionTicks.fontColor, globalDefaults.defaultFontColor); var tickFont = parseFontOptions(optionTicks); var tl = gridLines.drawTicks ? gridLines.tickMarkLength : 0; var borderDash = helpers.getValueOrDefault(gridLines.borderDash, globalDefaults.borderDash); var borderDashOffset = helpers.getValueOrDefault(gridLines.borderDashOffset, globalDefaults.borderDashOffset); var scaleLabelFontColor = helpers.getValueOrDefault(scaleLabel.fontColor, globalDefaults.defaultFontColor); var scaleLabelFont = parseFontOptions(scaleLabel); var labelRotationRadians = helpers.toRadians(me.labelRotation); var cosRotation = Math.cos(labelRotationRadians); var longestRotatedLabel = me.longestLabelWidth * cosRotation; // Make sure we draw text in the correct color and font context.fillStyle = tickFontColor; var itemsToDraw = []; if (isHorizontal) { skipRatio = false; // Only calculate the skip ratio with the half width of longestRotateLabel if we got an actual rotation // See #2584 if (isRotated) { longestRotatedLabel /= 2; } if ((longestRotatedLabel + optionTicks.autoSkipPadding) * me.ticks.length > (me.width - (me.paddingLeft + me.paddingRight))) { skipRatio = 1 + Math.floor(((longestRotatedLabel + optionTicks.autoSkipPadding) * me.ticks.length) / (me.width - (me.paddingLeft + me.paddingRight))); } // if they defined a max number of optionTicks, // increase skipRatio until that number is met if (maxTicks && me.ticks.length > maxTicks) { while (!skipRatio || me.ticks.length / (skipRatio || 1) > maxTicks) { if (!skipRatio) { skipRatio = 1; } skipRatio += 1; } } if (!useAutoskipper) { skipRatio = false; } } var xTickStart = options.position === 'right' ? me.left : me.right - tl; var xTickEnd = options.position === 'right' ? me.left + tl : me.right; var yTickStart = options.position === 'bottom' ? me.top : me.bottom - tl; var yTickEnd = options.position === 'bottom' ? me.top + tl : me.bottom; helpers.each(me.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 isLastTick = me.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 >= me.ticks.length); if (shouldSkip && !isLastTick || (label === undefined || label === null)) { return; } var lineWidth, lineColor; if (index === (typeof me.zeroLineIndex !== 'undefined' ? me.zeroLineIndex : 0)) { // Draw the first index specially lineWidth = gridLines.zeroLineWidth; lineColor = gridLines.zeroLineColor; } else { lineWidth = helpers.getValueAtIndexOrDefault(gridLines.lineWidth, index); lineColor = helpers.getValueAtIndexOrDefault(gridLines.color, index); } // Common properties var tx1, ty1, tx2, ty2, x1, y1, x2, y2, labelX, labelY; var textAlign = 'middle'; var textBaseline = 'middle'; if (isHorizontal) { if (!isRotated) { textBaseline = options.position === 'top' ? 'bottom' : 'top'; } textAlign = isRotated ? 'right' : 'center'; var xLineValue = me.getPixelForTick(index) + helpers.aliasPixel(lineWidth); // xvalues for grid lines labelX = me.getPixelForTick(index, gridLines.offsetGridLines) + optionTicks.labelOffset; // x values for optionTicks (need to consider offsetLabel option) labelY = (isRotated) ? me.top + 12 : options.position === 'top' ? me.bottom - tl : me.top + tl; tx1 = tx2 = x1 = x2 = xLineValue; ty1 = yTickStart; ty2 = yTickEnd; y1 = chartArea.top; y2 = chartArea.bottom; } else { var isLeft = options.position === 'left'; var tickPadding = optionTicks.padding; var labelXOffset; if (optionTicks.mirror) { textAlign = isLeft ? 'left' : 'right'; labelXOffset = tickPadding; } else { textAlign = isLeft ? 'right' : 'left'; labelXOffset = tl + tickPadding; } labelX = isLeft ? me.right - labelXOffset : me.left + labelXOffset; var yLineValue = me.getPixelForTick(index); // xvalues for grid lines yLineValue += helpers.aliasPixel(lineWidth); labelY = me.getPixelForTick(index, gridLines.offsetGridLines); tx1 = xTickStart; tx2 = xTickEnd; x1 = chartArea.left; x2 = chartArea.right; ty1 = ty2 = y1 = y2 = yLineValue; } itemsToDraw.push({ tx1: tx1, ty1: ty1, tx2: tx2, ty2: ty2, x1: x1, y1: y1, x2: x2, y2: y2, labelX: labelX, labelY: labelY, glWidth: lineWidth, glColor: lineColor, glBorderDash: borderDash, glBorderDashOffset: borderDashOffset, rotation: -1 * labelRotationRadians, label: label, textBaseline: textBaseline, textAlign: textAlign }); }); // Draw all of the tick labels, tick marks, and grid lines at the correct places helpers.each(itemsToDraw, function(itemToDraw) { if (gridLines.display) { context.save(); context.lineWidth = itemToDraw.glWidth; context.strokeStyle = itemToDraw.glColor; if (context.setLineDash) { context.setLineDash(itemToDraw.glBorderDash); context.lineDashOffset = itemToDraw.glBorderDashOffset; } context.beginPath(); if (gridLines.drawTicks) { context.moveTo(itemToDraw.tx1, itemToDraw.ty1); context.lineTo(itemToDraw.tx2, itemToDraw.ty2); } if (gridLines.drawOnChartArea) { context.moveTo(itemToDraw.x1, itemToDraw.y1); context.lineTo(itemToDraw.x2, itemToDraw.y2); } context.stroke(); context.restore(); } if (optionTicks.display) { context.save(); context.translate(itemToDraw.labelX, itemToDraw.labelY); context.rotate(itemToDraw.rotation); context.font = tickFont.font; context.textBaseline = itemToDraw.textBaseline; context.textAlign = itemToDraw.textAlign; var label = itemToDraw.label; if (helpers.isArray(label)) { for (var i = 0, y = 0; i < label.length; ++i) { // We just make sure the multiline element is a string here.. context.fillText('' + label[i], 0, y); // apply same lineSpacing as calculated @ L#320 y += (tickFont.size * 1.5); } } else { context.fillText(label, 0, 0); } context.restore(); } }); if (scaleLabel.display) { // Draw the scale label var scaleLabelX; var scaleLabelY; var rotation = 0; if (isHorizontal) { scaleLabelX = me.left + ((me.right - me.left) / 2); // midpoint of the width scaleLabelY = options.position === 'bottom' ? me.bottom - (scaleLabelFont.size / 2) : me.top + (scaleLabelFont.sie / 2); } else { var isLeft = options.position === 'left'; scaleLabelX = isLeft ? me.left + (scaleLabelFont.size / 2) : me.right - (scaleLabelFont.size / 2); scaleLabelY = me.top + ((me.bottom - me.top) / 2); rotation = isLeft ? -0.5 * Math.PI : 0.5 * Math.PI; } context.save(); context.translate(scaleLabelX, scaleLabelY); context.rotate(rotation); context.textAlign = 'center'; context.textBaseline = 'middle'; context.fillStyle = scaleLabelFontColor; // render in correct colour context.font = scaleLabelFont.font; context.fillText(scaleLabel.labelString, 0, 0); context.restore(); } if (gridLines.drawBorder) { // Draw the line at the edge of the axis context.lineWidth = helpers.getValueAtIndexOrDefault(gridLines.lineWidth, 0); context.strokeStyle = helpers.getValueAtIndexOrDefault(gridLines.color, 0); var x1 = me.left, x2 = me.right, y1 = me.top, y2 = me.bottom; var aliasPixel = helpers.aliasPixel(context.lineWidth); if (isHorizontal) { y1 = y2 = options.position === 'top' ? me.bottom : me.top; y1 += aliasPixel; y2 += aliasPixel; } else { x1 = x2 = options.position === 'left' ? me.right : me.left; x1 += aliasPixel; x2 += aliasPixel; } context.beginPath(); context.moveTo(x1, y1); context.lineTo(x2, y2); context.stroke(); } } }); };