diff --git a/gulpfile.js b/gulpfile.js index d55101d52..4115ab8a3 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -34,7 +34,8 @@ var srcFiles = [ './src/elements/**', './src/charts/**', './node_modules/color/dist/color.min.js', - './node_modules/javascript-detect-element-resize/detect-element-resize.js' + './node_modules/javascript-detect-element-resize/detect-element-resize.js', + './node_modules/moment/min/moment.min.js' ]; @@ -177,7 +178,10 @@ function moduleSizesTask() { } function watchTask() { - gulp.watch('./src/**', ['build', 'unittest', 'unittestWatch']); + if (util.env.test) { + return gulp.watch('./src/**', ['build', 'unittest', 'unittestWatch']); + } + return gulp.watch('./src/**', ['build']); } function serverTask() { diff --git a/package.json b/package.json index 8295f2bde..698bca3bf 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "karma-firefox-launcher": "^0.1.6", "karma-jasmine": "^0.3.6", "karma-jasmine-html-reporter": "^0.1.8", + "moment": "^2.10.6", "onecolor": "^2.5.0", "semver": "^3.0.1" }, diff --git a/samples/line-logarithmic.html b/samples/line-logarithmic.html new file mode 100644 index 000000000..8a249ba96 --- /dev/null +++ b/samples/line-logarithmic.html @@ -0,0 +1,154 @@ + + + + + Line Chart + + + + + + +
+ +
+
+
+ + + + + +
+

Legend

+
+ +
+
+ + + + diff --git a/samples/line-time-scale.html b/samples/line-time-scale.html new file mode 100644 index 000000000..01df3a4e2 --- /dev/null +++ b/samples/line-time-scale.html @@ -0,0 +1,177 @@ + + + + + Line Chart + + + + + + +
+ +
+
+
+ + + + + +
+

Legend

+
+
+
+ + + + diff --git a/samples/scatter-logX.html b/samples/scatter-logX.html new file mode 100644 index 000000000..d8d00c85c --- /dev/null +++ b/samples/scatter-logX.html @@ -0,0 +1,166 @@ + + + + + Scatter Chart + + + + + +
+
+ +
+
+ + + + diff --git a/src/core/core.controller.js b/src/core/core.controller.js index b621d6f1e..d39ef3329 100644 --- a/src/core/core.controller.js +++ b/src/core/core.controller.js @@ -330,10 +330,12 @@ var eventPosition = helpers.getRelativePosition(e); var elementsArray = []; - for (var datasetIndex = 0; datasetIndex < this.chart.data.datasets.length; datasetIndex++) { - for (elementIndex = 0; elementIndex < this.chart.data.datasets[datasetIndex].metaData.length; elementIndex++) { - if (this.chart.data.datasets[datasetIndex].metaData[elementIndex].inLabelRange(eventPosition.x, eventPosition.y)) { - helpers.each(this.chart.data.datasets, datasetIterator); + for (var datasetIndex = 0; datasetIndex < this.data.datasets.length; datasetIndex++) { + for (var elementIndex = 0; elementIndex < this.data.datasets[datasetIndex].metaData.length; elementIndex++) { + if (this.data.datasets[datasetIndex].metaData[elementIndex].inLabelRange(eventPosition.x, eventPosition.y)) { + helpers.each(this.data.datasets[datasetIndex].metaData, function(element, index) { + elementsArray.push(element); + }, this); } } } @@ -420,12 +422,11 @@ this.data.datasets[this.lastActive[0]._datasetIndex].controller.removeHoverStyle(this.lastActive[0], this.lastActive[0]._datasetIndex, this.lastActive[0]._index); break; case 'label': + case 'dataset': for (var i = 0; i < this.lastActive.length; i++) { this.data.datasets[this.lastActive[i]._datasetIndex].controller.removeHoverStyle(this.lastActive[i], this.lastActive[i]._datasetIndex, this.lastActive[i]._index); } break; - case 'dataset': - break; default: // Don't change anything } @@ -438,12 +439,11 @@ this.data.datasets[this.active[0]._datasetIndex].controller.setHoverStyle(this.active[0]); break; case 'label': + case 'dataset': for (var i = 0; i < this.active.length; i++) { this.data.datasets[this.active[i]._datasetIndex].controller.setHoverStyle(this.active[i]); } break; - case 'dataset': - break; default: // Don't change anything } diff --git a/src/core/core.helpers.js b/src/core/core.helpers.js index b967b30b6..8d6790bf9 100644 --- a/src/core/core.helpers.js +++ b/src/core/core.helpers.js @@ -387,8 +387,7 @@ } else { // Generate a reusable function that will serve as a template // generator (and which will be cached). - fn = new Function("obj", - "var p=[],print=function(){p.push.apply(p,arguments);};" + + var functionCode = "var p=[],print=function(){p.push.apply(p,arguments);};" + // Introduce the data as local variables using with(){} "with(obj){p.push('" + @@ -402,8 +401,8 @@ .split("\t").join("');") .split("%>").join("p.push('") .split("\r").join("\\'") + - "');}return p.join('');" - ); + "');}return p.join('');"; + fn = new Function("obj", functionCode); // Cache the result templateStringCache[str] = fn; diff --git a/src/core/core.tooltip.js b/src/core/core.tooltip.js index 3f4a892c4..92e2b1364 100644 --- a/src/core/core.tooltip.js +++ b/src/core/core.tooltip.js @@ -164,7 +164,11 @@ x: medianPosition.x, y: medianPosition.y, labels: labels, - title: this._data.labels && this._data.labels.length ? this._data.labels[this._active[0]._index] : '', + title: (function() { + return this._data.timeLabels ? this._data.timeLabels[this._active[0]._index] : + (this._data.labels && this._data.labels.length) ? this._data.labels[this._active[0]._index] : + ''; + }).call(this), legendColors: colors, legendBackgroundColor: this._options.tooltips.multiKeyBackground, }); diff --git a/src/scales/scale.logarithmic.js b/src/scales/scale.logarithmic.js new file mode 100644 index 000000000..20545846e --- /dev/null +++ b/src/scales/scale.logarithmic.js @@ -0,0 +1,589 @@ +(function() { + "use strict"; + + var root = this, + Chart = root.Chart, + helpers = Chart.helpers; + + var defaultConfig = { + display: true, + position: "left", + + // grid line settings + gridLines: { + show: true, + color: "rgba(0, 0, 0, 0.1)", + lineWidth: 1, + drawOnChartArea: true, + drawTicks: true, // draw ticks extending towards the label + zeroLineWidth: 1, + zeroLineColor: "rgba(0,0,0,0.25)", + }, + + // scale numbers + reverse: false, + override: null, + + // label settings + labels: { + show: true, + mirror: false, + padding: 10, + template: "<%var remain = value / (Math.pow(10, Math.floor(Chart.helpers.log10(value))));if (remain === 1 || remain === 2 || remain === 5) {%><%=value.toExponential()%><%} else {%><%= null %><%}%>", + fontSize: 12, + fontStyle: "normal", + fontColor: "#666", + fontFamily: "Helvetica Neue" + } + }; + + var LogarithmicScale = Chart.Element.extend({ + isHorizontal: function() { + return this.options.position == "top" || this.options.position == "bottom"; + }, + generateTicks: function(width, height) { + // We need to decide how many ticks we are going to have. Each tick draws a grid line. + // There are two possibilities. The first is that the user has manually overridden the scale + // calculations in which case the job is easy. The other case is that we have to do it ourselves + // + // We assume at this point that the scale object has been updated with the following values + // by the chart. + // min: this is the minimum value of the scale + // max: this is the maximum value of the scale + // options: contains the options for the scale. This is referenced from the user settings + // rather than being cloned. This ensures that updates always propogate to a redraw + + // Reset the ticks array. Later on, we will draw a grid line at these positions + // The array simply contains the numerical value of the spots where ticks will be + this.ticks = []; + + if (this.options.override) { + // The user has specified the manual override. We use <= instead of < so that + // we get the final line + for (var i = 0; i <= this.options.override.steps; ++i) { + var value = this.options.override.start + (i * this.options.override.stepWidth); + this.ticks.push(value); + } + } else { + // Figure out what the max number of ticks we can support it is based on the size of + // the axis area. For now, we say that the minimum tick spacing in pixels must be 50 + // We also limit the maximum number of ticks to 11 which gives a nice 10 squares on + // the graph + + var minExponent = Math.floor(helpers.log10(this.min)); + var maxExponent = Math.ceil(helpers.log10(this.max)); + + for (var exponent = minExponent; exponent < maxExponent; ++exponent) { + for (var i = 1; i < 10; ++i) { + this.ticks.push(i * Math.pow(10, exponent)); + } + } + + this.ticks.push(1.0 * Math.pow(10, maxExponent)); + } + + if (this.options.position == "left" || this.options.position == "right") { + // We are in a vertical orientation. The top value is the highest. So reverse the array + this.ticks.reverse(); + } + + // At this point, we need to update our max and min given the tick values since we have expanded the + // range of the scale + this.max = helpers.max(this.ticks); + this.min = helpers.min(this.ticks); + + if (this.options.reverse) { + this.ticks.reverse(); + + this.start = this.max; + this.end = this.min; + } else { + this.start = this.min; + this.end = this.max; + } + }, + buildLabels: function() { + // We assume that this has been run after ticks have been generated. We try to figure out + // a label for each tick. + this.labels = []; + + helpers.each(this.ticks, function(tick, index, ticks) { + var label; + + if (this.options.labels.userCallback) { + // If the user provided a callback for label generation, use that as first priority + label = this.options.labels.userCallback(tick, index, ticks); + } else if (this.options.labels.template) { + // else fall back to the template string + label = helpers.template(this.options.labels.template, { + value: tick + }); + } + + this.labels.push(label); // empty string will not render so we're good + }, this); + }, + // Get the correct value. If the value type is object get the x or y based on whether we are horizontal or not + getRightValue: function(rawValue) { + return typeof rawValue === "object" ? (this.isHorizontal() ? rawValue.x : rawValue.y) : rawValue; + }, + getPixelForValue: function(value) { + // This must be called after fit has been run so that + // this.left, this.top, this.right, and this.bottom have been defined + var pixel; + var range = helpers.log10(this.end) - helpers.log10(this.start); + + if (this.isHorizontal()) { + if (value === 0) { + pixel = this.left + this.paddingLeft; + } else { + var innerWidth = this.width - (this.paddingLeft + this.paddingRight); + pixel = this.left + (innerWidth / range * (helpers.log10(value) - helpers.log10(this.start))); + pixel += this.paddingLeft; + } + } else { + // Bottom - top since pixels increase downard on a screen + if (value === 0) { + pixel = this.top + this.paddingTop; + } else { + var innerHeight = this.height - (this.paddingTop + this.paddingBottom); + pixel = (this.bottom - this.paddingBottom) - (innerHeight / range * (helpers.log10(value) - helpers.log10(this.start))); + } + } + + return pixel; + }, + + // Functions needed for line charts + calculateRange: function() { + this.min = null; + this.max = null; + + var values = []; + + if (this.options.stacked) { + helpers.each(this.data.datasets, function(dataset) { + if (this.isHorizontal() ? dataset.xAxisID === this.id : dataset.yAxisID === this.id) { + helpers.each(dataset.data, function(rawValue, index) { + + var value = this.getRightValue(rawValue); + + values[index] = values[index] || 0; + + if (this.options.relativePoints) { + values[index] = 100; + } else { + // Don't need to split positive and negative since the log scale can't handle a 0 crossing + values[index] += value; + } + }, this); + } + }, this); + + this.min = helpers.min(values); + this.max = helpers.max(values); + + } else { + helpers.each(this.data.datasets, function(dataset) { + if (this.isHorizontal() ? dataset.xAxisID === this.id : dataset.yAxisID === this.id) { + helpers.each(dataset.data, function(rawValue, index) { + var value = this.getRightValue(rawValue); + + if (this.min === null) { + this.min = value; + } else if (value < this.min) { + this.min = value; + } + + if (this.max === null) { + this.max = value; + } else if (value > this.max) { + this.max = value; + } + }, this); + } + }, this); + } + + if (this.min === this.max) { + if (this.min !== 0 && this.min !== null) { + this.min = Math.pow(10, Math.floor(helpers.log10(this.min)) - 1); + this.max = Math.pow(10, Math.floor(helpers.log10(this.max)) + 1); + } else { + this.min = 1; + this.max = 10; + } + } + }, + + getPointPixelForValue: function(rawValue, index, datasetIndex) { + var value = this.getRightValue(rawValue); + + if (this.options.stacked) { + var offsetPos = 0; + var offsetNeg = 0; + + for (var i = this.data.datasets.length - 1; i > datasetIndex; --i) { + if (this.data.datasets[i].data[index] < 0) { + offsetNeg += this.data.datasets[i].data[index]; + } else { + offsetPos += this.data.datasets[i].data[index]; + } + } + + if (value < 0) { + return this.getPixelForValue(offsetNeg + value); + } else { + return this.getPixelForValue(offsetPos + value); + } + } else { + return this.getPixelForValue(value); + } + }, + + // Functions needed for bar charts + calculateBarBase: function(datasetIndex, index) { + var base = 0; + + if (this.options.stacked) { + + var value = this.data.datasets[datasetIndex].data[index]; + + for (var j = 0; j < datasetIndex; j++) { + if (this.data.datasets[j].yAxisID === this.id) { + base += this.data.datasets[j].data[index]; + } + } + + return this.getPixelForValue(base); + } + + base = this.getPixelForValue(this.min); + + if (this.min < 0 && this.max < 0) { + // All values are negative. Use the top as the base + base = this.getPixelForValue(this.max); + } + + return base; + + }, + calculateBarY: function(datasetIndex, index) { + var value = this.data.datasets[datasetIndex].data[index]; + + if (this.options.stacked) { + + var sumPos = 0, + sumNeg = 0; + + for (var i = 0; i < datasetIndex; i++) { + if (this.data.datasets[i].data[index] < 0) { + sumNeg += this.data.datasets[i].data[index] || 0; + } else { + sumPos += this.data.datasets[i].data[index] || 0; + } + } + + if (value < 0) { + return this.getPixelForValue(sumNeg + value); + } else { + return this.getPixelForValue(sumPos + value); + } + + return this.getPixelForValue(value); + } + + var offset = 0; + + for (var j = datasetIndex; j < this.data.datasets.length; j++) { + if (j === datasetIndex && value) { + offset += value; + } else { + offset = offset + value; + } + } + + return this.getPixelForValue(value); + }, + + // Fit this axis to the given size + // @param {number} maxWidth : the max width the axis can be + // @param {number} maxHeight: the max height the axis can be + // @return {object} minSize : the minimum size needed to draw the axis + fit: function(maxWidth, maxHeight, margins) { + this.calculateRange(); + this.generateTicks(maxWidth, maxHeight); + this.buildLabels(); + + var minSize = { + width: 0, + height: 0, + }; + + // In a horizontal axis, we need some room for the scale to be drawn + // + // ----------------------------------------------------- + // | | | | | + // + // In a vertical axis, we need some room for the scale to be drawn. + // The actual grid lines will be drawn on the chart area, however, we need to show + // ticks where the axis actually is. + // We will allocate 25px for this width + // | + // -| + // | + // | + // -| + // | + // | + // -| + + + // Width + if (this.isHorizontal()) { + minSize.width = maxWidth; // fill all the width + } else { + minSize.width = this.options.gridLines.show && this.options.display ? 10 : 0; + } + + // height + if (this.isHorizontal()) { + minSize.height = this.options.gridLines.show && this.options.display ? 10 : 0; + } else { + minSize.height = maxHeight; // fill all the height + } + + this.paddingLeft = 0; + this.paddingRight = 0; + this.paddingTop = 0; + this.paddingBottom = 0; + + + if (this.options.labels.show && this.options.display) { + // Don't bother fitting the labels if we are not showing them + var labelFont = helpers.fontString(this.options.labels.fontSize, + this.options.labels.fontStyle, this.options.labels.fontFamily); + + if (this.isHorizontal()) { + // A horizontal axis is more constrained by the height. + var maxLabelHeight = maxHeight - minSize.height; + var labelHeight = 1.5 * this.options.labels.fontSize; + minSize.height = Math.min(maxHeight, minSize.height + labelHeight); + + var labelFont = helpers.fontString(this.options.labels.fontSize, this.options.labels.fontStyle, this.options.labels.fontFamily); + this.ctx.font = labelFont; + + var firstLabelWidth = this.ctx.measureText(this.labels[0]).width; + var lastLabelWidth = this.ctx.measureText(this.labels[this.labels.length - 1]).width; + + // Ensure that our labels are always inside the canvas + this.paddingLeft = firstLabelWidth / 2; + this.paddingRight = lastLabelWidth / 2; + } else { + // A vertical axis is more constrained by the width. Labels are the dominant factor + // here, so get that length first + var maxLabelWidth = maxWidth - minSize.width; + var largestTextWidth = helpers.longestText(this.ctx, labelFont, this.labels); + + if (largestTextWidth < maxLabelWidth) { + // We don't need all the room + minSize.width += largestTextWidth; + minSize.width += 3; // extra padding + } else { + // Expand to max size + minSize.width = maxWidth; + } + + this.paddingTop = this.options.labels.fontSize / 2; + this.paddingBottom = this.options.labels.fontSize / 2; + } + } + + if (margins) { + this.paddingLeft -= margins.left; + this.paddingTop -= margins.top; + this.paddingRight -= margins.right; + this.paddingBottom -= margins.bottom; + + this.paddingLeft = Math.max(this.paddingLeft, 0); + this.paddingTop = Math.max(this.paddingTop, 0); + this.paddingRight = Math.max(this.paddingRight, 0); + this.paddingBottom = Math.max(this.paddingBottom, 0); + } + + this.width = minSize.width; + this.height = minSize.height; + return minSize; + }, + // 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 hasZero; + + // Make sure we draw text in the correct color + this.ctx.fillStyle = this.options.labels.fontColor; + + if (this.isHorizontal()) { + if (this.options.gridLines.show) { + // Draw the horizontal line + setContextLineSettings = true; + hasZero = helpers.findNextWhere(this.ticks, function(tick) { + return tick === 0; + }) !== undefined; + var yTickStart = this.options.position == "bottom" ? this.top : this.bottom - 5; + var yTickEnd = this.options.position == "bottom" ? this.top + 5 : this.bottom; + + helpers.each(this.ticks, function(tick, index) { + // Grid lines are vertical + var xValue = this.getPixelForValue(tick); + + if (this.labels[index] === null) { + // If the user specifically hid the label by returning null from the label function, do so + return; + } + + if (tick === 0 || (!hasZero && index === 0)) { + // Draw the 0 point specially or the left if there is no 0 + 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; + } + + xValue += helpers.aliasPixel(this.ctx.lineWidth); + + // Draw the label area + this.ctx.beginPath(); + + if (this.options.gridLines.drawTicks) { + this.ctx.moveTo(xValue, yTickStart); + this.ctx.lineTo(xValue, yTickEnd); + } + + // Draw the chart area + if (this.options.gridLines.drawOnChartArea) { + this.ctx.moveTo(xValue, chartArea.top); + this.ctx.lineTo(xValue, chartArea.bottom); + } + + // Need to stroke in the loop because we are potentially changing line widths & colours + this.ctx.stroke(); + }, this); + } + + if (this.options.labels.show) { + // Draw the labels + + var labelStartY; + + if (this.options.position == "top") { + labelStartY = this.bottom - 10; + this.ctx.textBaseline = "bottom"; + } else { + // bottom side + labelStartY = this.top + 10; + this.ctx.textBaseline = "top"; + } + + this.ctx.textAlign = "center"; + this.ctx.font = helpers.fontString(this.options.labels.fontSize, this.options.labels.fontStyle, this.options.labels.fontFamily); + + helpers.each(this.labels, function(label, index) { + var xValue = this.getPixelForValue(this.ticks[index]); + if (label) { + this.ctx.fillText(label, xValue, labelStartY); + } + }, this); + } + } else { + // Vertical + if (this.options.gridLines.show) { + + // Draw the vertical line + setContextLineSettings = true; + hasZero = helpers.findNextWhere(this.ticks, function(tick) { + return tick === 0; + }) !== undefined; + 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(tick, index) { + // Grid lines are horizontal + var yValue = this.getPixelForValue(tick); + + if (tick === 0 || (!hasZero && index === 0)) { + // Draw the 0 point specially or the bottom if there is no 0 + 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; // use boolean to indicate that we only want to do this once + } + + yValue += helpers.aliasPixel(this.ctx.lineWidth); + + // Draw the label area + this.ctx.beginPath(); + + if (this.options.gridLines.drawTicks) { + this.ctx.moveTo(xTickStart, yValue); + this.ctx.lineTo(xTickEnd, yValue); + } + + // Draw the chart area + if (this.options.gridLines.drawOnChartArea) { + this.ctx.moveTo(chartArea.left, yValue); + this.ctx.lineTo(chartArea.right, yValue); + } + + this.ctx.stroke(); + }, this); + } + + if (this.options.labels.show) { + // Draw the labels + + var labelStartX; + + if (this.options.position == "left") { + if (this.options.labels.mirror) { + labelStartX = this.right + this.options.labels.padding; + this.ctx.textAlign = "left"; + } else { + labelStartX = this.right - this.options.labels.padding; + this.ctx.textAlign = "right"; + } + } else { + // right side + if (this.options.labels.mirror) { + labelStartX = this.left - this.options.labels.padding; + this.ctx.textAlign = "right"; + } else { + labelStartX = this.left + this.options.labels.padding; + this.ctx.textAlign = "left"; + } + } + + this.ctx.textBaseline = "middle"; + this.ctx.font = helpers.fontString(this.options.labels.fontSize, this.options.labels.fontStyle, this.options.labels.fontFamily); + + helpers.each(this.labels, function(label, index) { + var yValue = this.getPixelForValue(this.ticks[index]); + this.ctx.fillText(label, labelStartX, yValue); + }, this); + } + } + } + } + }); + Chart.scaleService.registerScaleType("logarithmic", LogarithmicScale, defaultConfig); + +}).call(this); diff --git a/src/scales/scale.time.js b/src/scales/scale.time.js new file mode 100644 index 000000000..18df1a6ac --- /dev/null +++ b/src/scales/scale.time.js @@ -0,0 +1,437 @@ +(function() { + "use strict"; + + var root = this, + Chart = root.Chart, + helpers = Chart.helpers; + + var time = { + units: [ + 'millisecond', + 'second', + 'minute', + 'hour', + 'day', + 'week', + 'month', + 'quarter', + 'year', + ], + unit: { + 'millisecond': { + display: 'SSS [ms]', // 002 ms + maxStep: 1000, + }, + 'second': { + display: 'h:mm:ss a', // 11:20:01 AM + maxStep: 60, + }, + 'minute': { + display: 'h:mm:ss a', // 11:20:01 AM + maxStep: 60, + }, + 'hour': { + display: 'MMM D, hA', // Sept 4, 5PM + maxStep: 24, + }, + 'day': { + display: 'll', // Sep 4 2015 + maxStep: 7, + }, + 'week': { + display: 'll', // Week 46, or maybe "[W]WW - YYYY" ? + maxStep: 4.3333, + }, + 'month': { + display: 'MMM YYYY', // Sept 2015 + maxStep: 12, + }, + 'quarter': { + display: '[Q]Q - YYYY', // Q3 + maxStep: 4, + }, + 'year': { + display: 'YYYY', // 2015 + maxStep: false, + }, + } + }; + + var defaultConfig = { + display: true, + position: "bottom", + + // grid line settings + gridLines: { + show: true, + color: "rgba(0, 0, 0, 0.1)", + lineWidth: 1, + drawOnChartArea: true, + drawTicks: true, // draw ticks extending towards the label + }, + + tick: { + format: false, // false == date objects or use pattern string from http://momentjs.com/docs/#/parsing/string-format/ + unit: false, // false == automatic or override with week, month, year, etc. + round: false, // none, or override with week, month, year, etc. + displayFormat: false, // defaults to unit's corresponding unitFormat below or override using pattern string from http://momentjs.com/docs/#/displaying/format/ + }, + + // scale numbers + reverse: false, + override: null, + + // label settings + labels: { + show: true, + mirror: false, + padding: 10, + template: "<%=value.toLocaleString()%>", + fontSize: 12, + fontStyle: "normal", + fontColor: "#666", + fontFamily: "Helvetica Neue", + maxRotation: 45, + } + }; + + var TimeScale = Chart.Element.extend({ + isHorizontal: function() { + return this.options.position == "top" || this.options.position == "bottom"; + }, + parseTime: function(label) { + // Date objects + if (typeof label.getMonth === 'function' || typeof label == 'number') { + return moment(label); + } + // Moment support + if (label.isValid && label.isValid()) { + return label; + } + // Custom parsing (return an instance of moment) + if (typeof this.options.tick.format !== 'string' && this.options.tick.format.call) { + return this.options.tick.format(label); + } + // Moment format parsing + return moment(label, this.options.tick.format); + }, + generateTicks: function(index) { + + this.ticks = []; + this.labelMoments = []; + + // Parse each label into a moment + this.data.labels.forEach(function(label, index) { + var labelMoment = this.parseTime(label); + if (this.options.tick.round) { + labelMoment.startOf(this.options.tick.round); + } + this.labelMoments.push(labelMoment); + }, this); + + // Find the first and last moments, and range + this.firstTick = moment.min.call(this, this.labelMoments).clone(); + this.lastTick = moment.max.call(this, this.labelMoments).clone(); + + // Set unit override if applicable + if (this.options.tick.unit) { + this.tickUnit = this.options.tick.unit || 'day'; + this.displayFormat = time.unit.day.display; + this.tickRange = Math.ceil(this.lastTick.diff(this.firstTick, this.tickUnit, true)); + } else { + // Determine the smallest needed unit of the time + var innerWidth = this.width - (this.paddingLeft + this.paddingRight); + var labelCapacity = innerWidth / this.options.labels.fontSize + 4; + var buffer = this.options.tick.round ? 0 : 2; + + this.tickRange = Math.ceil(this.lastTick.diff(this.firstTick, true) + buffer); + var done; + + helpers.each(time.units, function(format) { + if (this.tickRange <= labelCapacity) { + return; + } + this.tickUnit = format; + this.tickRange = Math.ceil(this.lastTick.diff(this.firstTick, this.tickUnit) + buffer); + this.displayFormat = time.unit[format].display; + + }, this); + } + + this.firstTick.startOf(this.tickUnit); + this.lastTick.endOf(this.tickUnit); + + + // Tick displayFormat override + if (this.options.tick.displayFormat) { + this.displayFormat = this.options.tick.displayFormat; + } + + // For every unit in between the first and last moment, create a moment and add it to the labels tick + var i = 0; + if (this.options.labels.userCallback) { + for (; i <= this.tickRange; i++) { + this.ticks.push( + this.options.labels.userCallback(this.firstTick.clone() + .add(i, this.tickUnit) + .format(this.options.tick.displayFormat ? this.options.tick.displayFormat : time.unit[this.tickUnit].display) + ) + ); + } + } else { + for (; i <= this.tickRange; i++) { + this.ticks.push(this.firstTick.clone() + .add(i, this.tickUnit) + .format(this.options.tick.displayFormat ? this.options.tick.displayFormat : time.unit[this.tickUnit].display) + ); + } + } + }, + getPixelForValue: function(value, decimal, datasetIndex, includeOffset) { + // This must be called after fit has been run so that + // this.left, this.top, this.right, and this.bottom have been defined + if (this.isHorizontal()) { + var innerWidth = this.width - (this.paddingLeft + this.paddingRight); + var valueWidth = innerWidth / Math.max((this.ticks.length - ((this.options.gridLines.offsetGridLines) ? 0 : 1)), 1); + var valueOffset = (innerWidth * decimal) + this.paddingLeft; + + if (this.options.gridLines.offsetGridLines && includeOffset) { + valueOffset += (valueWidth / 2); + } + + return this.left + Math.round(valueOffset); + } else { + return this.top + (decimal * (this.height / this.ticks.length)); + } + }, + getPointPixelForValue: function(value, index, datasetIndex) { + + var offset = this.labelMoments[index].diff(this.firstTick, this.tickUnit, true); + return this.getPixelForValue(value, offset / this.tickRange, datasetIndex); + }, + + // Functions needed for bar charts + calculateBaseWidth: function() { + return (this.getPixelForValue(null, this.ticks.length / 100, 0, true) - this.getPixelForValue(null, 0, 0, true)) - (2 * this.options.categorySpacing); + }, + calculateBarWidth: function(barDatasetCount) { + //The padding between datasets is to the right of each bar, providing that there are more than 1 dataset + var baseWidth = this.calculateBaseWidth() - ((barDatasetCount - 1) * this.options.spacing); + + if (this.options.stacked) { + return baseWidth; + } + return (baseWidth / barDatasetCount); + }, + calculateBarX: function(barDatasetCount, datasetIndex, elementIndex) { + + var xWidth = this.calculateBaseWidth(), + xAbsolute = this.getPixelForValue(null, elementIndex, datasetIndex, true) - (xWidth / 2), + barWidth = this.calculateBarWidth(barDatasetCount); + + if (this.options.stacked) { + return xAbsolute + barWidth / 2; + } + + return xAbsolute + (barWidth * datasetIndex) + (datasetIndex * this.options.spacing) + barWidth / 2; + }, + + calculateTickRotation: function(maxHeight, margins) { + //Get the width of each grid by calculating the difference + //between x offsets between 0 and 1. + var labelFont = helpers.fontString(this.options.labels.fontSize, this.options.labels.fontStyle, this.options.labels.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; + var lastRotated; + + this.paddingRight = lastWidth / 2 + 3; + this.paddingLeft = firstWidth / 2 + 3; + + this.labelRotation = 0; + + if (this.options.display) { + var originalLabelWidth = helpers.longestText(this.ctx, labelFont, this.ticks); + var cosRotation; + var sinRotation; + var firstRotatedWidth; + + this.labelWidth = originalLabelWidth; + + //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 datasetWidth = Math.floor(this.getPixelForValue(null, 1 / this.ticks.length) - this.getPixelForValue(null, 0)) - 6; + + //Max label rotation can be set or default to 90 - also act as a loop counter + while (this.labelWidth > datasetWidth && this.labelRotation <= this.options.labels.maxRotation) { + cosRotation = Math.cos(helpers.toRadians(this.labelRotation)); + sinRotation = Math.sin(helpers.toRadians(this.labelRotation)); + + firstRotated = cosRotation * firstWidth; + lastRotated = cosRotation * lastWidth; + + // We're right aligning the text now. + if (firstRotated + this.options.labels.fontSize / 2 > this.yLabelWidth) { + this.paddingLeft = firstRotated + this.options.labels.fontSize / 2; + } + + this.paddingRight = this.options.labels.fontSize / 2; + + if (sinRotation * originalLabelWidth > maxHeight) { + // go back one step + this.labelRotation--; + break; + } + + this.labelRotation++; + this.labelWidth = cosRotation * originalLabelWidth; + + + } + } else { + this.labelWidth = 0; + this.paddingRight = 0; + this.paddingLeft = 0; + } + + if (margins) { + this.paddingLeft -= margins.left; + this.paddingRight -= margins.right; + + this.paddingLeft = Math.max(this.paddingLeft, 0); + this.paddingRight = Math.max(this.paddingRight, 0); + } + + }, + // Fit this axis to the given size + // @param {number} maxWidth : the max width the axis can be + // @param {number} maxHeight: the max height the axis can be + // @return {object} minSize : the minimum size needed to draw the axis + fit: function(maxWidth, maxHeight, margins) { + // Set the unconstrained dimension before label rotation + if (this.isHorizontal()) { + this.width = maxWidth; + } else { + this.height = maxHeight; + } + + this.generateTicks(); + this.calculateTickRotation(maxHeight, margins); + + var minSize = { + width: 0, + height: 0, + }; + + var labelFont = helpers.fontString(this.options.labels.fontSize, this.options.labels.fontStyle, this.options.labels.fontFamily); + var longestLabelWidth = helpers.longestText(this.ctx, labelFont, this.ticks); + + // Width + if (this.isHorizontal()) { + minSize.width = maxWidth; + } else if (this.options.display) { + var labelWidth = this.options.labels.show ? longestLabelWidth + 6 : 0; + minSize.width = Math.min(labelWidth, maxWidth); + } + + // Height + if (this.isHorizontal() && this.options.display) { + var labelHeight = (Math.sin(helpers.toRadians(this.labelRotation)) * longestLabelWidth) + 1.5 * this.options.labels.fontSize; + minSize.height = Math.min(this.options.labels.show ? labelHeight : 0, maxHeight); + } else if (this.options.display) { + minSize.height = maxHeight; + } + + this.width = minSize.width; + this.height = minSize.height; + return minSize; + }, + // 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; + + // Make sure we draw text in the correct color + this.ctx.fillStyle = this.options.labels.fontColor; + + 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; + var isRotated = this.labelRotation !== 0; + var skipRatio = false; + + if ((this.options.labels.fontSize + 4) * this.ticks.length > (this.width - (this.paddingLeft + this.paddingRight))) { + skipRatio = 1 + Math.floor(((this.options.labels.fontSize + 4) * this.ticks.length) / (this.width - (this.paddingLeft + this.paddingRight))); + } + + helpers.each(this.ticks, function(tick, index) { + // Blank ticks + if ((skipRatio > 1 && index % skipRatio > 0) || (tick === undefined || tick === null)) { + return; + } + var xLineValue = this.getPixelForValue(null, (1 / (this.ticks.length - 1)) * index, null, false); // xvalues for grid lines + var xLabelValue = this.getPixelForValue(null, (1 / (this.ticks.length - 1)) * index, null, true); // x values for ticks (need to consider offsetLabel option) + + if (this.options.gridLines.show) { + if (index === 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 tick 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.labels.show) { + this.ctx.save(); + this.ctx.translate(xLabelValue, (isRotated) ? this.top + 12 : this.top + 8); + this.ctx.rotate(helpers.toRadians(this.labelRotation) * -1); + this.ctx.font = this.font; + this.ctx.textAlign = (isRotated) ? "right" : "center"; + this.ctx.textBaseline = (isRotated) ? "middle" : "top"; + this.ctx.fillText(tick, 0, 0); + this.ctx.restore(); + } + }, this); + } else { + // Vertical + if (this.options.gridLines.show) {} + + if (this.options.labels.show) { + // Draw the labels + } + } + } + } + }); + Chart.scaleService.registerScaleType("time", TimeScale, defaultConfig); + +}).call(this); diff --git a/test/scale.logarithmic.tests.js b/test/scale.logarithmic.tests.js new file mode 100644 index 000000000..7a472d88c --- /dev/null +++ b/test/scale.logarithmic.tests.js @@ -0,0 +1,1561 @@ +describe('Logarithmic Scale tests', function() { + + it('Should register the constructor with the scale service', function() { + var Constructor = Chart.scaleService.getScaleConstructor('logarithmic'); + expect(Constructor).not.toBe(undefined); + expect(typeof Constructor).toBe('function'); + }); + + it('Should have the correct default config', function() { + var defaultConfig = Chart.scaleService.getScaleDefaults('logarithmic'); + expect(defaultConfig).toEqual({ + display: true, + position: "left", + gridLines: { + show: true, + color: "rgba(0, 0, 0, 0.1)", + lineWidth: 1, + drawOnChartArea: true, + drawTicks: true, + zeroLineWidth: 1, + zeroLineColor: "rgba(0,0,0,0.25)", + }, + reverse: false, + override: null, + labels: { + show: true, + mirror: false, + padding: 10, + template: "<%var remain = value / (Math.pow(10, Math.floor(Chart.helpers.log10(value))));if (remain === 1 || remain === 2 || remain === 5) {%><%=value.toExponential()%><%} else {%><%= null %><%}%>", + fontSize: 12, + fontStyle: "normal", + fontColor: "#666", + fontFamily: "Helvetica Neue" + } + }); + }); + + it('Should correctly determine the max & min data values', function() { + var scaleID = 'myScale'; + + var mockData = { + datasets: [{ + yAxisID: scaleID, + data: [10, 5, 5000, 78, 450] + }, { + yAxisID: 'second scale', + data: [1, 1000, 10, 100], + }, { + yAxisID: scaleID, + data: [150] + }] + }; + + var Constructor = Chart.scaleService.getScaleConstructor('logarithmic'); + var scale = new Constructor({ + ctx: {}, + options: Chart.scaleService.getScaleDefaults('logarithmic'), // use default config for scale + data: mockData, + id: scaleID + }); + + expect(scale).not.toEqual(undefined); // must construct + expect(scale.min).toBe(undefined); // not yet set + expect(scale.max).toBe(undefined); + + scale.calculateRange(); + expect(scale.min).toBe(5); + expect(scale.max).toBe(5000); + }); + + it('Should correctly determine the max & min for scatter data', function() { + var scaleID = 'myScale'; + + var mockData = { + datasets: [{ + xAxisID: scaleID, // for the horizontal scale + yAxisID: scaleID, + data: [{ + x: 10, + y: 100 + }, { + x: 2, + y: 6 + }, { + x: 65, + y: 121 + }, { + x: 99, + y: 7 + }] + }] + }; + + var config = Chart.helpers.clone(Chart.scaleService.getScaleDefaults('logarithmic')); + var Constructor = Chart.scaleService.getScaleConstructor('logarithmic'); + var verticalScale = new Constructor({ + ctx: {}, + options: config, + data: mockData, + id: scaleID + }); + + verticalScale.calculateRange(); + expect(verticalScale.min).toBe(6); + expect(verticalScale.max).toBe(121); + + var horizontalConfig = Chart.helpers.clone(config); + horizontalConfig.position = 'bottom'; + var horizontalScale = new Constructor({ + ctx: {}, + options: horizontalConfig, + data: mockData, + id: scaleID, + }); + + horizontalScale.calculateRange(); + expect(horizontalScale.min).toBe(2); + expect(horizontalScale.max).toBe(99); + }); + + it('Should correctly determine the min and max data values when stacked mode is turned on', function() { + var scaleID = 'myScale'; + + var mockData = { + datasets: [{ + yAxisID: scaleID, + data: [10, 5, 1, 5, 78, 100] + }, { + yAxisID: 'second scale', + data: [-1000, 1000], + }, { + yAxisID: scaleID, + data: [150, 10, 10, 100, 10, 9] + }] + }; + + var config = Chart.helpers.clone(Chart.scaleService.getScaleDefaults('logarithmic')); + config.stacked = true; // enable scale stacked mode + + var Constructor = Chart.scaleService.getScaleConstructor('logarithmic'); + var scale = new Constructor({ + ctx: {}, + options: config, + data: mockData, + id: scaleID + }); + + scale.calculateRange(); + expect(scale.min).toBe(11); + expect(scale.max).toBe(160); + }); + + it('Should ensure that the scale has a max and min that are not equal', function() { + var scaleID = 'myScale'; + + var mockData = { + datasets: [] + }; + + var config = Chart.helpers.clone(Chart.scaleService.getScaleDefaults('logarithmic')); + var Constructor = Chart.scaleService.getScaleConstructor('logarithmic'); + var scale = new Constructor({ + ctx: {}, + options: config, + data: mockData, + id: scaleID + }); + + scale.calculateRange(); + expect(scale.min).toBe(1); + expect(scale.max).toBe(10); + + mockData.datasets = [{ + yAxisID: scaleID, + data: [0.15, 0.15] + }]; + + scale.calculateRange(); + expect(scale.min).toBe(0.01); + expect(scale.max).toBe(1); + }); + + it('Should generate tick marks', function() { + var scaleID = 'myScale'; + + var mockData = { + datasets: [{ + yAxisID: scaleID, + data: [10, 5, 1, 25, 78] + }, ] + }; + + var config = Chart.helpers.clone(Chart.scaleService.getScaleDefaults('logarithmic')); + var Constructor = Chart.scaleService.getScaleConstructor('logarithmic'); + var scale = new Constructor({ + ctx: {}, + options: config, + data: mockData, + id: scaleID + }); + + scale.calculateRange(); + expect(scale.ticks).toBe(undefined); // not set + + // Large enough to be unimportant + var maxWidth = 400; + var maxHeight = 400; + scale.generateTicks(maxWidth, maxHeight); + + // Counts down because the lines are drawn top to bottom + expect(scale.ticks).toEqual([100, 90, 80, 70, 60, 50, 40, 30, 20, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]); + expect(scale.start).toBe(1); + expect(scale.end).toBe(100); + }); + + it('Should generate tick marks in the correct order in reversed mode', function() { + var scaleID = 'myScale'; + + var mockData = { + datasets: [{ + yAxisID: scaleID, + data: [10, 5, 1, 25, 78] + }, ] + }; + + var config = Chart.helpers.clone(Chart.scaleService.getScaleDefaults('logarithmic')); + config.reverse = true; + + var Constructor = Chart.scaleService.getScaleConstructor('logarithmic'); + var scale = new Constructor({ + ctx: {}, + options: config, + data: mockData, + id: scaleID + }); + + scale.calculateRange(); + expect(scale.ticks).toBe(undefined); // not set + + // Large enough to be unimportant + var maxWidth = 400; + var maxHeight = 400; + scale.generateTicks(maxWidth, maxHeight); + + // Counts down because the lines are drawn top to bottom + expect(scale.ticks).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]); + expect(scale.start).toBe(100); + expect(scale.end).toBe(1); + }); + + it('Should generate tick marks using the user supplied options', function() { + var scaleID = 'myScale'; + + var mockData = { + datasets: [{ + yAxisID: scaleID, + data: [10, 5, 0, 25, 78] + }, ] + }; + + var config = Chart.helpers.clone(Chart.scaleService.getScaleDefaults('logarithmic')); + config.override = { + steps: 9, + start: 1, + stepWidth: 1 + }; + + var Constructor = Chart.scaleService.getScaleConstructor('logarithmic'); + var scale = new Constructor({ + ctx: {}, + options: config, + data: mockData, + id: scaleID + }); + + scale.calculateRange(); + + // Large enough to be unimportant + var maxWidth = 400; + var maxHeight = 400; + scale.generateTicks(maxWidth, maxHeight); + + expect(scale.ticks).toEqual([10, 9, 8, 7, 6, 5, 4, 3, 2, 1]); + expect(scale.start).toBe(1); + expect(scale.end).toBe(10); + }); + + it('Should build labels using the default template', function() { + var scaleID = 'myScale'; + + var mockData = { + datasets: [{ + yAxisID: scaleID, + data: [10, 5, 1, 25, 78] + }, ] + }; + + var config = Chart.helpers.clone(Chart.scaleService.getScaleDefaults('logarithmic')); + var Constructor = Chart.scaleService.getScaleConstructor('logarithmic'); + var scale = new Constructor({ + ctx: {}, + options: config, + data: mockData, + id: scaleID + }); + + scale.calculateRange(); + + // Large enough to be unimportant + var maxWidth = 400; + var maxHeight = 400; + scale.generateTicks(maxWidth, maxHeight); + + // Generate labels + scale.buildLabels(); + + expect(scale.labels).toEqual(['1e+2', '', '', '', '', '5e+1', '', '', '2e+1', '1e+1', '', '', '', '', '5e+0', '', '', '2e+0', '1e+0']); + }); + + it('Should build labels using the user supplied callback', function() { + var scaleID = 'myScale'; + + var mockData = { + datasets: [{ + yAxisID: scaleID, + data: [10, 5, 1, 25, 78] + }, ] + }; + + var config = Chart.helpers.clone(Chart.scaleService.getScaleDefaults('logarithmic')); + config.labels.userCallback = function(value, index) { + return index.toString(); + }; + + var Constructor = Chart.scaleService.getScaleConstructor('logarithmic'); + var scale = new Constructor({ + ctx: {}, + options: config, + data: mockData, + id: scaleID + }); + + scale.calculateRange(); + + // Large enough to be unimportant + var maxWidth = 400; + var maxHeight = 400; + scale.generateTicks(maxWidth, maxHeight); + + // Generate labels + scale.buildLabels(); + + // Just the index + expect(scale.labels).toEqual(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17', '18']); + }); + + it('Should get the correct pixel value for a point', function() { + var scaleID = 'myScale'; + + var mockData = { + datasets: [{ + xAxisID: scaleID, // for the horizontal scale + yAxisID: scaleID, + data: [10, 5, 1, 25, 78] + }] + }; + + var config = Chart.helpers.clone(Chart.scaleService.getScaleDefaults('logarithmic')); + var Constructor = Chart.scaleService.getScaleConstructor('logarithmic'); + var verticalScale = new Constructor({ + ctx: {}, + options: config, + data: mockData, + id: scaleID + }); + + verticalScale.calculateRange(); + verticalScale.generateTicks(50, 100); + + // Fake out positioning of the scale service + verticalScale.left = 0; + verticalScale.top = 0; + verticalScale.right = 50; + verticalScale.bottom = 110; + verticalScale.paddingTop = 5; + verticalScale.paddingBottom = 5; + verticalScale.width = 50; + verticalScale.height = 110; + + expect(verticalScale.getPointPixelForValue(100, 0, 0)).toBe(5); // top + paddingTop + expect(verticalScale.getPointPixelForValue(1, 0, 0)).toBe(105); // bottom - paddingBottom + expect(verticalScale.getPointPixelForValue(10, 0, 0)).toBe(55); // halfway + + var horizontalConfig = Chart.helpers.clone(config); + horizontalConfig.position = 'bottom'; + var horizontalScale = new Constructor({ + ctx: {}, + options: horizontalConfig, + data: mockData, + id: scaleID, + }); + + horizontalScale.calculateRange(); + horizontalScale.generateTicks(100, 50); + + // Fake out positioning of the scale service + horizontalScale.left = 0; + horizontalScale.top = 0; + horizontalScale.right = 110; + horizontalScale.bottom = 50; + horizontalScale.paddingLeft = 5; + horizontalScale.paddingRight = 5; + horizontalScale.width = 110; + horizontalScale.height = 50; + + // Range expands to [-2, 2] due to nicenum algorithm + expect(horizontalScale.getPointPixelForValue(100, 0, 0)).toBe(105); // right - paddingRight + expect(horizontalScale.getPointPixelForValue(1, 0, 0)).toBe(5); // left + paddingLeft + expect(horizontalScale.getPointPixelForValue(10, 0, 0)).toBe(55); // halfway + }); + + it('should get the correct pixel value for a bar', function() { + var scaleID = 'myScale'; + + var mockData = { + datasets: [{ + xAxisID: scaleID, // for the horizontal scale + yAxisID: scaleID, + data: [10, 5, 1, 25, 78] + }] + }; + + var config = Chart.helpers.clone(Chart.scaleService.getScaleDefaults('logarithmic')); + var Constructor = Chart.scaleService.getScaleConstructor('logarithmic'); + var verticalScale = new Constructor({ + ctx: {}, + options: config, + data: mockData, + id: scaleID + }); + + verticalScale.calculateRange(); + verticalScale.generateTicks(50, 100); + + // Fake out positioning of the scale service + verticalScale.left = 0; + verticalScale.top = 0; + verticalScale.right = 50; + verticalScale.bottom = 110; + verticalScale.paddingTop = 5; + verticalScale.paddingBottom = 5; + verticalScale.width = 50; + verticalScale.height = 110; + + expect(verticalScale.calculateBarBase()).toBe(105); // bottom + expect(verticalScale.calculateBarY(0, 3)).toBe(35.10299956639811); // bottom + }); + + it('should fit correctly', function() { + var scaleID = 'myScale'; + + var mockData = { + datasets: [{ + xAxisID: scaleID, // for the horizontal scale + yAxisID: scaleID, + data: [10, 5, 1, 25, 78] + }] + }; + var mockContext = window.createMockContext(); + + var config = Chart.helpers.clone(Chart.scaleService.getScaleDefaults('logarithmic')); + var Constructor = Chart.scaleService.getScaleConstructor('logarithmic'); + var verticalScale = new Constructor({ + ctx: mockContext, + options: config, + data: mockData, + id: scaleID + }); + + var minSize = verticalScale.fit(100, 300); + expect(minSize).toEqual({ + width: 53, + height: 300, + }); + expect(verticalScale.width).toBe(53); + expect(verticalScale.height).toBe(300); + expect(verticalScale.paddingTop).toBe(6); + expect(verticalScale.paddingBottom).toBe(6); + expect(verticalScale.paddingLeft).toBe(0); + expect(verticalScale.paddingRight).toBe(0); + + // Refit with margins to see the padding go away + minSize = verticalScale.fit(53, 300, { + left: 0, + right: 0, + top: 15, + bottom: 3 + }); + expect(minSize).toEqual({ + width: 53, + height: 300, + }); + expect(verticalScale.paddingTop).toBe(0); + expect(verticalScale.paddingBottom).toBe(3); + expect(verticalScale.paddingLeft).toBe(0); + expect(verticalScale.paddingRight).toBe(0); + }); + + it('should fit correctly when horizontal', function() { + var scaleID = 'myScale'; + + var mockData = { + datasets: [{ + xAxisID: scaleID, // for the horizontal scale + yAxisID: scaleID, + data: [10, 5, 1, 25, 78] + }] + }; + var mockContext = window.createMockContext(); + + var config = Chart.helpers.clone(Chart.scaleService.getScaleDefaults('logarithmic')); + config.position = "bottom"; + var Constructor = Chart.scaleService.getScaleConstructor('logarithmic'); + var verticalScale = new Constructor({ + ctx: mockContext, + options: config, + data: mockData, + id: scaleID + }); + + var minSize = verticalScale.fit(100, 300); + expect(minSize).toEqual({ + width: 100, + height: 28, + }); + expect(verticalScale.width).toBe(100); + expect(verticalScale.height).toBe(28); + expect(verticalScale.paddingTop).toBe(0); + expect(verticalScale.paddingBottom).toBe(0); + expect(verticalScale.paddingLeft).toBe(20); + expect(verticalScale.paddingRight).toBe(20); + + // Refit with margins to see the padding go away + minSize = verticalScale.fit(100, 28, { + left: 10, + right: 6, + top: 15, + bottom: 3 + }); + expect(minSize).toEqual({ + width: 100, + height: 28, + }); + expect(verticalScale.paddingTop).toBe(0); + expect(verticalScale.paddingBottom).toBe(0); + expect(verticalScale.paddingLeft).toBe(10); + expect(verticalScale.paddingRight).toBe(14); + }); + + it('should draw correctly horizontally', function() { + var scaleID = 'myScale'; + + var mockData = { + datasets: [{ + xAxisID: scaleID, // for the horizontal scale + yAxisID: scaleID, + data: [10, 5, 1, 25, 78] + }] + }; + var mockContext = window.createMockContext(); + + var config = Chart.helpers.clone(Chart.scaleService.getScaleDefaults('logarithmic')); + config.position = "bottom"; + var Constructor = Chart.scaleService.getScaleConstructor('logarithmic'); + var horizontalScale = new Constructor({ + ctx: mockContext, + options: config, + data: mockData, + id: scaleID + }); + + var minSize = horizontalScale.fit(100, 300); + minSize = horizontalScale.fit(100, 28, { + left: 10, + right: 6, + top: 15, + bottom: 3 + }); + + horizontalScale.left = 0; + horizontalScale.right = minSize.width; + horizontalScale.top = 0; + horizontalScale.bottom = minSize.height; + + var chartArea = { + top: 100, + bottom: 0, + left: 0, + right: minSize.width + }; + horizontalScale.draw(chartArea); + + expect(mockContext.getCalls()).toEqual([{ + "name": "measureText", + "args": ["1e+0"] + }, { + "name": "measureText", + "args": ["1e+2"] + }, { + "name": "measureText", + "args": ["1e+0"] + }, { + "name": "measureText", + "args": ["1e+2"] + }, { + "name": "setFillStyle", + "args": ["#666"] + }, { + "name": "setLineWidth", + "args": [1] + }, { + "name": "setStrokeStyle", + "args": ["rgba(0,0,0,0.25)"] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [10.5, 0] + }, { + "name": "lineTo", + "args": [10.5, 5] + }, { + "name": "moveTo", + "args": [10.5, 100] + }, { + "name": "lineTo", + "args": [10.5, 0] + }, { + "name": "stroke", + "args": [] + }, { + "name": "setLineWidth", + "args": [1] + }, { + "name": "setStrokeStyle", + "args": ["rgba(0, 0, 0, 0.1)"] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [21.939139835231288, 0] + }, { + "name": "lineTo", + "args": [21.939139835231288, 5] + }, { + "name": "moveTo", + "args": [21.939139835231288, 100] + }, { + "name": "lineTo", + "args": [21.939139835231288, 0] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [28.63060767934717, 0] + }, { + "name": "lineTo", + "args": [28.63060767934717, 5] + }, { + "name": "moveTo", + "args": [28.63060767934717, 100] + }, { + "name": "lineTo", + "args": [28.63060767934717, 0] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [33.378279670462575, 0] + }, { + "name": "lineTo", + "args": [33.378279670462575, 5] + }, { + "name": "moveTo", + "args": [33.378279670462575, 100] + }, { + "name": "lineTo", + "args": [33.378279670462575, 0] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [37.06086016476871, 0] + }, { + "name": "lineTo", + "args": [37.06086016476871, 5] + }, { + "name": "moveTo", + "args": [37.06086016476871, 100] + }, { + "name": "lineTo", + "args": [37.06086016476871, 0] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [40.06974751457846, 0] + }, { + "name": "lineTo", + "args": [40.06974751457846, 5] + }, { + "name": "moveTo", + "args": [40.06974751457846, 100] + }, { + "name": "lineTo", + "args": [40.06974751457846, 0] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [42.613725520541756, 0] + }, { + "name": "lineTo", + "args": [42.613725520541756, 5] + }, { + "name": "moveTo", + "args": [42.613725520541756, 100] + }, { + "name": "lineTo", + "args": [42.613725520541756, 0] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [44.817419505693856, 0] + }, { + "name": "lineTo", + "args": [44.817419505693856, 5] + }, { + "name": "moveTo", + "args": [44.817419505693856, 100] + }, { + "name": "lineTo", + "args": [44.817419505693856, 0] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [46.76121535869434, 0] + }, { + "name": "lineTo", + "args": [46.76121535869434, 5] + }, { + "name": "moveTo", + "args": [46.76121535869434, 100] + }, { + "name": "lineTo", + "args": [46.76121535869434, 0] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [48.5, 0] + }, { + "name": "lineTo", + "args": [48.5, 5] + }, { + "name": "moveTo", + "args": [48.5, 100] + }, { + "name": "lineTo", + "args": [48.5, 0] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [59.93913983523129, 0] + }, { + "name": "lineTo", + "args": [59.93913983523129, 5] + }, { + "name": "moveTo", + "args": [59.93913983523129, 100] + }, { + "name": "lineTo", + "args": [59.93913983523129, 0] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [66.63060767934718, 0] + }, { + "name": "lineTo", + "args": [66.63060767934718, 5] + }, { + "name": "moveTo", + "args": [66.63060767934718, 100] + }, { + "name": "lineTo", + "args": [66.63060767934718, 0] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [71.37827967046258, 0] + }, { + "name": "lineTo", + "args": [71.37827967046258, 5] + }, { + "name": "moveTo", + "args": [71.37827967046258, 100] + }, { + "name": "lineTo", + "args": [71.37827967046258, 0] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [75.06086016476871, 0] + }, { + "name": "lineTo", + "args": [75.06086016476871, 5] + }, { + "name": "moveTo", + "args": [75.06086016476871, 100] + }, { + "name": "lineTo", + "args": [75.06086016476871, 0] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [78.06974751457845, 0] + }, { + "name": "lineTo", + "args": [78.06974751457845, 5] + }, { + "name": "moveTo", + "args": [78.06974751457845, 100] + }, { + "name": "lineTo", + "args": [78.06974751457845, 0] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [80.61372552054176, 0] + }, { + "name": "lineTo", + "args": [80.61372552054176, 5] + }, { + "name": "moveTo", + "args": [80.61372552054176, 100] + }, { + "name": "lineTo", + "args": [80.61372552054176, 0] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [82.81741950569385, 0] + }, { + "name": "lineTo", + "args": [82.81741950569385, 5] + }, { + "name": "moveTo", + "args": [82.81741950569385, 100] + }, { + "name": "lineTo", + "args": [82.81741950569385, 0] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [84.76121535869434, 0] + }, { + "name": "lineTo", + "args": [84.76121535869434, 5] + }, { + "name": "moveTo", + "args": [84.76121535869434, 100] + }, { + "name": "lineTo", + "args": [84.76121535869434, 0] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [86.5, 0] + }, { + "name": "lineTo", + "args": [86.5, 5] + }, { + "name": "moveTo", + "args": [86.5, 100] + }, { + "name": "lineTo", + "args": [86.5, 0] + }, { + "name": "stroke", + "args": [] + }, { + "name": "fillText", + "args": ["1e+0", 10, 10] + }, { + "name": "fillText", + "args": ["2e+0", 21.439139835231288, 10] + }, { + "name": "fillText", + "args": ["5e+0", 36.56086016476871, 10] + }, { + "name": "fillText", + "args": ["1e+1", 48, 10] + }, { + "name": "fillText", + "args": ["2e+1", 59.43913983523129, 10] + }, { + "name": "fillText", + "args": ["5e+1", 74.56086016476871, 10] + }, { + "name": "fillText", + "args": ["1e+2", 86, 10] + }]); + + // Turn off some drawing + config.gridLines.drawTicks = false; + config.gridLines.drawOnChartArea = false; + config.labels.show = false; + + mockContext.resetCalls(); + + horizontalScale.draw(); + expect(mockContext.getCalls()).toEqual([{ + "name": "setFillStyle", + "args": ["#666"] + }, { + "name": "setLineWidth", + "args": [1] + }, { + "name": "setStrokeStyle", + "args": ["rgba(0,0,0,0.25)"] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "setLineWidth", + "args": [1] + }, { + "name": "setStrokeStyle", + "args": ["rgba(0, 0, 0, 0.1)"] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }]); + + // Turn off display + + mockContext.resetCalls(); + config.display = false; + horizontalScale.draw(); + expect(mockContext.getCalls()).toEqual([]); + }); + + it('should draw correctly vertically', function() { + var scaleID = 'myScale'; + + var mockData = { + datasets: [{ + xAxisID: scaleID, // for the horizontal scale + yAxisID: scaleID, + data: [10, 5, 1, 2.5, 7.8] + }] + }; + var mockContext = window.createMockContext(); + + var config = Chart.helpers.clone(Chart.scaleService.getScaleDefaults('logarithmic')); + var Constructor = Chart.scaleService.getScaleConstructor('logarithmic'); + var verticalScale = new Constructor({ + ctx: mockContext, + options: config, + data: mockData, + id: scaleID + }); + + var minSize = verticalScale.fit(100, 300); + minSize = verticalScale.fit(33, 300, { + left: 0, + right: 0, + top: 15, + bottom: 3 + }); + expect(minSize).toEqual({ + width: 33, + height: 300, + }); + + verticalScale.left = 0; + verticalScale.right = minSize.width; + verticalScale.top = 0; + verticalScale.bottom = minSize.height; + + var chartArea = { + top: 0, + bottom: minSize.height, + left: minSize.width, + right: minSize.width + 100 + }; + verticalScale.draw(chartArea); + + var expected = [{ + "name": "measureText", + "args": ["1e+1"] + }, { + "name": "measureText", + "args": [""] + }, { + "name": "measureText", + "args": [""] + }, { + "name": "measureText", + "args": [""] + }, { + "name": "measureText", + "args": [""] + }, { + "name": "measureText", + "args": ["5e+0"] + }, { + "name": "measureText", + "args": [""] + }, { + "name": "measureText", + "args": [""] + }, { + "name": "measureText", + "args": ["2e+0"] + }, { + "name": "measureText", + "args": ["1e+0"] + }, { + "name": "measureText", + "args": ["1e+1"] + }, { + "name": "measureText", + "args": [""] + }, { + "name": "measureText", + "args": [""] + }, { + "name": "measureText", + "args": [""] + }, { + "name": "measureText", + "args": [""] + }, { + "name": "measureText", + "args": ["5e+0"] + }, { + "name": "measureText", + "args": [""] + }, { + "name": "measureText", + "args": [""] + }, { + "name": "measureText", + "args": ["2e+0"] + }, { + "name": "measureText", + "args": ["1e+0"] + }, { + "name": "setFillStyle", + "args": ["#666"] + }, { + "name": "setLineWidth", + "args": [1] + }, { + "name": "setStrokeStyle", + "args": ["rgba(0,0,0,0.25)"] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [28, 0.5] + }, { + "name": "lineTo", + "args": [33, 0.5] + }, { + "name": "moveTo", + "args": [33, 0.5] + }, { + "name": "lineTo", + "args": [133, 0.5] + }, { + "name": "stroke", + "args": [] + }, { + "name": "setLineWidth", + "args": [1] + }, { + "name": "setStrokeStyle", + "args": ["rgba(0, 0, 0, 0.1)"] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [28, 14.089974696520528] + }, { + "name": "lineTo", + "args": [33, 14.089974696520528] + }, { + "name": "moveTo", + "args": [33, 14.089974696520528] + }, { + "name": "lineTo", + "args": [133, 14.089974696520528] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [28, 29.28227386339279] + }, { + "name": "lineTo", + "args": [33, 29.28227386339279] + }, { + "name": "moveTo", + "args": [33, 29.28227386339279] + }, { + "name": "lineTo", + "args": [133, 29.28227386339279] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [28, 46.50588211576573] + }, { + "name": "lineTo", + "args": [33, 46.50588211576573] + }, { + "name": "moveTo", + "args": [33, 46.50588211576573] + }, { + "name": "lineTo", + "args": [133, 46.50588211576573] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [28, 66.38907863605783] + }, { + "name": "lineTo", + "args": [33, 66.38907863605783] + }, { + "name": "moveTo", + "args": [33, 66.38907863605783] + }, { + "name": "lineTo", + "args": [133, 66.38907863605783] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [28, 89.9059087122024] + }, { + "name": "lineTo", + "args": [33, 89.9059087122024] + }, { + "name": "moveTo", + "args": [33, 89.9059087122024] + }, { + "name": "lineTo", + "args": [133, 89.9059087122024] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [28, 118.68818257559516] + }, { + "name": "lineTo", + "args": [33, 118.68818257559516] + }, { + "name": "moveTo", + "args": [33, 118.68818257559516] + }, { + "name": "lineTo", + "args": [133, 118.68818257559516] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [28, 155.79498734826026] + }, { + "name": "lineTo", + "args": [33, 155.79498734826026] + }, { + "name": "moveTo", + "args": [33, 155.79498734826026] + }, { + "name": "lineTo", + "args": [133, 155.79498734826026] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [28, 208.0940912877976] + }, { + "name": "lineTo", + "args": [33, 208.0940912877976] + }, { + "name": "moveTo", + "args": [33, 208.0940912877976] + }, { + "name": "lineTo", + "args": [133, 208.0940912877976] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "moveTo", + "args": [28, 297.5] + }, { + "name": "lineTo", + "args": [33, 297.5] + }, { + "name": "moveTo", + "args": [33, 297.5] + }, { + "name": "lineTo", + "args": [133, 297.5] + }, { + "name": "stroke", + "args": [] + }, { + "name": "fillText", + "args": ["1e+1", 23, 0] + }, { + "name": "fillText", + "args": ["", 23, 13.589974696520528] + }, { + "name": "fillText", + "args": ["", 23, 28.78227386339279] + }, { + "name": "fillText", + "args": ["", 23, 46.00588211576573] + }, { + "name": "fillText", + "args": ["", 23, 65.88907863605783] + }, { + "name": "fillText", + "args": ["5e+0", 23, 89.4059087122024] + }, { + "name": "fillText", + "args": ["", 23, 118.18818257559516] + }, { + "name": "fillText", + "args": ["", 23, 155.29498734826026] + }, { + "name": "fillText", + "args": ["2e+0", 23, 207.5940912877976] + }, { + "name": "fillText", + "args": ["1e+0", 23, 297] + }]; + expect(mockContext.getCalls()).toEqual(expected); + + // Turn off some drawing + config.gridLines.drawTicks = false; + config.gridLines.drawOnChartArea = false; + config.labels.show = false; + + mockContext.resetCalls(); + + verticalScale.draw(); + expect(mockContext.getCalls()).toEqual([{ + "name": "setFillStyle", + "args": ["#666"] + }, { + "name": "setLineWidth", + "args": [1] + }, { + "name": "setStrokeStyle", + "args": ["rgba(0,0,0,0.25)"] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "setLineWidth", + "args": [1] + }, { + "name": "setStrokeStyle", + "args": ["rgba(0, 0, 0, 0.1)"] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }, { + "name": "beginPath", + "args": [] + }, { + "name": "stroke", + "args": [] + }]); + }); +}); \ No newline at end of file