Created
June 9, 2013 23:24
-
-
Save factormystic/5745688 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var size = { | |
width: 650, | |
padding: 60, | |
radius: 250, | |
}; | |
// scale from unit positions to pixel positions | |
var scale = { | |
pixel: d3.scale.linear() | |
.domain([-1, 0, 1]) | |
.range([size.width/2-size.radius+size.padding, size.width/2+size.padding, size.width/2+size.radius+size.padding]), | |
x: function(d) { | |
return scale.pixel(Math.cos(scale.toRad(d))); | |
}, | |
y: function(d) { | |
return scale.pixel(Math.sin(scale.toRad(d))); | |
}, | |
toDeg: function(d) { | |
return d/Math.PI*180.0; | |
}, | |
toRad: function(d) { | |
return d*Math.PI/180.0; | |
}, | |
}; | |
// define an array of objects representing unit circle quadrants | |
// we'll use x and y for label positioning, and min/max for highlight detection | |
var quadrants = [ | |
{x: 0.4, y: -0.4, label:'I', min: 0, max: 90}, | |
{x: -0.4, y: -0.4, label: 'II', min: 90, max: 180}, | |
{x: -0.4, y: 0.4, label: 'III', min: 180, max: 270}, | |
{x: 0.4, y: 0.4, label: 'IV', min: 270, max: 360}, | |
]; | |
// svg chart area | |
var chart = d3.select('#chart .svg').append('svg') | |
.attr('width', size.width+size.padding*2) | |
.attr('height', size.width+size.padding*2) | |
// unit circle | |
var unit_circle = chart.append('svg:circle') | |
.attr('class', 'unit-circle') | |
.attr('cx', size.width/2+size.padding) | |
.attr('cy', size.width/2+size.padding) | |
.attr('r', size.radius) | |
var tick_transform = function(d) { | |
var translate = 'translate('+ scale.x(d) +','+ scale.y(d) +')'; | |
var rotate = 'rotate('+ d +')'; | |
return translate + rotate; | |
}; | |
// tick marks on unit circle every 18 degrees | |
// 15 degrees means 6 ticks per 90-degree quadrant, which looks pleasant | |
var ticks = chart.selectAll('g.tick') | |
.data(d3.range(0, 359, 15).map(function(d){ return 360-d })) | |
.enter() | |
.append('svg:g') | |
.attr('class', 'tick') | |
.attr('transform', tick_transform) | |
// if we just rotated each tick and label by n°, between 90° and 270° the labels would look upside-down | |
// we can fix this easier by drawing the left and right hand sides separately | |
var left = ticks.filter(function(d) { | |
// reverse the angle, because svg angles are clockwise but typically increment counterclockwise in the real world | |
d = 360 - d; | |
return (d > 90 && d <= 270); | |
}) | |
// dx and dy attributes shift the text so it can appear at the end of the tick mark | |
// https://developer.mozilla.org/en-US/docs/SVG/Attribute/dx | |
left.append('svg:text') | |
.attr('dx', 35) | |
.attr('dy', 5) | |
.attr('text-anchor', 'right') | |
.text(function(d) { | |
return (d > 0 ? 360-d : d) +'°'; | |
}) | |
.attr('transform', 'rotate(180, 40, 0)'); | |
// add a line of fixed length inside each svg:g | |
// each tick line inherits its parent g's translation and rotation | |
left.append('svg:line') | |
.attr('x1', 0) | |
.attr('y1', 0) | |
.attr('x2', 10) | |
.attr('y2', 0) | |
// right side | |
var right = ticks.filter(function(d) { | |
d = 360 - d; | |
return (d <= 90 || d > 270) | |
}) | |
right.append('svg:text') | |
.attr('dx', 15) | |
.attr('dy', 5) | |
.attr('text-anchor', 'right') | |
.text(function(d) { | |
return (d > 0 ? 360-d : d) +'°'; | |
}) | |
right.append('svg:line') | |
.attr('x1', 0) | |
.attr('y1', 0) | |
.attr('x2', 10) | |
.attr('y2', 0) | |
// quadrant labels | |
var quadrant_labels = chart.selectAll('g.quadrant-label') | |
.data(quadrants) | |
.enter() | |
.append('svg:g') | |
.attr('class', 'quadrant-label') | |
.attr('transform', function(d) { | |
return 'translate('+ scale.pixel(d.x) +','+ scale.pixel(d.y) +')' | |
}) | |
// svg text is the worst | |
// browsers seem to calculate the bounding rect all differently, otherwise I'd be positioning by width/2 and height/2, but it doesn't come out evenly | |
// tips for surviving: put the text in a svg:g and position that, and make sure to set text-anchor | |
quadrant_labels | |
.append('svg:text') | |
.attr('font-size', 80) | |
.attr('text-anchor', 'middle') | |
.text(function(d){ return d.label }) | |
.attr('dx', 0) | |
.attr('dy', function(){ return this.getBoundingClientRect().height/3 }) | |
// cartesian grid | |
// I think it looks better to hide 0.0 on both axes... it's too crowded at the origin | |
var hide_zero_formatter = function(d) { | |
return d == 0 ? "" : d.toFixed(1); | |
} | |
var x_axis = d3.svg.axis() | |
.scale(scale.pixel) | |
.ticks(5) | |
.tickSubdivide(true) | |
.tickFormat(hide_zero_formatter) | |
chart.append('svg:g') | |
.attr('class', 'axis') | |
.attr('transform', 'translate(0,'+ scale.pixel(0) +')') | |
.call(x_axis) | |
var y_axis = d3.svg.axis() | |
.scale(scale.pixel) | |
.ticks(5) | |
.tickSubdivide(true) | |
.orient('left') | |
.tickFormat(hide_zero_formatter) | |
chart.append('svg:g') | |
.attr('class', 'axis') | |
.attr('transform', 'translate('+ scale.pixel(0) +',0)') | |
.call(y_axis) | |
// drop line between cosine point and unit circle | |
var cos_line = chart.append('svg:line') | |
.attr('class', 'cos-line drop-line') | |
// drop line between sine point and unit circle | |
var sin_line = chart.append('svg:line') | |
.attr('class', 'sin-line drop-line') | |
// cosine point on x-axis | |
var cos_point = chart.append('svg:circle') | |
.attr('class', 'cos-point') | |
.attr('r', 4.0) | |
// sine point on y-axis | |
var sin_point = chart.append('svg:circle') | |
.attr('class', 'sin-point') | |
.attr('r', 4.0) | |
// create a group element to contain all the pieces of what make up the angle point area | |
// draw it after the drop lines, so it shows up on top (svg z-index is based on element order in the document, so top = last) | |
var point = chart.append('svg:g') | |
.attr('class', 'point') | |
// put a long tick and label coming out of a cicle | |
point.append('svg:line') | |
.attr('x1', 0) | |
.attr('y1', 0) | |
.attr('x2', 70) | |
.attr('y2', 0) | |
point.append('svg:circle') | |
.attr('r', 7.0) | |
// start the angle label off with a width so that we can measure the parent g for the invisible drag rect | |
var label = point.append('svg:text') | |
.attr('dx', 75) | |
.attr('dy', 7) | |
.text('____') | |
var drag = d3.behavior.drag() | |
.on('drag', function(d) { | |
// calculate the current angle of the mouse relative to the chart origin (upper left) | |
// it's much easier to get the mouse position relative to the chart, rather than relative to the dragged element | |
// this is for two reasons: | |
// firstly, we'll be updating the point position while still dragging it, so point-based mouse positions change on each call, making movement erratic | |
// secondly, the mouse coords inherit the transform of the selected element, meaning when rotate() is applied, the entire x-y plane of the mouse point is rotated by the point's rotation angle | |
var mouse = d3.mouse(chart[0][0]); | |
var xy = {x: mouse[0] - scale.pixel(0), | |
y: mouse[1] - scale.pixel(0)}; | |
var angle = scale.toDeg(Math.atan2(-xy.y, xy.x)); | |
animation.stop(); | |
update_angle_to(angle); | |
}); | |
// no need to pass a function to these attribute values... | |
// once created, the g.point (almost) doesn't change size | |
// the width *does* change a bit as the number of digits in the angle can change | |
// but building in a 10 pixel left/right padding (x = -10, width = +20) we don't need to worry about it | |
// also remember that `point` is a d3 selection object, which is an array of array of svg nodes (https://github.com/mbostock/d3/wiki/Selections#operating-on-selections) | |
// hence the double [0] array indexing... we know it'll only ever be one node | |
var drag_zone = point.append('svg:rect') | |
.attr('class', 'drag-zone') | |
.attr('x', -10) | |
.attr('y', function(){ return -point[0][0].getBBox().height / 2 }) | |
.attr('width', function(){ return point[0][0].getBBox().width + 20 }) | |
.attr('height', function(){ return point[0][0].getBBox().height }) | |
.call(drag) | |
// show the function value labels on top of the angle point | |
// cosine value label | |
var cos_label = chart.append('svg:text') | |
.attr('class', 'cos-label') | |
.attr('dy', -8) | |
// sine value label | |
var sin_label = chart.append('svg:text') | |
.attr('class', 'sin-label') | |
.attr('dx', 8) | |
.attr('dy', 5) | |
var last_angle = 0; | |
var update_angle_to = function(angle) { | |
// clamp to [0, 360] | |
angle = angle < 0 ? angle + 360 : (angle > 360 ? angle - 360 : angle) | |
last_angle = angle; | |
point.datum(360-angle); | |
point.attr('transform', tick_transform); | |
var left = angle > 90 && angle <= 270; | |
label.text(angle.toFixed(1) +'°') | |
.attr('transform', function() { | |
return (angle > 90 && angle <= 270) ? 'rotate(180, 100, 0)' : ''; | |
}); | |
// being able to pass the scale functions directly to attr() is due making sure that scale.x and scale.y are signature-compatible with the function that attr() expects | |
// eg, that the selection element datum value is the first parameter in both cases | |
// normally selection.attr calls are expressed with anonymous functions, but it's simply not necessary in this case | |
cos_line.datum(360-angle) | |
.attr('x1', scale.x) | |
.attr('y1', scale.pixel(0)) | |
.attr('x2', scale.x) | |
.attr('y2', scale.y) | |
sin_line.datum(360-angle) | |
.attr('x1', scale.pixel(0)) | |
.attr('y1', scale.y) | |
.attr('x2', scale.x) | |
.attr('y2', scale.y) | |
cos_point.datum(360-angle) | |
.attr('cx', scale.x) | |
.attr('cy', scale.pixel(0)) | |
sin_point.datum(360-angle) | |
.attr('cx', scale.pixel(0)) | |
.attr('cy', scale.y) | |
cos_label.datum(360-angle) | |
.attr('x', scale.x) | |
.attr('y', scale.pixel(0)) | |
cos_label.text(Math.cos(scale.toRad(angle)).toFixed(2)) | |
.attr('dx', function(){ return left ? -18 : -this.getBBox().width+20 }) | |
sin_label.datum(360-angle) | |
.attr('x', scale.pixel(0)) | |
.attr('y', scale.y) | |
sin_label.text((-Math.sin(scale.toRad(angle))).toFixed(2)) | |
chart.selectAll('g.quadrant-label') | |
.classed('highlight', function() { | |
d = d3.select(this).select('text').datum() | |
return angle >= d.min && angle <= d.max; | |
}) | |
// update the values in the explanation | |
d3.selectAll('.angle-explanation') | |
.text(angle.toFixed(1) +'°') | |
d3.selectAll('.cos-explanation') | |
.text(Math.cos(scale.toRad(angle)).toFixed(2)) | |
d3.selectAll('.sin-explanation') | |
.text((-Math.sin(scale.toRad(angle))).toFixed(2)) | |
}; | |
// set up a helper object to manage animation control | |
var animation = { | |
running: false, | |
last_tick: 0, | |
start: function() { | |
if (!this.running) { | |
this.running = true; | |
this.last_tick = 0; | |
d3.select('#animation-onoffswitch') | |
.property('checked', true); | |
d3.timer(function(n) { | |
var elapsed = n - animation.last_tick; | |
animation.last_tick = n; | |
update_angle_to(last_angle + (elapsed/100)); | |
return !animation.running; | |
}); | |
} | |
}, | |
stop: function() { | |
this.running = false; | |
d3.select('#animation-onoffswitch') | |
.property('checked', false); | |
}, | |
}; | |
// listen for the checkbox to toggle the animation | |
d3.select('#animation-onoffswitch') | |
.on('change', function() { | |
if (this.checked) | |
animation.start() | |
else | |
animation.stop(); | |
}); | |
// set the initial angle by starting the animation when the page loads | |
animation.start(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment