From: Evert Timberg Date: Sat, 16 May 2015 23:40:52 +0000 (-0400) Subject: Initial check in of the scale service and the scale constructor registration. An... X-Git-Tag: v2.0-alpha~8^2~27 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=db1365c8cec4a682690898f8c0565df486aa6165;p=thirdparty%2FChart.js.git Initial check in of the scale service and the scale constructor registration. An initial implementation of a linear scale that can be drawn in both horizontal and vertical orientations. --- diff --git a/src/Chart.Scale.js b/src/Chart.Scale.js new file mode 100644 index 000000000..3a2495cbe --- /dev/null +++ b/src/Chart.Scale.js @@ -0,0 +1,655 @@ +(function() { + "use strict"; + + var root = this, + Chart = root.Chart, + helpers = Chart.helpers; + + // The scale service is used to resize charts along with all of their axes. We make this as + // a service where scales are registered with their respective charts so that changing the + // scales does not require + Chart.scaleService = { + registeredCharts: [], + getWrapperForChart: function(chartInstance) { + var wrapper = helpers.findNextWhere(this.registeredCharts, function(charScaleWrapper) { + return charScaleWrapper.chartInstance == chartInstance; + }); + + return wrapper; + }, + registerChartScale: function(chartInstance, scaleInstance) { + var chartScaleWrapper = this.getWrapperForChart(chartInstance); + + if (!chartScaleWrapper) { + chartScaleWrapper = { + scales: [], + chartInstance: chartInstance, + }; + + this.registeredCharts.push(chartScaleWrapper); + } + + chartScaleWrapper.scales.push(scaleInstance); + }, + removeChartScale: function(chartInstance, scaleInstance) { + var chartScaleWrapper = this.getWrapperForChart(chartInstance); + + if (chartScaleWrapper) { + var scaleIndex = helpers.indexOf(scaleWrapper.scales, scaleInstance); + + if (scaleIndex) { + scaleWrapper.scales.splice(scaleIndex, 1); + } + } + }, + // Remove a chart instance from the scale service. Useful when a chart is destroyed + removeChartInstance: function(chartInstance) { + var index = helpers.findNextWhere(this.registeredCharts, function(scaleWrapper) { + return scaleWrapper.chartInstance == chartInstance; + }); + + if (index) { + this.registeredCharts.splice(index, 1); + } + }, + // The interesting function + fitScalesForChart: function(chartInstance, width, height) { + var chartScaleWrapper = this.getWrapperForChart(chartInstance); + + if (chartScaleWrapper) { + var leftScales = helpers.where(chartScaleWrapper.scales, function(scaleInstance) { + return scaleInstance.options.position == "left"; + }); + var rightScales = helpers.where(chartScaleWrapper.scales, function(scaleInstance) { + return scaleInstance.options.position == "right"; + }); + var topScales = helpers.where(chartScaleWrapper.scales, function(scaleInstance) { + return scaleInstance.options.position == "top"; + }); + var bottomScales = helpers.where(chartScaleWrapper.scales, function(scaleInstance) { + return scaleInstance.options.position == "bottom"; + }); + + // Essentially we now have any number of scales on each of the 4 sides. + // Our canvas looks like the following. + // The areas L1 and L2 are the left axes. R1 is the right axis, T1 is the top axis and + // B1 is the bottom axis + // |------------------------------------------------------| + // | | T1 | | + // |----|-----|-------------------------------------|-----| + // | | | | | + // | L1 | L2 | Chart area | R1 | + // | | | | | + // | | | | | + // |----|-----|-------------------------------------|-----| + // | | B1 | | + // | | | | + // |------------------------------------------------------| + + // What we do to find the best sizing, we do the following + // 1. Determine the minimum size of the chart area. + // 2. Split the remaining width equally between each vertical axis + // 3. Split the remaining height equally between each horizontal axis + // 4. Give each scale the maximum size it can be. The scale will return it's minimum size + // 5. Adjust the sizes of each axis based on it's minimum reported size. + // 6. Refit each axis + // 7. Position each axis in the final location + // 8. Tell the chart the final location of the chart area + + // Step 1 + var chartWidth = width / 2; // min 50% + var chartHeight = height / 2; // min 50% + var aspectRatio = chartHeight / chartWidth; + var screenAspectRatio; + + if (chartInstance.options.maintainAspectRatio) { + screenAspectRatio = height / width; + + if (aspectRatio != screenAspectRatio) { + chartHeight = chartWidth * screenAspectRatio; + aspectRatio = screenAspectRatio; + } + } + + // Step 2 + var verticalScaleWidth = (width - chartWidth) / (leftScales.length + rightScales.length); + + // Step 3 + var horizontalScaleHeight = (height - chartHeight) / (topScales.length + bottomScales.length); + + // Step 4; + var scalesToMinSize = {}; + + var verticalScaleMinSizeFunction = function(scaleInstance) { + var minSize = scaleInstance.fit(verticalScaleWidth, chartHeight); + scalesToMinSize[scaleInstance] = minSize; + }; + + var horizontalScaleMinSizeFunction = function(scaleInstance) { + var minSize = scaleInstance.fit(chartWidth, horizontalScaleHeight); + scalesToMinSize[scaleInstance] = minSize; + }; + + // vertical scales + helpers.each(leftScales, verticalScaleMinSizeFunction); + helpers.each(rightScales, verticalScaleMinSizeFunction); + + // horizontal scales + helpers.each(topScales, horizontalScaleMinSizeFunction); + helpers.each(bottomScales, horizontalScaleMinSizeFunction); + + // Step 5 + var maxChartHeight = height; + var maxChartWidth = width; + + var chartWidthReduceFunction = function(scaleInstance) { + maxChartWidth -= scalesToMinSize[scaleInstance].width; + }; + + var chartHeightReduceFunction = function(scaleInstance) { + maxChartHeight -= scalesToMinSize[scaleInstance].height; + }; + + helpers.each(leftScales, chartWidthReduceFunction); + helpers.each(rightScales, chartWidthReduceFunction); + helpers.each(topScales, chartHeightReduceFunction); + helpers.each(bottomScales, chartHeightReduceFunction); + + // At this point, maxChartHeight and maxChartWidth are the size the chart area could + // be if the axes are drawn at their minimum sizes. + if (chartInstance.options.maintainAspectRatio) { + // Figure out what the real max size will be + var maxAspectRatio = maxChartHeight / maxChartWidth; + + if (maxAspectRatio != screenAspectRatio) { + // Need to adjust + if (maxChartHeight < maxChartWidth) { + maxChartWidth = maxChartHeight / screenAspectRatio; + } + else { + maxChartHeight = maxChartWidth * screenAspectRatio; + } + } + } + + // Step 6 + var verticalScaleFitFunction = function(scaleInstance) { + var minSize = scalesToMinSize[scaleInstance]; + scaleInstance.fit(minSize.width, maxChartHeight); + }; + + var horizontalScaleFitFunction = function(scaleInstance) { + var minSize = scalesToMinSize[scaleInstance]; + scaleInstance.fit(maxChartWidth, minSize.width); + }; + + helpers.each(leftScales, verticalScaleFitFunction); + helpers.each(rightScales, verticalScaleFitFunction); + helpers.each(topScales, horizontalScaleFitFunction); + helpers.each(bottomScales, horizontalScaleFitFunction); + + // Step 7 + var totalLeftWidth = 0; + var totalTopHeight = 0; + + // Calculate total width of all left axes + helpers.each(leftScales, function(scaleInstance) { + totalLeftWidth += scaleInstance.width; + }); + + // Calculate total height of all top axes + helpers.each(topScales, function(scaleInstance) { + totalTopHeight += scaleInstance.height; + }); + + // Position the scales + var left = 0; + var top = 0; + var right = 0; + var bottom = 0; + + var verticalScalePlacer = function(scaleInstance) { + scaleInstance.left = left; + scaleInstance.right = left + scaleInstance.width; + scaleInstance.top = totalTopHeight; + scaleInstance.bottom = totalTopHeight + maxChartHeight; + + // Move to next point + left = scaleInstance.right; + }; + + var horizontalScalePlacer = function(scaleInstance) { + scaleInstance.left = totalLeftWidth; + scaleInstance.right = totalLeftWidth + maxChartWidth; + scaleInstance.top = top; + scaleInstance.bottom = top + scaleInstance.height; + + // Move to next point + top = scaleInstance.bottom; + }; + + helpers.each(leftScales, verticalScalePlacer); + helpers.each(topScales, horizontalScalePlacer); + + // Account for chart width and height + left += maxChartWidth; + top += maxChartHeight; + + helpers.each(rightScales, verticalScalePlacer); + helpers.each(bottomScales, horizontalScalePlacer); + + // Step 8 + chartScaleWrapper.chartInstance.chartArea = { + left: totalLeftWidth, + top: totalTopHeight, + right: totalLeftWidth + maxChartWidth, + bottom: totalTopHeight + maxChartHeight, + }; + } + } + }; + + // Scale registration object. Extensions can register new scale types (such as log or DB scales) and then + // use the new chart options to grab the correct scale + Chart.scales = { + constructors: {}, + // Use a registration function so that we can move to an ES6 map when we no longer need to support + // old browsers + registerScaleType: function(scaleType, scaleConstructor) { + this.constructors[scaleType] = scaleConstructor; + }, + getScaleConstructor: function(scaleType) { + return this.constructors.hasOwnProperty(scaleType) ? this.constructors[scaleType] : undefined; + } + }; + + var LinearScale = Chart.Element.extend({ + calculateRange: helpers.noop, // overridden in the chart. Will set min and max as properties of the scale for later use + 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); + 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 maxTicks; + + if (this.isHorizontal()) { + maxTicks = Math.min(11, Math.ceil(width / 50)); + } else { + maxTicks = Math.min(11, Math.ceil(height / 50)); + } + + // To get a "nice" value for the tick spacing, we will use the appropriately named + // "nice number" algorithm. See http://stackoverflow.com/questions/8506881/nice-label-algorithm-for-charts-with-minimum-ticks + // for details. + + // If we are forcing it to begin at 0, but 0 will already be rendered on the chart, + // do nothing since that would make the chart weird. If the user really wants a weird chart + // axis, they can manually override it + if (this.options.beginAtZero) { + this.min = Math.min(this.min, 0); + } + + var niceRange = helpers.niceNum(this.max - this.min, false); + var spacing = helpers.niceNum(niceRange / (maxTicks - 1), true); + var niceMin = Math.floor(this.min / spacing) * spacing; + var niceMax = Math.ceil(this.max / spacing) * spacing; + + // Put the values into the ticks array + for (var j = niceMin; j <= niceMax; j += spacing) { + this.ticks.push(j); + } + } + + 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(); + } + }, + 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.labelCallback) { + // If the user provided a callback for label generation, use that as first priority + label = this.options.labelCallback(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 ? label : ""); // empty string will not render so we're good + }, this); + }, + 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 = this.max - this.min; + + if (this.isHorizontal()) { + pixel = this.left + (this.width / range * (value - this.min)); + } else { + // Bottom - top since pixels increase downard on a screen + pixel = this.bottom - (this.height / range * (value - this.min)); + } + + return pixel; + }, + // 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) { + this.calculateRange(); + this.generateTicks(maxWidth, maxHeight); + this.buildLabels(); + + var minSize = { + width: 0, + height: 0, + }; + + if (this.isHorizontal()) { + minSize.width = maxWidth; // fill all the width + + // In a horizontal axis, we need some room for the scale to be drawn + // + // ----------------------------------------------------- + // | | | | | + // + minSize.height = this.options.gridLines.show ? 25 : 0; + } else { + minSize.height = maxHeight; // fill all the height + + // 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 + // | + // -| + // | + // | + // -| + // | + // | + // -| + minSize.width = this.options.gridLines.show ? 25 : 0; + } + + if (this.options.labels.show) { + // 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; + + // Calculate the label rotation + var labelHeight = this.calculateLabelRotation(minSize.width, maxLabelHeight); + minSize.height = Math.min(maxHeight, minSize.height + labelHeight); + } 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; + } else { + // Expand to max size + minSize.width = maxWidth; + } + } + } + + this.width = minSize.width; + this.height = minSize.height; + return minSize; + }, + // Function calculate the needed rotation of the labels. Should only be used in horizontal mode + // @param {number} width : the available width + // @param {number} height: the available height + // @return {number} : the height needed by the labels + calculateLabelRotation : function(width, height){ + //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.labelRotation = 0; // reset + + // Steps + // 1. determine if we need to overlap + // 2. if overlap, determine max rotation + // 3. Rotate until no overlap + // 4. Save rotation + // 5. Return height needed for rotation + var longestTextWidth = helpers.longestText(this.ctx, labelFont, this.lables); + var maxAvailableWidth = (width / (this.ticks.length - 1)) - 6; + + // 6 adds 3px of padding on each end of the label + if (longestTextWidth > maxAvailableWidth) { + // Ok, we need to rotate. Do steps 2-4 + var idealRotation = Math.floor(helpers.toDegrees(Math.asin(height / longestTextWidth))); + var maxRotation = Math.min(90, idealRotation); + + // Increment the rotation in 1 degree steps (step 3) + for (var rotation = 1; rotation < maxRotation; ++rotation) { + var cosRotation = Math.cos(helpers.toRadians(rotation)); + this.labelRotation = rotation; // step 4 + + if (cosRotation * longestTextWidth <= maxAvailableWidth) { + // Rotated enough + break; + } + } + + // step 5 + return Math.min(height, longestTextWidth * Math.sin(this.labelRotation)); + } else { + // Height only constrained by text font size and padding + var idealHeight = this.options.labels.fontSize + 10; // add 10 for padding + return Math.min(height, idealHeight); + } + }, + + // 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.show) { + + var setContextLineSettings; + var hasZero; + + 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 - 10; + var yTickEnd = this.options.position == "bottom" ? this.top + 10 : this.bottom; + + this.ctx.lineWidth = this.options.gridLines.zeroLineWidth; + this.ctx.strokeStyle = this.options.gridLines.zeroLineColor; + + this.ctx.beginPath(); + + if (this.options.position == "top") { + this.ctx.moveTo(this.left, this.bottom - 5); + this.ctx.lineTo(this.right, this.bottom - 5); + } else { + // On bottom, so draw horizontal line on the top + this.ctx.moveTo(this.left, this.top + 5); + this.ctx.lineTo(this.right, this.top + 5); + } + + helpers.each(this.ticks, function(tick, index) { + // Grid lines are vertical + var xValue = this.getPixelForValue(tick); + + 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.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); + } + }, this); + + this.ctx.stroke(); + } + + if (this.options.labels.show) { + // Draw the labels + + var labelStartY; + + if (this.options.position == "top") { + labelStartY = this.top; + } else { + // bottom side + labelStartY = this.top + 20; + } + + this.ctx.textAlign = "center"; + this.ctx.textBaseline = "top"; + 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]); + 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 == "left" ? this.left : this.right - 10; + var xTickEnd = this.options.position == "left" ? this.left + 10 : this.right; + + this.ctx.lineWidth = this.options.gridLines.zeroLineWidth; + this.ctx.strokeStyle = this.options.gridLines.zeroLineColor; + + this.ctx.beginPath(); + + if (this.options.position == "left") { + this.ctx.moveTo(this.right - 5, this.top); + this.ctx.lineTo(this.right - 5, this.bottom); + } else { + // On right, so draw vertical line on left size of axis block + this.ctx.moveTo(this.left + 5, this.top); + this.ctx.lineTo(this.left + 5, this.bottom); + } + + 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; + } + + // Draw the label area + 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); + + this.ctx.stroke(); + } + + if (this.options.labels.show) { + // Draw the labels + + var labelStartX; + var maxLabelWidth = this.width - 25; + + if (this.options.position == "left") { + labelStartX = this.left; + } else { + // right side + labelStartX = this.left + 20; + } + + 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, maxLabelWidth); + }, this); + } + } + } + } + }); + Chart.scales.registerScaleType("linear", LinearScale); +}).call(this); \ No newline at end of file