This block demonstrates d3-geo-voronoi’s Urquhart Graph feature, in red.
The geoVoronoi.find(x,y)
method is used to select the mission that will ‘radiate’ towards the cursor. (Tiny green lines show the underlying Voronoi tesselation.)
Dataset from Ryan Hodges; image: USGS.
Uses @mbostock's Milky Way block for the WebGL reprojection shader, adapted for the orthographic projection. License GPL-3.
To do:
-- terminator lighting
xxxxxxxxxx
<meta charset="utf-8">
<script src="../d3-geo-voronoi/dev/d3.v4.js"></script>
<script src="//d3js.org/d3.v4.min.js"></script>
<script src="d3-geo-voronoi.min.js"></script>
<!--
*Note to self: do not edit this block on blockbuilder*
-->
<style>
body {
margin: 0;
overflow: hidden;
background: black;
font-family: "Lucida Grande", "Lucida Sans Unicode", "Lucida Sans", Geneva, Verdana, sans-serif;
}
canvas {
cursor: move;
}
svg, canvas, #legend {
position:absolute;
top:0;
}
.missions path { fill: white }
.polygons path {
fill: rgba(0,0,0,0.001); /* to receive mouseover events */
stroke: rgba(200,255,220,0.1);
stroke-width: 1.5;
}
.links path {
fill: none;
stroke: rgba(180,90,90,0.9);
stroke-width: 3;
}
path.glow {
fill: none;
stroke: rgba(255,255,255,0.7);
stroke-width: 1;
}
#legend {
color: white;
top: 1em;
left: 1em;
padding: 0.5em 1em;
border-left: solid white 4px;
background: rgba(255,255,255,0.1);
width: 19em;
line-height: 1.4em;
}
#legend a {
color: white;
}
</style>
<canvas></canvas>
<svg>
<defs>
<radialGradient id="grad1" cx="50%" cy="50%" r="50%" fx="50%" fy="50%">
<stop offset="0%" style="stop-color:black;stop-opacity:0" />
<stop offset="94%" style="stop-color:black;stop-opacity:0.01" />
<stop offset="97%" style="stop-color:black;stop-opacity:0.2" />
<stop offset="110%" style="stop-color:black;stop-opacity:0.6" />
</radialGradient>
</defs>
</svg>
<div id="legend">
<h1>Missions to Mars</h1>
<p>A d3-geo-voronoi demo<br>by <a href="https://bl.ocks.org/Fil/" target="_blank">Philippe Rivière</a>.</p>
</div>
<script id="vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;
void main(void) {
gl_Position = vec4(a_position, 0.0, 1.0);
}
</script>
<script id="fragment-shader" type="x-shader/x-fragment">
precision mediump float;
uniform sampler2D u_image;
uniform vec2 u_translate; /* width/2, height/2 */
uniform float u_scale; /* in pixels ! */
uniform vec3 u_rotate; /* rotation in degrees ! */
const float c_pi = 3.14159265358979323846264;
const float c_halfPi = c_pi * 0.5;
const float c_twoPi = c_pi * 2.0;
// Inclination of the equator on Mars = 25.19° (earth= 23.44)
const float declination = 25.19 / 90.0 * c_halfPi;
float phi0 = -u_rotate.y / 90.0 * c_halfPi;
float cosphi0 = cos(phi0);
float sinphi0 = sin(phi0);
void main(void) {
float x = (gl_FragCoord.x - u_translate.x) / u_scale;
float y = (u_translate.y - gl_FragCoord.y) / u_scale;
// inverse orthographic projection
float rho = sqrt(x * x + y * y);
// color if the point (px, py) does not exist in the texture
if (rho >= 1.0) {
gl_FragColor = texture2D(u_image, vec2(0.0, 0.0));
gl_FragColor[0] = 0.1*(rho-1.0+0.1);
gl_FragColor[1] = 0.06*(rho-1.0+0.1);
gl_FragColor[2] = 0.2*(rho-1.0+0.1);
}
else {
float c = asin(rho);
float sinc = sin(c);
float cosc = cos(c);
float lambda = atan(x * sinc, rho * cosc);
float phi = asin(y * sinc / rho);
// inverse rotation
float cosphi = cos(phi);
float x0 = cos(lambda) * cosphi;
float y0 = sin(lambda) * cosphi;
float cosgamma = cos(u_rotate.z / 90.0 * c_halfPi);
float singamma = sin(u_rotate.z / 90.0 * c_halfPi);
float x1 = x0 * cosgamma - y0 * singamma;
float y1 = y0 * cosgamma + x0 * singamma;
float z1 = y * sinc / rho;
lambda = atan(y1, x1 * cosphi0 + z1 * sinphi0) - u_rotate.x / 90.0 * c_halfPi;
phi = asin(z1 * cosphi0 - x1 * sinphi0);
// pixels
float px = (lambda + c_pi) / c_twoPi;
float py = (phi + c_halfPi) / c_pi;
gl_FragColor = texture2D(u_image, vec2(px, py));
// terminator ?? see https://github.com/joergdietrich/Leaflet.Terminator/blob/master/L.Terminator.js
// float sinh = sin(lambda)*sin(declination) + cos(lambda)*cos(declination)*cos(1.0);
// float intensity = (sinh > 0.0) ? 1.0 + 0.1*sinh : 0.2 + 0.8 * exp(6.0*sinh);
float intensity = 1.1; // boost the pixel by some factor
gl_FragColor[0] = intensity * gl_FragColor[0] * (1.3 - 0.3 * sqrt(gl_FragColor[0]));
gl_FragColor[1] = intensity * gl_FragColor[1];
gl_FragColor[2] = intensity * gl_FragColor[2];
}
}
</script>
<script>
// Select the canvas from the document.
var canvas = document.querySelector("canvas");
var width = +canvas.getAttribute('width') || 600,
height = +canvas.getAttribute('height') || 400;
width = Math.max(width, self.innerWidth);
height = Math.max(height, self.innerHeight);
// save the legend
var legend = d3.select('#legend').html();
// Create the WebGL context, with fallback for experimental support.
var context = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
// Compile the vertex shader.
var vertexShader = context.createShader(context.VERTEX_SHADER);
context.shaderSource(vertexShader, document.querySelector("#vertex-shader").textContent);
context.compileShader(vertexShader);
if (!context.getShaderParameter(vertexShader, context.COMPILE_STATUS)) throw new Error(context.getShaderInfoLog(vertexShader));
// Compile the fragment shader.
var fragmentShader = context.createShader(context.FRAGMENT_SHADER);
context.shaderSource(fragmentShader, document.querySelector("#fragment-shader").textContent);
context.compileShader(fragmentShader);
if (!context.getShaderParameter(fragmentShader, context.COMPILE_STATUS)) throw new Error(context.getShaderInfoLog(fragmentShader));
// Link and use the program.
var program = context.createProgram();
context.attachShader(program, vertexShader);
context.attachShader(program, fragmentShader);
context.linkProgram(program);
if (!context.getProgramParameter(program, context.LINK_STATUS)) throw new Error(context.getProgramInfoLog(program));
context.useProgram(program);
// Define the positions (as vec2) of the square that covers the canvas.
var positionBuffer = context.createBuffer();
context.bindBuffer(context.ARRAY_BUFFER, positionBuffer);
context.bufferData(context.ARRAY_BUFFER, new Float32Array([
-1.0, -1.0,
+1.0, -1.0,
+1.0, +1.0,
-1.0, +1.0
]), context.STATIC_DRAW);
// Bind the position buffer to the position attribute.
var positionAttribute = context.getAttribLocation(program, "a_position");
context.enableVertexAttribArray(positionAttribute);
context.vertexAttribPointer(positionAttribute, 2, context.FLOAT, false, 0, 0);
// Extract the projection parameters.
var translateUniform = context.getUniformLocation(program, "u_translate"),
scaleUniform = context.getUniformLocation(program, "u_scale"),
rotateUniform = context.getUniformLocation(program, "u_rotate");
// Load the reference image.
var image = new Image;
image.src = "Mars_Viking_MDIM21_ClrMosaic_global_1024.jpg";
image.onload = readySoon;
var projection = d3.geoOrthographic()
.translate([width / 2, height / 2])
.scale(0.95 * height / 2);
var path = d3.geoPath()
.projection(projection);
var svg = d3.select('svg')
.attr('width', width)
.attr('height', height);
svg._defs = svg.append("defs");
svg._clip = svg._defs.append("path")
.datum({
type: "Sphere"
})
.attr("id", "sphere");
svg._defs.append("clipPath")
.attr("id", "clip")
.append("use")
.attr("xlink:href", "#sphere");
svg._earth = svg.append('g')
.attr("clip-path", "url(#clip)")
.style('cursor', '-webkit-grab');
svg._polygons = svg._earth.append('g').attr('class', 'polygons');
svg._links = svg._earth.append('g').attr('class', 'links');
svg._missions = svg._earth.append('g').attr('class', 'missions');
svg._shade = svg.append("use")
.attr("class", "stroke")
.attr("xlink:href", "#sphere")
.attr("stroke", "black")
.attr("stroke-width", 3)
.attr("fill", "url(#grad1)")
.attr('pointer-events', 'none');
// Hack to ensure correct inference of window dimensions.
function readySoon() {
// https://raw.githubusercontent.com/rhodges/hodgimoto/master/app/layers/mars_landings.geojson
d3.json('mars_landings.geojson', function (err, missions) {
missions.features.push({"type":"Feature","properties":{"OBJECTID":-1,"ID":-1,"NAME":"Schiaparelli","X_COORD":0.2,"Y_COORD":357.5,"FULL_NAME":"ExoMars Schiaparelli EDM lander","NSSDC_ID":"2016-017A","WEB_LINK":"https://en.wikipedia.org/wiki/Schiaparelli_EDM_lander","COUNTRY":"Europe","YEAR":2016},"geometry":{"type":"MultiPoint","coordinates":[[0.2, 357.5]]}})
var v = svg._voronoi = d3.geoVoronoi()(missions),
polygons = v.polygons(),
urquhart = v.links().features.filter(function(l) {
return l.properties.urquhart;
});
svg._missions = svg._missions
.selectAll('path')
.data(missions.features);
var enter = svg._missions.enter().append('path');
svg._missions = svg._missions.merge(enter);
svg._polygons = svg._polygons
.selectAll('path')
.data(polygons.features);
var enter = svg._polygons.enter().append('path');
svg._polygons = svg._polygons.merge(enter);
svg._links = svg._links
.selectAll('path')
.data(urquhart);
var enter = svg._links.enter().append('path');
svg._links = svg._links.merge(enter);
setTimeout(function () {
resize();
ready();
}, 10);
});
}
// retina display
var devicePixelRatio = window.devicePixelRatio || 1;
function resize() {
canvas.setAttribute('width', width * devicePixelRatio);
canvas.setAttribute('height', height * devicePixelRatio);
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
context.uniform2f(translateUniform, width / 2 * devicePixelRatio, height / 2 * devicePixelRatio);
context.viewport(0, 0, width * devicePixelRatio, height * devicePixelRatio);
}
function ready() {
// Create a texture and a mipmap for accurate minification.
var texture = context.createTexture();
context.bindTexture(context.TEXTURE_2D, texture);
context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MAG_FILTER, context.LINEAR);
context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MIN_FILTER, context.LINEAR_MIPMAP_LINEAR);
context.texImage2D(context.TEXTURE_2D, 0, context.RGBA, context.RGBA, context.UNSIGNED_BYTE, image);
context.generateMipmap(context.TEXTURE_2D);
// The current rotation
var scale = scale0 = projection.scale(),
rotate = [0, 0, 0];
// Rotate and redraw!
function redraw() {
projection.scale(scale).rotate(rotate);
svg._missions.attr('d', path);
svg._polygons.attr('d', path);
svg._links.attr('d', path);
svg._clip.attr('d', path);
context.uniform1f(scaleUniform, scale * devicePixelRatio);
context.uniform3fv(rotateUniform, rotate);
context.bindTexture(context.TEXTURE_2D, texture); // XXX Safari
context.drawArrays(context.TRIANGLE_FAN, 0, 4);
}
svg
.on('mousemove', function () {
var p = d3.mouse(this),
c = projection.invert(p),
found;
// if we're on the Earth
if (c[0] !== 90 &&
(found = svg._voronoi.find(c[0], c[1], 0.8 /* radian */ ))) {
var center = d3.geoCentroid(svg._missions.data()[found.index]);
var circle = d3.geoCircle().center(center),
r = d3.geoLength({
type: "LineString",
coordinates: [c, center]
}) * 180 / Math.PI;
r = Math.max(1.1 * r, 5);
svg._earth.append('path')
.attr('class', 'glow')
.transition()
.attrTween('d', function () {
return function (t) {
return path(circle.radius(2 + r * d3.easePolyIn(t, 4))()) || ''; // empty path when the signal comes from the hidden side of the planet
};
})
.remove();
var p = svg._missions.data()[found.index].properties;
d3.select('#legend h1').text(p.NAME);
d3.select('#legend p').html(
'<a href="' + p.WEB_LINK + '">' + p.FULL_NAME + '</a>' +
'<br>' +
p.COUNTRY + ', ' + p.YEAR + '.'
/* +
'<br>' +
p.X_COORD + "×" + p.Y_COORD */
);
path.pointRadius(function (d, j) {
return j == found.index ? 8 : 4.5 /* 4.5 = default value */ ;
});
redraw();
} else {
d3.select('#legend').html(legend);
}
});
var lambda = d3.scaleLinear()
.domain([-width / 2, width / 2])
.range([-180, 180]);
var phi = d3.scaleLinear()
.domain([0, height])
.range([90, -90]);
var q, r, transform, d;
zoom = d3.zoom()
.scaleExtent([.8, 1.5])
.on("start", function () {
q = rotate, d = [0, 0, 0]; // accumulate change in d
r = d3.mouse(this);
svg._earth.style('cursor', '-webkit-grabbing');
})
.on("zoom.redraw", function () {
scale = scale0 * d3.event.transform.k;
var p = d3.mouse(this);
var dr = [lambda(p[0]) - lambda(r[0]), phi(p[1]) - phi(r[1])];
r = p;
// inverse dr[0] if the mouse is beyond one of the poles
var a = (phi(p[1]) - rotate[1]) * Math.PI / 180,
ca = Math.cos(a),
sa = Math.sin(a);
d = [d[0] + dr[0] * (ca < 0 ? -1 : 1),
d[1] + dr[1], d[2] + dr[0] * -sa];
rotate = [q[0] + d[0], q[1] + d[1], q[2] + 0 * d[2]];
redraw();
})
.on('end', function() {
svg._earth.style('cursor', '-webkit-grab');
});
d3.select("svg")
.call(zoom);
redraw();
var elapsed = null;
function animate(t) {
elapsed = t;
//d3.select("canvas").transition().call(zoom.transform, d3.zoomIdentity);
requestAnimationFrame(animate);
}
//animate();
}
// A polyfill for requestAnimationFrame.
if (!self.requestAnimationFrame) requestAnimationFrame =
self.webkitRequestAnimationFrame || self.mozRequestAnimationFrame || self.msRequestAnimationFrame || self.oRequestAnimationFrame || function (f) {
setTimeout(f, 17);
};
</script>
https://d3js.org/d3.v4.min.js