diff --git a/app/components/d3-gauge.js b/app/components/d3-gauge.js new file mode 100644 index 0000000..28fb1f7 --- /dev/null +++ b/app/components/d3-gauge.js @@ -0,0 +1,257 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + tagName: 'span', + classNames: ['gauge'], + + size: 120, + minValue: 0, + maxValue: 100, + value: 0, + minorTicks: 2, + majorTicks: 5, + label: '', + greenColor: '#109618', + yellowColor: '#FF9900', + redColor: '#DC3912', + greenZones: [], + yellowZones: [], + redZones: [], + + didInsertElement: function() { + this._drawGauge(); + }, + + _drawGauge: function() { + // calculate dimensions + var cx = this.size / 2; + var radius = this.size / 2 * 0.97; + var labelFontSize = Math.round(this.size / 9); + var fontSize = Math.round(this.size / 16); + var range = this.maxValue - this.minValue; + var majorDelta = range / (this.majorTicks - 1); + var minorDelta = majorDelta / this.minorTicks; + var midValue = (this.minValue + this.maxValue) / 2; + var pointerFontSize = Math.round(this.size / 10); + + // create body element + var body = d3.select('#' + this.elementId).append("svg:svg"); + this.set('svgBody', body); + + // base circles + body.append("svg:circle") + .attr("cx", cx) + .attr("cy", cx) + .attr("r", radius) + .style("fill", "#ccc") + .style("stroke", "#000") + .style("stroke-width", "0.5px"); + + body.append("svg:circle") + .attr("cx", cx) + .attr("cy", cx) + .attr("r", radius * 0.9) + .style("fill", "#fff") + .style("stroke", "#e0e0e0") + .style("stroke-width", "2px"); + + // color zones + for (var index in this.greenZones) { + this.drawBand(this.greenZones[index].from, this.greenZones[index].to, this.greenColor); + } + + for (var index in this.yellowZones) { + this.drawBand(this.yellowZones[index].from, this.yellowZones[index].to, this.yellowColor); + } + + for (var index in this.redZones) { + var zone = this.redZones[index]; + console.log(zone); + this.drawBand(this.redZones[index].from, this.redZones[index].to, this.redColor); + } + + // label + if (this.label) { + body.append("svg:text") + .attr("x", cx) + .attr("y", cx / 2 + labelFontSize / 2) + .attr("dy", labelFontSize / 2) + .attr("text-anchor", "middle") + .text(this.label) + .style("font-size", labelFontSize + "px") + .style("fill", "#333") + .style("stroke-width", "0px"); + } + + // ticks + for (var major = this.minValue; major <= this.maxValue; major += majorDelta) { + for (var minor = major + minorDelta; minor < Math.min(major + majorDelta, this.maxValue); minor += minorDelta) { + var p1 = this.valueToPoint(minor, 0.75); + var p2 = this.valueToPoint(minor, 0.85); + + body.append("svg:line") + .attr("x1", p1.x) + .attr("y1", p1.y) + .attr("x2", p2.x) + .attr("y2", p2.y) + .style("stroke", "#666") + .style("stroke-width", "1px"); + } + + var p1 = this.valueToPoint(major, 0.7); + var p2 = this.valueToPoint(major, 0.85); + + body.append("svg:line") + .attr("x1", p1.x) + .attr("y1", p1.y) + .attr("x2", p2.x) + .attr("y2", p2.y) + .style("stroke", "#333") + .style("stroke-width", "2px"); + + if (major == this.minValue || major == this.maxValue) { + var point = this.valueToPoint(major, 0.63); + + body.append("svg:text") + .attr("x", point.x) + .attr("y", point.y) + .attr("dy", fontSize / 3) + .attr("text-anchor", major == this.minValue ? "start" : "end") + .text(major) + .style("font-size", fontSize + "px") + .style("fill", "#333") + .style("stroke-width", "0px"); + } + } + + // pointer + var pointerContainer = body.append("svg:g").attr("class", "pointerContainer"); + var pointerPath = this.buildPointerPath(midValue); + var pointerLine = d3.svg.line() + .x(function(d) { return d.x }) + .y(function(d) { return d.y }) + .interpolate("basis"); + + pointerContainer.selectAll("path") + .data([pointerPath]) + .enter() + .append("svg:path") + .attr("d", pointerLine) + .style("fill", "#dc3912") + .style("stroke", "#c63310") + .style("fill-opacity", 0.7); + + pointerContainer.append("svg:circle") + .attr("cx", cx) + .attr("cy", cx) + .attr("r", radius * 0.12) + .style("fill", "#4684EE") + .style("stroke", "#666") + .style("opacity", 1); + + pointerContainer.selectAll("text") + .data([midValue]) + .enter() + .append("svg:text") + .attr("x", cx) + .attr("y", this.size - cx / 4 - pointerFontSize) + .attr("dy", pointerFontSize / 2) + .attr("text-anchor", "middle") + .style("font-size", pointerFontSize + "px") + .style("fill", "#000") + .style("stroke-width", "0px"); + + this._redraw(this.value, 0); + }, + + _redraw: function(value, transitionDuration) { + var pointerContainer = this.svgBody.select(".pointerContainer"); + pointerContainer.selectAll("text").text(Math.round(value)); + + var pointer = pointerContainer.selectAll("path"); + var _this = this; + + pointer.transition() + .duration(transitionDuration) + .attrTween("transform", function() { + var pointerValue = value; + if (value > _this.maxValue) { + pointerValue = _this.maxValue + 0.02 * (_this.maxValue - _this.minValue); + } else if (value < _this.minValue) { + pointerValue = _this.minValue - 0.02 * (_this.maxValue - _this.minValue); + } + + var targetRotation = _this.valueToDegrees(pointerValue) - 90; + var currentRotation = _this._currentRotation || targetRotation; + _this._currentRotation = targetRotation; + + return function(step) { + var rotation = currentRotation + (targetRotation - currentRotation) * step; + return "translate(" + (_this.size / 2) + ", " + (_this.size / 2) + ") rotate(" + rotation + ")"; + } + }); + }.observes('value'), + + drawBand: function(start, end, color) { + if (0 >= end - start) { + return; + } + + var _this = this; + + this.svgBody.append("svg:path") + .style("fill", color) + .attr("d", d3.svg.arc() + .startAngle(this.valueToRadians(start)) + .endAngle(this.valueToRadians(end)) + .innerRadius(0.65 * (this.size / 2 * 0.97)) + .outerRadius(0.85 * (this.size / 2 * 0.97))) + .attr("transform", function() { + return "translate(" + (_this.size / 2) + ", " + (_this.size / 2) + ") rotate(270)"; + }); + }, + + buildPointerPath: function(value) { + var delta = (this.maxValue - this.minValue) / 13; + var head = this.valueToPoint(value, 0.85); + var head1 = this.valueToPoint(value - delta, 0.12); + var head2 = this.valueToPoint(value + delta, 0.12); + + var cx = this.size / 2; + head.x -= cx; + head.y -= cx; + head1.x -= cx; + head1.y -= cx; + head2.x -= cx; + head2.y -= cx; + + var tailValue = value - ((this.maxValue - this.minValue) * (1/(270/360)) / 2); + var tail = this.valueToPoint(tailValue, 0.28); + var tail1 = this.valueToPoint(tailValue - delta, 0.12); + var tail2 = this.valueToPoint(tailValue + delta, 0.12); + + tail.x -= cx; + tail.y -= cx; + tail1.x -= cx; + tail1.y -= cx; + tail2.x -= cx; + tail2.y -= cx; + + return [head, head1, tail2, tail, tail1, head2, head]; + }, + + valueToPoint: function(value, factor) { + return { + x: (this.size / 2) - (this.size / 2 * 0.97) * factor * Math.cos(this.valueToRadians(value)), + y: (this.size / 2) - (this.size / 2 * 0.97) * factor * Math.sin(this.valueToRadians(value)) + }; + }, + + valueToRadians: function(value) { + return this.valueToDegrees(value) * Math.PI / 180; + }, + + valueToDegrees: function(value) { + return value / (this.maxValue - this.minValue) * 270 - (this.minValue / (this.maxValue - this.minValue) * 270 + 45); + } +}); diff --git a/app/controllers/lab-mashup.js b/app/controllers/lab-mashup.js index cb6c094..2c7233a 100644 --- a/app/controllers/lab-mashup.js +++ b/app/controllers/lab-mashup.js @@ -2,6 +2,8 @@ import Ember from 'ember'; export default Ember.Controller.extend({ state: 1, + redZone: [{from: 50, to: 60}], + yellowZone: [{from: 40, to: 50}], dataSetOne: [ { diff --git a/app/templates/components/d3-gauge.hbs b/app/templates/components/d3-gauge.hbs new file mode 100644 index 0000000..889d9ee --- /dev/null +++ b/app/templates/components/d3-gauge.hbs @@ -0,0 +1 @@ +{{yield}} diff --git a/app/templates/lab-mashup.hbs b/app/templates/lab-mashup.hbs index 216973f..26cb868 100644 --- a/app/templates/lab-mashup.hbs +++ b/app/templates/lab-mashup.hbs @@ -32,6 +32,8 @@
  • Electric power capacity: 8458 MW
  • + + {{d3-gauge label="Frequency" value=23 maxValue=60 minorTicks=4 redZones=redZone yellowZones=yellowZone}} diff --git a/bower.json b/bower.json index 3b4db98..f339e8d 100644 --- a/bower.json +++ b/bower.json @@ -17,7 +17,8 @@ "Faker": "~3.0.0", "bootstrap": "~3.3.2", "flot": "*", - "normalize.css": "3.0.3" + "normalize.css": "3.0.3", + "d3": "~3.5.6" }, "resolutions": { "ember": "^2.0.0" diff --git a/ember-cli-build.js b/ember-cli-build.js index a8d7780..79ba103 100644 --- a/ember-cli-build.js +++ b/ember-cli-build.js @@ -20,6 +20,7 @@ module.exports = function(defaults) { // along with the exports of each module as its value. app.import('bower_components/flot/jquery.flot.time.js'); + app.import('bower_components/d3/d3.js'); return app.toTree(); }; diff --git a/tests/integration/components/d3-gauge-test.js b/tests/integration/components/d3-gauge-test.js new file mode 100644 index 0000000..05120e4 --- /dev/null +++ b/tests/integration/components/d3-gauge-test.js @@ -0,0 +1,26 @@ +import { moduleForComponent, test } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; + +moduleForComponent('d3-gauge', 'Integration | Component | d3 gauge', { + integration: true +}); + +test('it renders', function(assert) { + assert.expect(2); + + // Set any properties with this.set('myProperty', 'value'); + // Handle any actions with this.on('myAction', function(val) { ... }); + + this.render(hbs`{{d3-gauge}}`); + + assert.equal(this.$().text().trim(), ''); + + // Template block usage: + this.render(hbs` + {{#d3-gauge}} + template block text + {{/d3-gauge}} + `); + + assert.equal(this.$().text().trim(), 'template block text'); +});