Skip to content

Instantly share code, notes, and snippets.

@mgold
Last active August 28, 2021 19:20
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mgold/1cb3b740f935d155a61e to your computer and use it in GitHub Desktop.
Save mgold/1cb3b740f935d155a61e to your computer and use it in GitHub Desktop.
Spherical Coordinates

Grab the brown and black knobs. You can also dial some of the numbers on the right.

Spherical coordinates are defined by ρ (rho, the distance from the origin), θ (theta, rotation parallel to the xy-plane), and φ (phi, inclination from the north pole to the south pole). This interactive drawing shows how they relate to the Cartesian xyz coordinates. The key is the horizontal slice of radius r.

  • z = ρ cos φ
  • r = ρ sin φ

Which makes sense: when φ=0, we're looking at the north pole, z=ρ and r=0. Then we're left with the familiar equations:

  • x = r cos θ
  • y = r sin θ

In many textbooks, the definition of r is inserted into the definition of x and y, making them difficult to memorize (and the image above harder to see). In particular, defining r allows one to mentally construct spherical coordinates on top of polar coordinates, rather than as a separate entity. While we're improving notation, remember that τ = 2π.

/* d3.selection.place() - an unofficial add-on
Place one item of the given tag and class (as "tag.class") into the DOM, unless it already exists.
Returns the selection either found in or just added to the DOM.
Caveats: Used d3.jetpack append with class. Might do multiple appends for selections of more than one
element. Might be slow.
*/
d3.selection.prototype.place = function(selector) {
sel = this.select(selector);
if (sel.empty()){
sel = this.append(selector)
}
return sel;
};
<!DOCTYPE html>
<head>
<meta charset="utf-8">
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.5/d3.min.js"></script>
<script src="https://cdn.rawgit.com/gka/d3-jetpack/dd27abb646a9aa1e5100d79b336113eb6adb8d6b/d3-jetpack.js"></script>
<script src="d3.place.js"></script>
<style>
/* Yes, this should be split out as a SASS file. But it's not, because I want it to be a block.. */
body { margin:0;position:fixed;top:0;right:0;bottom:0;left:0; }
svg { width: 100%; height: 100%; }
text { font-family: avenir, sans-serif; }
.gray { fill: #AAAAAA }
line {
stroke-linecap: round;
stroke-width: 4px;
stroke: #444;
}
circle.outer {
fill: none;
stroke: #444;
stroke-width: 2px;
}
line.sliceOutline {
stroke: #D0D0D0;
}
circle.sliceOutline {
fill: #EEEEEE;
}
line.z {
stroke: #0074D9;
stroke-width: 4px;
}
text.z, tspan.z { fill: #0074D9; }
line.r-interior {
stroke: #CE9A76;
stroke-width: 4px;
}
circle.r {
fill: #E4C6B0; /* #CE9A76; */
stroke: #A05725;
stroke-width: 4px;
}
circle.r-edge {
fill: #A05725;
}
tspan.r { fill: #A05725; }
line.r-edge { stroke: #A05725; }
line.rho {
fill: none;
stroke: #B10DC9;
stroke-width: 4px;
stroke-linecap: round;
shape-rendering: geometricPrecision;
}
tspan.rho { fill: #B10DC9; }
line.x {
stroke: #FF4136;
}
text.x, tspan.x { fill: #FF4136;}
line.y {
stroke: #2ECC40;
}
text.y, tspan.y { fill: #2ECC40;}
path.phi {
fill: #FFDC00;
}
tspan.phi { fill: #FFDC00;}
path.theta {
fill: #FF851B;
}
tspan.theta { fill: #FF851B;}
text.descr {
fill: #AAAAAA;
text-anchor: end;
}
text.label {
font-size: 11px;
font-family: courier, monospace;
}
.grab {
cursor: grab;
cursor: -webkit-grab;
}
.grabbing {
cursor: grabbing;
cursor: -webkit-grabbing;
}
</style>
</head>
<body>
<script>
// Tau polyfills
Math.TAU = 2*Math.PI;
Math.HALFTAU = Math.PI;
Math.QUARTERTAU = Math.PI/2;
// Math polyfills
Math.approx = function(d){ return Math.round(d*100)/100 }
Math.dist = function(a,b){ return Math.sqrt(a*a + b*b) }
Math.clamp = function(low, val, high){ return Math.max(low, Math.min(val, high)) }
Math.near = function(a,b, prec){return Math.abs(a-b) < (prec || 0.1)}
function isZero(d){ return Math.abs(d) <= 0.01 }
// fractions dictionary, numbers become strings and it just works
var fractions = d3.map();
fractions.set(1, ""); fractions.set(1/2, "½");
fractions.set(1/3, "⅓"); fractions.set(2/3, "⅔")
fractions.set(1/4, "¼"); fractions.set(3/4, "¾")
fractions.set(1/6, "⅙"); fractions.set(5/6, "⅚")
fractions.set(1/8, "⅛"); fractions.set(3/8, "⅜")
fractions.set(5/8, "⅝"); fractions.set(7/8, "⅞")
var snapPrecision = 0.001;
// margin convention
var margin = {top: 20, right: 10, bottom: 20, left: 20};
var width = 960 - margin.left - margin.right;
var height = 500 - margin.top - margin.bottom;
var svg = d3.select("body").append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.translate([margin.left, margin.top])
var R = height/2
// All the mutable state
var rho = 0.8, // fraction of R
theta = 3*Math.TAU/8,
phi = Math.TAU/8,
r, x, y, z,
draggingR = false;
// snaps angles to nice values
function snapAngles(){
var snapPoints = fractions.keys().concat(0);
snapPoints.forEach(function(snapPoint){
snapPoint *= Math.TAU;
if (!draggingR && Math.near(phi, snapPoint, 40*snapPrecision)) phi = snapPoint;
if (Math.near(theta, snapPoint, 40*snapPrecision)) theta = snapPoint;
})
}
// updates the derives state values
function updateRXYZ(){
r = R * rho * Math.sin(phi);
z = R * rho * Math.cos(phi);
x = r*Math.cos(theta);
y = r*Math.sin(theta);
}
// the three main g elements
var xzSlice = svg.append("g.xzSlice")
.translate([0, R]);
var xySlice = svg.append("g.xySlice")
.translate([500, R]);
var legend = svg.append("g.legend")
.translate([805, 153]);
// arc generators for phi and theta
var phiGen = d3.svg.arc()
.innerRadius(50)
.outerRadius(54)
.startAngle(0)
.endAngle(function(d){return d})
var thetaGen = d3.svg.arc()
.innerRadius(50)
.outerRadius(54)
.startAngle(Math.QUARTERTAU)
.endAngle(function(d){return Math.QUARTERTAU-d})
// this function gets called any time a drag occurs
// and at the start of execution
function render(){
if (rho <= 0.02) rho = 0;
snapAngles();
updateRXYZ();
var sliceRadius = Math.sqrt(R*R - z*z);
var axisOffset = sliceRadius + 5;
// selection.place is a polyfill I wrote that appends an element of the
// type and class given, unless it already exists, and always returns the
// selection.
xzSlice.place("line.sliceOutline")
.attr({x1: -sliceRadius, y1: -z, x2: sliceRadius, y2: -z})
xzSlice.place("circle.outer")
.attr("r", R)
xzSlice.place("path.phi")
.attr("d", phiGen(phi))
.style("display", isPhiDefined() ? null : "none")
xzSlice.place("line.z")
.attr({x1: 0, y1: 0, x2: 0, y2: -z})
xzSlice.place("line.rho")
.attr({x1: 0, y1: 0, x2: r, y2: -z})
xzSlice.place("line.r-interior")
.attr({x1: -r, y1: -z, x2: r, y2: -z})
xzSlice.place("circle.r-edge")
.attr("r", "6px")
.translate([r, -z])
xzSlice.place("text.z.label")
.translate([0, -R-5])
.text("z")
var label = xzSlice.place("text.xy.label")
.translate([R+5, -5])
label.place("tspan.x")
.text("x")
label.place("tspan.y")
.text("y")
xzSlice.place("text.plane.label")
.text("plane")
.translate([R+4, 8])
xySlice.place("circle.outer.sliceOutline")
.attr("r", sliceRadius)
xySlice.place("circle.r")
.attr("r", Math.max(1, r))
xySlice.place("path.theta")
.attr("d", thetaGen(theta))
.style("display", isThetaDefined() ? null : "none")
xySlice.place("line.y")
.attr({x1: x, y1: 0, x2: x, y2: -y})
xySlice.place("line.r-edge")
.attr({x1: 0, y1: 0, x2: x, y2: -y})
xySlice.place("line.x")
.attr({x1: 0, y1: 0, x2: x, y2: 0})
xySlice.place("circle.point")
.attr("r", "6px")
.translate([x, -y]);
xySlice.place("text.x.label")
.translate([axisOffset, 0])
.style("display", isThetaDefined() ? null : "none")
.text("x");
xySlice.place("text.y.label")
.translate([0, -axisOffset])
.style("display", isThetaDefined() ? null : "none")
.text("y");
([["ρ", rho < 0.02 ? "0" : Math.approx(rho), "rho", 0, "magnitude"],
["θ", formatTheta(), "theta", 1, "rotation"],
["φ", formatPhi(), "phi", 2, "incline"],
["r", Math.approx(r/R), "r", 4],
["x", Math.approx(x/R), "x", 6],
["y", Math.approx(y/R), "y", 7],
["z", Math.approx(z/R), "z", 8]]).forEach(function(d){renderEqtn.apply(null, d)})
}
render(); // start everything off
// the next part of the code handles formatting angles as indetermiante or fractions
var indet = "indeterminate";
function isPhiDefined(){ return !isZero(rho) }
function formatPhi(){ return isPhiDefined() ? formatAngle(phi) : indet}
function isThetaDefined(){ return isPhiDefined() && !(isZero(phi) || phi > 0.99*Math.HALFTAU)}
function formatTheta(){ return isThetaDefined() ? formatAngle(theta) : indet}
function formatAngle(ang){
if (isZero(ang)) return "0"
var returnMe = Math.approx(ang/Math.TAU);
fractions.forEach(function(key, val){
if (Math.near(ang/Math.TAU, key, snapPrecision)) {
returnMe = val;
}
})
return returnMe+"τ"
}
// called from the render function, remember?
function renderEqtn(symbol, value, klass, i, descr){
var spacing = 20;
if (descr){
legend.place("text.descr."+klass)
.translate([-5, i*spacing])
.text(descr)
}
var text = legend.place("text."+klass+"-eqtn")
.translate([0, i*spacing])
text.place("tspan.sym."+klass)
.text(symbol)
text.place("tspan.val")
.text((value === indet ? " is " : " = ") + value)
.classed("gray", value === indet)
}
// Add grab hooks to the two circles
function addGrab(onDrag){
var drag = d3.behavior.drag();
drag.on("dragstart", function(){ d3.select(this).classed("grab", false).classed("grabbing", true)})
drag.on("dragend", function(){ d3.select(this).classed("grab", true).classed("grabbing", false); draggingR = false;})
drag.on("drag", function(){
onDrag();
render();
})
return drag;
}
xzSlice.select("circle.r-edge").call(addGrab(function(){
var ex = Math.max(0, d3.event.x), ey = d3.event.y;
rho = Math.min(1, Math.dist(ex, ey)/R)
phi = Math.atan2(ex, -ey)
}));
xySlice.select("circle.point").call(addGrab(function(){
if (!isPhiDefined()) return;
draggingR = true;
var ex = d3.event.x, ey = d3.event.y;
theta = Math.atan2(-ey, ex);
if (theta < 0) theta += Math.TAU;
r = Math.clamp(0.01, Math.dist(ex, ey), r/rho);
rho = Math.dist(z, r)/R;
phi = Math.atan2(r, z);
}));
// Add drag hooks to the three spherical coordinates (and r) in the legend
function addDrag(sel, onDrag){
sel.style("cursor", "ew-resize")
var drag = d3.behavior.drag();
drag.on("dragend", function(){ draggingR = false; })
drag.on("drag", function(){
onDrag();
render();
})
sel.call(drag);
return sel;
}
legend.select(".rho-eqtn .val").call(addDrag, function(){
rho += d3.event.dx/50;
rho = Math.clamp(0, rho, 1)
})
legend.select(".theta-eqtn .val").call(addDrag, function(){
if (isThetaDefined()){
theta += d3.event.dx/30;
theta = Math.clamp(0, theta, Math.TAU)
}
})
legend.select(".phi-eqtn .val").call(addDrag, function(){
if (isPhiDefined()){
phi += d3.event.dx/30;
phi = Math.clamp(0, phi, Math.HALFTAU)
}
})
legend.select(".r-eqtn .val").call(addDrag, function(){
draggingR = true;
r += d3.event.dx;
r = Math.clamp(0.01, r, r/rho);
rho = Math.dist(z, r)/R;
phi = Math.atan2(r, z);
})
</script>
</body>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment