Home Rotating a 3D globe with D3
Post
Cancel

Rotating a 3D globe with D3

This example illustrates a simple trackball approach for rotating an orthographic globe with D3 3.0. Click and drag on the globe animation below. The idea is to express the click location in spherical coordinates with two orthogonal rotations: one with a horizontal axis, and one with a vertical axis. When we drag with the mouse, we then can rotate the underlying sphere so that the initial click location stays underneath the mouse.

This was originally a gist hosted at bl.ocks.org, which seems dead now. This clone might still work.

When the globe is oriented in its original location (i.e. rotation(0,0), with the north pole at the top of the page and the equator aligned with the horizontal canvas axis, it’s easy, since D3’s longitude rotation equals trackball rotation around the vertical axis, and the subsequently applied latitude rotation corresponds to trackball rotation around the horizontal axis. But when the sphere is already rotated, it’s not easy to infer what the combined rotation should be. Ideally we want a way of composing two rotations, but I’m not aware that you can do that currently in D3? So I did some math (actually SageMath did most of it for me) to work out the product of the original rotation matrix plus the new trackball motion, and derived an equivalent single rotation would be. This gives a more intuitive feel regardless of the position of the globe. The annotated javascript implementation is repeated below.

Compare with the original example I started from and see what happens when you first rotate the globe so the north pole is approximately facing you. Subsequent trackball movements then become completely non-intuitive. You can reproduce the same behavior in the original source here by uncommenting one of the three behaviors in the mousemove() routine.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
function trackballAngles(pt) {
  // based on https://www.opengl.org/wiki/Trackball
  // given a click at (x,y) in canvas coords on the globe (trackball),
  // calculate the spherical coordianates for the point as a rotation around
  // the vertical and horizontal axes

  var r = projection.scale();
  var c = projection.translate();
  var x = pt[0] - c[0], y = - (pt[1] - c[1]), ss = x*x + y*y;
  var z = r*r > 2 * ss ? Math.sqrt(r*r - ss) : r*r / 2 / Math.sqrt(ss);

  var lambda = Math.atan2(x, z) * 180 / Math.PI;
  var phi = Math.atan2(y, z) * 180 / Math.PI
  return [lambda, phi];
}

/*
This is the cartesian equivalent of the rotation matrix,
which is the product of the following rotations (in numbered order):
1. longitude: λ around the y axis (which points up in the canvas)
2. latitude: -ϕ around the x axis (which points right in the canvas)
3. yaw:       γ around the z axis (which points out of the screen)

NB.  If you measure rotations in a positive direction according to the right-hand rule
(point your right thumb in the positive direction of the rotation axis, and rotate in the
direction of your curled fingers), then the latitude rotation is negative.

R(λ, ϕ, γ) =
[[ sin(γ)sin(λ)sin(ϕ)+cos(γ)cos(λ), −sin(γ)cos(ϕ), −sin(γ)sin(ϕ)cos(λ)+sin(λ)cos(γ)],
 [ −sin(λ)sin(ϕ)cos(γ)+sin(γ)cos(λ), cos(γ)cos(ϕ), sin(ϕ)cos(γ)cos(λ)+sin(γ)sin(λ)],
 [ −sin(λ)cos(ϕ),                    −sin(ϕ),       cos(λ)cos(ϕ)]]

If you then apply a "trackball rotation" of δλ around the y axis, and -δϕ around the
x axis, you get this horrible composite matrix:

R2(λ, ϕ, γ, δλ, δϕ) =
[[−sin(δλ)sin(λ)cos(ϕ)+(sin(γ)sin(λ)sin(ϕ)+cos(γ)cos(λ))cos(δλ),
        −sin(γ)cos(δλ)cos(ϕ)−sin(δλ)sin(ϕ),
                sin(δλ)cos(λ)cos(ϕ)−(sin(γ)sin(ϕ)cos(λ)−sin(λ)cos(γ))cos(δλ)],
 [−sin(δϕ)sin(λ)cos(δλ)cos(ϕ)−(sin(γ)sin(λ)sin(ϕ)+cos(γ)cos(λ))sin(δλ)sin(δϕ)−(sin(λ)sin(ϕ)cos(γ)−sin(γ)cos(λ))cos(δϕ),
        sin(δλ)sin(δϕ)sin(γ)cos(ϕ)−sin(δϕ)sin(ϕ)cos(δλ)+cos(δϕ)cos(γ)cos(ϕ),
                sin(δϕ)cos(δλ)cos(λ)cos(ϕ)+(sin(γ)sin(ϕ)cos(λ)−sin(λ)cos(γ))sin(δλ)sin(δϕ)+(sin(ϕ)cos(γ)cos(λ)+sin(γ)sin(λ))cos(δϕ)],
 [−sin(λ)cos(δλ)cos(δϕ)cos(ϕ)−(sin(γ)sin(λ)sin(ϕ)+cos(γ)cos(λ))sin(δλ)cos(δϕ)+(sin(λ)sin(ϕ)cos(γ)−sin(γ)cos(λ))sin(δϕ),
        sin(δλ)sin(γ)cos(δϕ)cos(ϕ)−sin(δϕ)cos(γ)cos(ϕ)−sin(ϕ)cos(δλ)cos(δϕ),
                cos(δλ)cos(δϕ)cos(λ)cos(ϕ)+(sin(γ)sin(ϕ)cos(λ)−sin(λ)cos(γ))sin(δλ)cos(δϕ)−(sin(ϕ)cos(γ)cos(λ)+sin(γ)sin(λ))sin(δϕ)]]

by equating components of the matrics
(label them [[a00, a01, a02], [a10, a11, a12], [a20, a21, a22]])
we can find an equivalent rotation R(λ', ϕ', γ') == RC(λ, ϕ, γ, δλ, δϕ) :

if cos(ϕ') != 0:
 γ' = atan2(-RC01, RC11)
 ϕ' = atan2(-RC21, γ' == 0 ? RC11 / cos(γ') : - RC01 / sin(γ'))
 λ' = atan2(-RC20, RC22)
else:
 // when cos(ϕ') == 0, RC21 == - sin(ϕ') == +/- 1
 // the solution is degenerate, requiring just that
 //    γ' - λ' = atan2(RC00, RC10) if RC21 == -1 (ϕ' = π/2)
 // or γ' + λ' = atan2(RC00, RC10) if RC21 == 1 (ϕ' = -π/2)
 // so choose:
 γ' = atan2(RC10, RC00) - RC21 * λ
 ϕ' = - RC21 * π/2
 λ' = λ

*/

function composedRotation(λ, ϕ, γ, δλ, δϕ) {
    λ = Math.PI / 180 * λ;
    ϕ = Math.PI / 180 * ϕ;
    γ = Math.PI / 180 * γ;
    δλ = Math.PI / 180 * δλ;
    δϕ = Math.PI / 180 * δϕ;

    var  = Math.sin(λ),  = Math.sin(ϕ),  = Math.sin(γ),
        sδλ = Math.sin(δλ), sδϕ = Math.sin(δϕ),
         = Math.cos(λ),  = Math.cos(ϕ),  = Math.cos(γ),
        cδλ = Math.cos(δλ), cδϕ = Math.cos(δϕ);

    var m00 = -sδλ *  *  + ( *  *  +  * ) * cδλ,
            m01 = - * cδλ *  - sδλ * ,
                m02 = sδλ *  *  - ( *  *  -  * ) * cδλ,
        m10 = - sδϕ *  * cδλ *  - ( *  *  +  * ) * sδλ * sδϕ - ( *  *  -  * ) * cδϕ,
            m11 = sδλ * sδϕ *  *  - sδϕ *  * cδλ + cδϕ *  * ,
                 m12 = sδϕ * cδλ *  *  + ( *  *  -  * ) * sδλ * sδϕ + ( *  *  +  * ) * cδϕ,
        m20 = -  * cδλ * cδϕ *  - ( *  *  +  * ) * sδλ * cδϕ + ( *  *  -  * ) * sδϕ,
            m21 = sδλ *  * cδϕ *  - sδϕ *  *  -  * cδλ * cδϕ,
                 m22 = cδλ * cδϕ *  *  + ( *  *  -  * ) * sδλ * cδϕ - ( *  *  +  * ) * sδϕ;

    if (m01 != 0 || m11 != 0) {
         γ_ = Math.atan2(-m01, m11);
         ϕ_ = Math.atan2(-m21, Math.sin(γ_) == 0 ? m11 / Math.cos(γ_) : - m01 / Math.sin(γ_));
         λ_ = Math.atan2(-m20, m22);
    } else {
         γ_ = Math.atan2(m10, m00) - m21 * λ;
         ϕ_ = - m21 * Math.PI / 2;
         λ_ = λ;
    }

    return([λ_ * 180 / Math.PI, ϕ_ * 180 / Math.PI, γ_ * 180 / Math.PI]);
}

var m0 = null,
    o0;

function mousedown() {  // remember where the mouse was pressed, in canvas coords
  m0 = trackballAngles(d3.mouse(svg[0][0]));
  o0 = projection.rotate();
  d3.event.preventDefault();
}

function mousemove() {
  if (m0) {  // if mousedown
    var m1 = trackballAngles(d3.mouse(svg[0][0]));
    // we want to find rotate the current projection so that the point at m0 rotates to m1
    // along the great circle arc between them.
    // when the current projection is at rotation(0,0), with the north pole aligned
    // to the vertical canvas axis, and the equator aligned to the horizontal canvas
    // axis, this is easy to do, since D3's longitude rotation corresponds to trackball
    // rotation around the vertical axis, and then the subsequent latitude rotation
    // corresponds to the trackball rotation around the horizontal axis.
    // But if the current projection is already rotated, it's harder.
    // We need to find a new rotation equivalent to the composition of both

    // Choose one of these three update schemes:

    // Best behavior
    o1 = composedRotation(o0[0], o0[1], o0[2], m1[0] - m0[0], m1[1] - m0[1])

    // Improved behavior over original example
    //o1 = [o0[0] + (m1[0] - m0[0]), o0[1] + (m1[1] - m0[1])];

    // Original example from https://mbostock.github.io/d3/talk/20111018/azimuthal.html
    // o1 = [o0[0] - (m0[0] - m1[0]) / 8, o0[1] - (m1[1] - m0[1]) / 8];

    // move to the updated rotation
    projection.rotate(o1);

    // We can optionally update the "origin state" at each step.  This has the
    // advantage that each 'trackball movement' is small, but the disadvantage of
    // potentially accumulating many small drifts (you often see a twist creeping in
    // if you keep rolling the globe around with the mouse button down)
//    o0 = o1;
//    m0 = m1;

    svg.selectAll("path").attr("d", path);
  }
}
This post is licensed under CC BY 4.0 by the author.