Skip to content

Instantly share code, notes, and snippets.

@factormystic
Created June 9, 2013 23:24
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save factormystic/5745688 to your computer and use it in GitHub Desktop.
Save factormystic/5745688 to your computer and use it in GitHub Desktop.
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