From 67b3d32218e1a27d59d13f8fd4eb88282b59e1ac Mon Sep 17 00:00:00 2001 From: Evert Timberg Date: Sun, 17 May 2015 13:20:37 -0400 Subject: [PATCH] Initial implementation of a scatter chart. Uses a new style of config from the other charts. For now, the config is not changeable. --- src/Chart.Scale.js | 53 ++++- src/Chart.Scatter.js | 490 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 536 insertions(+), 7 deletions(-) create mode 100644 src/Chart.Scatter.js diff --git a/src/Chart.Scale.js b/src/Chart.Scale.js index 3a2495cbe..dcaaefe2c 100644 --- a/src/Chart.Scale.js +++ b/src/Chart.Scale.js @@ -55,6 +55,8 @@ // The interesting function fitScalesForChart: function(chartInstance, width, height) { var chartScaleWrapper = this.getWrapperForChart(chartInstance); + var xPadding = 10; + var yPadding = 10; if (chartScaleWrapper) { var leftScales = helpers.where(chartScaleWrapper.scales, function(scaleInstance) { @@ -70,6 +72,31 @@ return scaleInstance.options.position == "bottom"; }); + // Adjust the padding to take into account displaying labels + if (topScales.length == 0 || bottomScales.length == 0) { + var maxFontHeight = 0; + + var maxFontHeightFunction = function(scaleInstance) { + if (scaleInstance.options.labels.show) { + // Only consider font sizes for axes that actually show labels + maxFontHeight = Math.max(maxFontHeight, scaleInstance.options.labels.fontSize); + } + }; + + helpers.each(leftScales, maxFontHeightFunction); + helpers.each(rightScales, maxFontHeightFunction); + + if (topScales.length == 0) { + // Add padding so that we can handle drawing the top nicely + yPadding += 0.75 * maxFontHeight; // 0.75 since padding added on both sides + } + + if (bottomScales.length == 0) { + // Add padding so that we can handle drawing the bottom nicely + yPadding += 1.5 * maxFontHeight; + } + } + // 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 @@ -111,6 +138,9 @@ } } + chartWidth -= (2 * xPadding); + chartHeight-= (2 * yPadding); + // Step 2 var verticalScaleWidth = (width - chartWidth) / (leftScales.length + rightScales.length); @@ -139,8 +169,8 @@ helpers.each(bottomScales, horizontalScaleMinSizeFunction); // Step 5 - var maxChartHeight = height; - var maxChartWidth = width; + var maxChartHeight = height - (2 * yPadding); + var maxChartWidth = width - (2 * xPadding); var chartWidthReduceFunction = function(scaleInstance) { maxChartWidth -= scalesToMinSize[scaleInstance].width; @@ -189,8 +219,8 @@ helpers.each(bottomScales, horizontalScaleFitFunction); // Step 7 - var totalLeftWidth = 0; - var totalTopHeight = 0; + var totalLeftWidth = xPadding; + var totalTopHeight = yPadding; // Calculate total width of all left axes helpers.each(leftScales, function(scaleInstance) { @@ -203,8 +233,8 @@ }); // Position the scales - var left = 0; - var top = 0; + var left = xPadding; + var top = yPadding; var right = 0; var bottom = 0; @@ -303,7 +333,8 @@ if (this.isHorizontal()) { maxTicks = Math.min(11, Math.ceil(width / 50)); } else { - maxTicks = Math.min(11, Math.ceil(height / 50)); + // The factor of 2 used to scale the font size has been experimentally determined. + maxTicks = Math.min(11, Math.ceil(height / (2 * this.options.labels.fontSize))); } // To get a "nice" value for the tick spacing, we will use the appropriately named @@ -332,6 +363,11 @@ // 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); }, buildLabels: function() { // We assume that this has been run after ticks have been generated. We try to figure out @@ -498,6 +534,9 @@ 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 diff --git a/src/Chart.Scatter.js b/src/Chart.Scatter.js new file mode 100644 index 000000000..d1c6b8b2a --- /dev/null +++ b/src/Chart.Scatter.js @@ -0,0 +1,490 @@ +(function() { + "use strict"; + + var root = this, + Chart = root.Chart, + helpers = Chart.helpers; + + var defaultConfig = { + + ///Boolean - Whether grid lines are shown across the chart + scaleShowGridLines: true, + + //String - Colour of the grid lines + scaleGridLineColor: "rgba(0,0,0,.05)", + + //Number - Width of the grid lines + scaleGridLineWidth: 1, + + //Boolean - Whether to show horizontal lines (except X axis) + scaleShowHorizontalLines: true, + + //Boolean - Whether to show vertical lines (except Y axis) + scaleShowVerticalLines: true, + + //Number - Tension of the bezier curve between points + tension: 0.4, + + //Number - Radius of each point dot in pixels + pointRadius: 4, + + //Number - Pixel width of point dot border + pointBorderWidth: 1, + + //Number - amount extra to add to the radius to cater for hit detection outside the drawn point + pointHoverRadius: 20, + + //Number - Pixel width of dataset border + borderWidth: 2, + + //String - A legend template + legendTemplate: "", + + //Boolean - Whether to horizontally center the label and point dot inside the grid + offsetGridLines: false + + }; + + + Chart.Type.extend({ + name: "Scatter", + defaults: defaultConfig, + initialize: function(data) { + // Save data as a source for updating of values & methods + this.data = data; + + //Custom Point Defaults + this.PointClass = Chart.Point.extend({ + _chart: this.chart, + offsetGridLines: this.options.offsetGridLines, + borderWidth: this.options.pointBorderWidth, + radius: this.options.pointRadius, + hoverRadius: this.options.pointHoverRadius, + }); + + // Events + helpers.bindEvents(this, this.options.tooltipEvents, this.onHover); + + // Build Scale + this.buildScale(this.data.labels); + Chart.scaleService.fitScalesForChart(this, this.chart.width, this.chart.height); + + //Create a new line and its points for each dataset and piece of data + helpers.each(this.data.datasets, function(dataset, datasetIndex) { + dataset.metaDataset = new Chart.Line(); + dataset.metaData = []; + helpers.each(dataset.data, function(dataPoint, index) { + dataset.metaData.push(new this.PointClass()); + }, this); + }, this); + + // Set defaults for lines + this.eachDataset(function(dataset, datasetIndex) { + dataset = helpers.merge(this.options, dataset); + helpers.extend(dataset.metaDataset, { + _points: dataset.metaData, + _datasetIndex: datasetIndex, + _chart: this.chart, + }); + // Copy to view model + dataset.metaDataset.save(); + }, this); + + // Set defaults for points + this.eachElement(function(point, index, dataset, datasetIndex) { + helpers.extend(point, { + x: this.xScale.getPixelForValue(index), + y: this.chartArea.bottom, + _datasetIndex: datasetIndex, + _index: index, + _chart: this.chart + }); + + // Default bezier control points + helpers.extend(point, { + controlPointPreviousX: this.previousPoint(dataset, index).x, + controlPointPreviousY: this.nextPoint(dataset, index).y, + controlPointNextX: this.previousPoint(dataset, index).x, + controlPointNextY: this.nextPoint(dataset, index).y, + }); + // Copy to view model + point.save(); + }, this); + + // Create tooltip instance exclusively for this chart with some defaults. + this.tooltip = new Chart.Tooltip({ + _chart: this.chart, + _data: this.data, + _options: this.options, + }, this); + + this.update(); + }, + nextPoint: function(collection, index) { + return collection[index - 1] || collection[index]; + }, + previousPoint: function(collection, index) { + return collection[index + 1] || collection[index]; + }, + onHover: function(e) { + // If exiting chart + if (e.type == 'mouseout') { + return this; + } + + this.lastActive = this.lastActive || []; + + // Find Active Elements + this.active = function() { + switch (this.options.hoverMode) { + case 'single': + return this.getElementAtEvent(e); + case 'label': + return this.getElementsAtEvent(e); + case 'dataset': + return this.getDatasetAtEvent(e); + default: + return e; + } + }.call(this); + + // On Hover hook + if (this.options.onHover) { + this.options.onHover.call(this, this.active); + } + + // Remove styling for last active (even if it may still be active) + if (this.lastActive.length) { + switch (this.options.hoverMode) { + case 'single': + this.lastActive[0].backgroundColor = this.data.datasets[this.lastActive[0]._datasetIndex].pointBackgroundColor; + this.lastActive[0].borderColor = this.data.datasets[this.lastActive[0]._datasetIndex].pointBorderColor; + this.lastActive[0].borderWidth = this.data.datasets[this.lastActive[0]._datasetIndex].pointBorderWidth; + break; + case 'label': + for (var i = 0; i < this.lastActive.length; i++) { + this.lastActive[i].backgroundColor = this.data.datasets[this.lastActive[i]._datasetIndex].pointBackgroundColor; + this.lastActive[i].borderColor = this.data.datasets[this.lastActive[i]._datasetIndex].pointBorderColor; + this.lastActive[i].borderWidth = this.data.datasets[this.lastActive[0]._datasetIndex].pointBorderWidth; + } + break; + case 'dataset': + break; + default: + // Don't change anything + } + } + + // Built in hover styling + if (this.active.length && this.options.hoverMode) { + switch (this.options.hoverMode) { + case 'single': + this.active[0].backgroundColor = this.data.datasets[this.active[0]._datasetIndex].hoverBackgroundColor || helpers.color(this.active[0].backgroundColor).saturate(0.5).darken(0.35).rgbString(); + this.active[0].borderColor = this.data.datasets[this.active[0]._datasetIndex].hoverBorderColor || helpers.color(this.active[0].borderColor).saturate(0.5).darken(0.35).rgbString(); + this.active[0].borderWidth = this.data.datasets[this.active[0]._datasetIndex].borderWidth + 10; + break; + case 'label': + for (var i = 0; i < this.active.length; i++) { + this.active[i].backgroundColor = this.data.datasets[this.active[i]._datasetIndex].hoverBackgroundColor || helpers.color(this.active[i].backgroundColor).saturate(0.5).darken(0.35).rgbString(); + this.active[i].borderColor = this.data.datasets[this.active[i]._datasetIndex].hoverBorderColor || helpers.color(this.active[i].borderColor).saturate(0.5).darken(0.35).rgbString(); + this.active[i].borderWidth = this.data.datasets[this.active[i]._datasetIndex].borderWidth + 2; + } + break; + case 'dataset': + break; + default: + // Don't change anything + } + } + + // Built in Tooltips + if (this.options.showTooltips) { + + // The usual updates + this.tooltip.initialize(); + + // Active + if (this.active.length) { + helpers.extend(this.tooltip, { + opacity: 1, + _active: this.active, + }); + + this.tooltip.update(); + } else { + // Inactive + helpers.extend(this.tooltip, { + opacity: 0, + }); + } + } + + // Hover animations + this.tooltip.pivot(); + + if (!this.animating) { + var changed; + + helpers.each(this.active, function(element, index) { + if (element !== this.lastActive[index]) { + changed = true; + } + }, this); + + // If entering, leaving, or changing elements, animate the change via pivot + if ((!this.lastActive.length && this.active.length) || + (this.lastActive.length && !this.active.length) || + (this.lastActive.length && this.active.length && changed)) { + + this.stop(); + this.render(this.options.hoverAnimationDuration); + } + } + + // Remember Last Active + this.lastActive = this.active; + return this; + + }, + update: function() { + Chart.scaleService.fitScalesForChart(this, this.chart.width, this.chart.height); + + // Update the lines + this.eachDataset(function(dataset, datasetIndex) { + helpers.extend(dataset.metaDataset, { + backgroundColor: dataset.backgroundColor || this.options.backgroundColor, + borderWidth: dataset.borderWidth || this.options.borderWidth, + borderColor: dataset.borderColor || this.options.borderColor, + tension: dataset.tension || this.options.tension, + scaleTop: this.chartArea.top, + scaleBottom: this.chartArea.bottom, + _points: dataset.metaData, + _datasetIndex: datasetIndex, + }); + dataset.metaDataset.pivot(); + }); + + // Update the points + this.eachElement(function(point, index, dataset, datasetIndex) { + helpers.extend(point, { + x: this.xScale.getPixelForValue(this.data.datasets[datasetIndex].data[index].x), + y: this.yScale.getPixelForValue(this.data.datasets[datasetIndex].data[index].y), + value: this.data.datasets[datasetIndex].data[index].y, + label: this.data.datasets[datasetIndex].data[index].x, + datasetLabel: this.data.datasets[datasetIndex].label, + // Appearance + hoverBackgroundColor: this.data.datasets[datasetIndex].pointHoverBackgroundColor || this.options.pointHoverBackgroundColor, + hoverBorderColor: this.data.datasets[datasetIndex].pointHoverBorderColor || this.options.pointHoverBorderColor, + hoverRadius: this.data.datasets[datasetIndex].pointHoverRadius || this.options.pointHoverRadius, + radius: this.data.datasets[datasetIndex].pointRadius || this.options.pointRadius, + borderWidth: this.data.datasets[datasetIndex].pointBorderWidth || this.options.pointBorderWidth, + borderColor: this.data.datasets[datasetIndex].pointBorderColor || this.options.pointBorderColor, + backgroundColor: this.data.datasets[datasetIndex].pointBackgroundColor || this.options.pointBackgroundColor, + tension: this.data.datasets[datasetIndex].metaDataset.tension, + _datasetIndex: datasetIndex, + _index: index, + }); + }, this); + + // Update control points for the bezier curve + this.eachElement(function(point, index, dataset, datasetIndex) { + var controlPoints = helpers.splineCurve( + this.previousPoint(dataset, index), + point, + this.nextPoint(dataset, index), + point.tension + ); + + point.controlPointPreviousX = controlPoints.previous.x; + point.controlPointNextX = controlPoints.next.x; + + // Prevent the bezier going outside of the bounds of the graph + + // Cap puter bezier handles to the upper/lower scale bounds + if (controlPoints.next.y > this.chartArea.bottom) { + point.controlPointNextY = this.chartArea.bottom; + } else if (controlPoints.next.y < this.chartArea.top) { + point.controlPointNextY = this.chartArea.top; + } else { + point.controlPointNextY = controlPoints.next.y; + } + + // Cap inner bezier handles to the upper/lower scale bounds + if (controlPoints.previous.y > this.chartArea.bottom) { + point.controlPointPreviousY = this.chartArea.bottom; + } else if (controlPoints.previous.y < this.chartArea.top) { + point.controlPointPreviousY = this.chartArea.top; + } else { + point.controlPointPreviousY = controlPoints.previous.y; + } + // Now pivot the point for animation + point.pivot(); + }, this); + + this.render(); + }, + buildScale: function(labels) { + var self = this; + + var dataTotal = function() { + var values = []; + self.eachValue(function(value) { + values.push(value); + }); + + return values; + }; + + var XScaleClass = Chart.scales.getScaleConstructor("linear"); + var YScaleClass = Chart.scales.getScaleConstructor("linear"); + + this.xScale = new XScaleClass({ + ctx: this.chart.ctx, + }); + + // Eventually this will be referenced from the user supplied config options. + this.xScale.options = { + scaleType: "dataset", // default options are 'dataset', 'linear'. + show: true, + position: "bottom", + horizontal: true, + + // grid line settings + gridLines: { + show: true, + color: "rgba(0, 0, 0, 0.05)", + lineWidth: 1, + drawOnChartArea: true, + }, + + // scale numbers + beginAtZero: false, + integersOnly: false, + override: null, + + // label settings + labels: { + show: true, + template: "<%=value%>", + fontSize: 12, + fontStyle: "normal", + fontColor: "#666", + fontFamily: "Helvetica Neue", + }, + }; + this.yScale = new YScaleClass({ + ctx: this.chart.ctx, + }); + this.yScale.options = { + scaleType: "linear", // only linear but allow scale type registration. This allows extensions to exist solely for log scale for instance + show: true, + position: "left", + horizontal: false, + + // grid line settings + gridLines: { + show: true, + color: "rgba(0, 0, 0, 0.05)", + lineWidth: 1, + drawOnChartArea: true, + }, + + // scale numbers + beginAtZero: false, + integersOnly: false, + override: null, + + // label settings + labels: { + show: true, + template: "<%=value%>", + fontSize: 12, + fontStyle: "normal", + fontColor: "#666", + fontFamily: "Helvetica Neue", + }, + }; + + this.xScale.calculateRange = function() { + this.min = null; + this.max = null; + + helpers.each(self.data.datasets, function(dataset) { + helpers.each(dataset.data, function(value) { + if (this.min === null) { + this.min = value.x; + } else if (value.x < this.min) { + this.min = value.x; + } + + if (this.max === null) { + this.max = value.x; + } else if (value.x > this.max) { + this.max = value.x; + } + }, this); + }, this); + }; + + this.yScale.calculateRange = function() { + this.min = null; + this.max = null; + + helpers.each(self.data.datasets, function(dataset) { + helpers.each(dataset.data, function(value) { + if (this.min === null) { + this.min = value.y; + } else if (value.y < this.min) { + this.min = value.y; + } + + if (this.max === null) { + this.max = value.y; + } else if (value.y > this.max) { + this.max = value.y; + } + }, this); + }, this); + }; + + // Register the axes with the scale service + Chart.scaleService.registerChartScale(this, this.xScale); + Chart.scaleService.registerChartScale(this, this.yScale); + }, + redraw: function() { + + }, + draw: function(ease) { + + var easingDecimal = ease || 1; + this.clear(); + + var chartScaleWrapper = Chart.scaleService.getWrapperForChart(this); + + // Draw all the scales + helpers.each(chartScaleWrapper.scales, function(scale) { + scale.draw(this.chartArea); + }, this); + + this.eachDataset(function(dataset, datasetIndex) { + // Transition Point Locations + helpers.each(dataset.metaData, function(point, index) { + point.transition(easingDecimal); + }, this); + + // Transition and Draw the line + dataset.metaDataset.transition(easingDecimal).draw(); + + // Draw the points + helpers.each(dataset.metaData, function(point) { + point.draw(); + }); + }, this); + + // Finally draw the tooltip + this.tooltip.transition(easingDecimal).draw(); + } + }); + + +}).call(this);