|
<!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> |