Zoomable/rotatable world globe that uses orthographic projection. Drag behavior is enhanced as described here: https://www.jasondavies.com/maps/rotate/
Performance is not good due to redrawing whole world upon zoom/drag.
Zoomable/rotatable world globe that uses orthographic projection. Drag behavior is enhanced as described here: https://www.jasondavies.com/maps/rotate/
Performance is not good due to redrawing whole world upon zoom/drag.
// Copyright (c) 2013, Jason Davies, http://www.jasondavies.com | |
// See LICENSE.txt for details. | |
(function() { | |
var radians = Math.PI / 180, | |
degrees = 180 / Math.PI; | |
// TODO make incremental rotate optional | |
d3.geo.zoom = function() { | |
var projection, | |
zoomPoint, | |
event = d3.dispatch("zoomstart", "zoom", "zoomend"), | |
zoom = d3.behavior.zoom() | |
.on("zoomstart", function() { | |
var mouse0 = d3.mouse(this), | |
rotate = quaternionFromEuler(projection.rotate()), | |
point = position(projection, mouse0); | |
if (point) zoomPoint = point; | |
zoomOn.call(zoom, "zoom", function() { | |
projection.scale(d3.event.scale); | |
var mouse1 = d3.mouse(this), | |
between = rotateBetween(zoomPoint, position(projection, mouse1)); | |
projection.rotate(eulerFromQuaternion(rotate = between | |
? multiply(rotate, between) | |
: multiply(bank(projection, mouse0, mouse1), rotate))); | |
mouse0 = mouse1; | |
event.zoom.apply(this, arguments); | |
}); | |
event.zoomstart.apply(this, arguments); | |
}) | |
.on("zoomend", function() { | |
zoomOn.call(zoom, "zoom", null); | |
event.zoomend.apply(this, arguments); | |
}), | |
zoomOn = zoom.on; | |
zoom.projection = function(_) { | |
return arguments.length ? zoom.scale((projection = _).scale()) : projection; | |
}; | |
return d3.rebind(zoom, event, "on"); | |
}; | |
function bank(projection, p0, p1) { | |
var t = projection.translate(), | |
angle = Math.atan2(p0[1] - t[1], p0[0] - t[0]) - Math.atan2(p1[1] - t[1], p1[0] - t[0]); | |
return [Math.cos(angle / 2), 0, 0, Math.sin(angle / 2)]; | |
} | |
function position(projection, point) { | |
var t = projection.translate(), | |
spherical = projection.invert(point); | |
return spherical && isFinite(spherical[0]) && isFinite(spherical[1]) && cartesian(spherical); | |
} | |
function quaternionFromEuler(euler) { | |
var λ = .5 * euler[0] * radians, | |
φ = .5 * euler[1] * radians, | |
γ = .5 * euler[2] * radians, | |
sinλ = Math.sin(λ), cosλ = Math.cos(λ), | |
sinφ = Math.sin(φ), cosφ = Math.cos(φ), | |
sinγ = Math.sin(γ), cosγ = Math.cos(γ); | |
return [ | |
cosλ * cosφ * cosγ + sinλ * sinφ * sinγ, | |
sinλ * cosφ * cosγ - cosλ * sinφ * sinγ, | |
cosλ * sinφ * cosγ + sinλ * cosφ * sinγ, | |
cosλ * cosφ * sinγ - sinλ * sinφ * cosγ | |
]; | |
} | |
function multiply(a, b) { | |
var a0 = a[0], a1 = a[1], a2 = a[2], a3 = a[3], | |
b0 = b[0], b1 = b[1], b2 = b[2], b3 = b[3]; | |
return [ | |
a0 * b0 - a1 * b1 - a2 * b2 - a3 * b3, | |
a0 * b1 + a1 * b0 + a2 * b3 - a3 * b2, | |
a0 * b2 - a1 * b3 + a2 * b0 + a3 * b1, | |
a0 * b3 + a1 * b2 - a2 * b1 + a3 * b0 | |
]; | |
} | |
function rotateBetween(a, b) { | |
if (!a || !b) return; | |
var axis = cross(a, b), | |
norm = Math.sqrt(dot(axis, axis)), | |
halfγ = .5 * Math.acos(Math.max(-1, Math.min(1, dot(a, b)))), | |
k = Math.sin(halfγ) / norm; | |
return norm && [Math.cos(halfγ), axis[2] * k, -axis[1] * k, axis[0] * k]; | |
} | |
function eulerFromQuaternion(q) { | |
return [ | |
Math.atan2(2 * (q[0] * q[1] + q[2] * q[3]), 1 - 2 * (q[1] * q[1] + q[2] * q[2])) * degrees, | |
Math.asin(Math.max(-1, Math.min(1, 2 * (q[0] * q[2] - q[3] * q[1])))) * degrees, | |
Math.atan2(2 * (q[0] * q[3] + q[1] * q[2]), 1 - 2 * (q[2] * q[2] + q[3] * q[3])) * degrees | |
]; | |
} | |
function cartesian(spherical) { | |
var λ = spherical[0] * radians, | |
φ = spherical[1] * radians, | |
cosφ = Math.cos(φ); | |
return [ | |
cosφ * Math.cos(λ), | |
cosφ * Math.sin(λ), | |
Math.sin(φ) | |
]; | |
} | |
function dot(a, b) { | |
for (var i = 0, n = a.length, s = 0; i < n; ++i) s += a[i] * b[i]; | |
return s; | |
} | |
function cross(a, b) { | |
return [ | |
a[1] * b[2] - a[2] * b[1], | |
a[2] * b[0] - a[0] * b[2], | |
a[0] * b[1] - a[1] * b[0] | |
]; | |
} | |
})(); |
<!DOCTYPE html> | |
<meta charset="utf-8"> | |
<style> | |
#globe{ | |
background: #fcfcfa; | |
width: 900px; | |
height: 500px; | |
margin-left: 50px; | |
} | |
.stroke { | |
fill: none; | |
stroke: #000; | |
stroke-width: 3px; | |
} | |
.fill { | |
fill: #fff; | |
} | |
.graticule { | |
fill: none; | |
stroke: #777; | |
stroke-width: .5px; | |
stroke-opacity: .5; | |
} | |
.land { | |
fill: #222; | |
} | |
.boundary { | |
fill: none; | |
stroke: #fff; | |
stroke-width: .5px; | |
} | |
.overlay { | |
fill: none; | |
pointer-events: all; | |
} | |
</style> | |
<body> | |
<script src="http://d3js.org/d3.v3.min.js"></script> | |
<script src="http://d3js.org/topojson.v1.min.js"></script> | |
<script src="d3.geo.zoom.js"></script> | |
<div id="globe"></div> | |
<script> | |
var width = 680, | |
height = 680; | |
var projection = d3.geo.orthographic() | |
.scale(270) | |
.translate([width / 2, height / 2]) | |
.clipAngle(90) | |
.precision(.1); | |
var zoom = d3.behavior.zoom() | |
.scaleExtent([1,6]) | |
.on("zoom",zoomed); | |
var zoomEnhanced = d3.geo.zoom().projection(projection) | |
.on("zoom",zoomedEnhanced); | |
var drag = d3.behavior.drag() | |
.origin(function() { var r = projection.rotate(); return {x: r[0], y: -r[1]}; }) | |
.on("drag", dragged) | |
.on("dragstart", dragstarted) | |
.on("dragend", dragended); | |
var path = d3.geo.path() | |
.projection(projection); | |
var graticule = d3.geo.graticule(); | |
var svg = d3.select("#globe").append("svg") | |
.attr("width", width) | |
.attr("height", height); | |
var pathG = svg.append("g"); | |
svg.append("rect") | |
.attr("class", "overlay") | |
.attr("width", width) | |
.attr("height", height) | |
.call(zoomEnhanced) | |
pathG.append("defs").append("path") | |
.datum({type: "Sphere"}) | |
.attr("id", "sphere") | |
.attr("d", path); | |
pathG.append("use") | |
.attr("class", "stroke") | |
.attr("xlink:href", "#sphere"); | |
pathG.append("use") | |
.attr("class", "fill") | |
.attr("xlink:href", "#sphere"); | |
pathG.append("path") | |
.datum(graticule) | |
.attr("class", "graticule") | |
.attr("d", path); | |
d3.json("worldTopo.json", function(error, world) { | |
// to render meridians/graticules on top of lands, use insert which adds new path before graticule in the selection | |
pathG.insert("path", ".graticule") | |
.datum(topojson.feature(world, world.objects.land)) | |
.attr("class", "land") | |
.attr("d", path) | |
pathG.insert("path", ".graticule") | |
.datum(topojson.mesh(world, world.objects.countries, function(a, b) { return a !== b; })) | |
.attr("class", "boundary") | |
.attr("d", path); | |
}); | |
// apply transformations to map and all elements on it | |
function zoomed() | |
{ | |
pathG.attr("transform", "translate(" + d3.event.translate + ")scale(" + d3.event.scale + ")"); | |
//grids.attr("transform", "translate(" + d3.event.translate + ")scale(" + d3.event.scale + ")"); | |
//geofeatures.select("path.graticule").style("stroke-width", 0.5 / d3.event.scale); | |
pathG.selectAll("path.boundary").style("stroke-width", 0.5 / d3.event.scale); | |
} | |
function zoomedEnhanced() | |
{ | |
pathG.selectAll("path").attr("d",path); | |
} | |
function dragstarted(d) | |
{ | |
//stopPropagation prevents dragging to "bubble up" which triggers same event for all elements below this object | |
d3.event.sourceEvent.stopPropagation(); | |
d3.select(this).classed("dragging", true); | |
} | |
function dragged() { | |
projection.rotate([d3.event.x, -d3.event.y]); | |
pathG.selectAll("path").attr("d", path); | |
} | |
function dragended(d) | |
{ | |
d3.select(this).classed("dragging", false); | |
} | |
d3.select(self.frameElement).style("height", height + "px"); | |
</script> |