/* * demonstrates * genning textures with topojson and d3 * using d3 for transitions and interpolation, * and three.js for rendering the globe * * adapted from Mike Bostock's World Tour, http://bl.ocks.org/mbostock/4183330 * and Steve Hall's Interactive WebGL Globes with THREE.js and D3, * http://www.delimited.io/blog/2015/5/16/interactive-webgl-globes-with-threejs-and-d3 * all cruft and smells are mine. */ (function() { var genKey = function (arr) { var key = ''; arr.forEach(function (str) { key += str.toLowerCase().replace(/[^a-z0-9]/g, ''); }); return key; }; // used for working with three.js globe and lat/lon var twoPI = Math.PI * 2; var halfPI = Math.PI / 2;; var world = { glEl : {}, sunColor : '#fbfccc', countryColor : d3.rgb('orange').darker().darker().toString(), waterColor : '#0419a0', gratiColor : '', landColor : '#185f18', borderColor : '', d3Canvas : d3.select('#d3-canvas'), projection : d3.geo.equirectangular().translate([1024, 512]).scale(325), geoCache : { keys : {}, textures: [], init : function (countries, names) { var self = this; this.countries = countries.filter(function(d) { return names.some(function(n) { if (d.id == n.name) { d.name = d.id; return d.id = n.id; } }); }).sort(function(a, b) { return a.name.localeCompare(b.name); }); this.countries.forEach(function(country, cx) { country.key = genKey([country.name, country.id]) self.keys[country.id] = { name: country.name, idx : cx }; }); } }, init : function (opts) { this.glEl = d3.select(opts.selector); this.slug = d3.select('#slug'); this.gratiColor = d3.rgb(this.sunColor).darker().toString(); this.borderColor = d3.rgb(this.landColor).darker().toString(); var countries = topojson.feature(opts.data, opts.data.objects.countries).features; this.geoCache.init(countries, opts.names); this.initD3(opts); }, initD3 : function(opts) { // will create textures for three.js globe var land = topojson.feature(opts.data, opts.data.objects.countries); var borders = topojson.mesh( opts.data, opts.data.objects.countries, function(a, b) { return a !== b; } ); this.initThree({ selector: opts.selector, land : land, borders : borders }); }, scene : new THREE.Scene(), globe : new THREE.Object3D(), initThree : function(opts) { var segments = 155; // number of vertices. Higher = better mouse accuracy, slower loading // Set up cache for country textures var glRect = this.glEl.node().getBoundingClientRect(); var canvas = this.glEl.append('canvas') .attr('width', glRect.width) .attr('height', glRect.height); canvas.node().getContext('webgl'); this.renderer = new THREE.WebGLRenderer({ canvas: canvas.node(), antialias: true }); this.renderer.setSize(glRect.width, glRect.height); this.renderer.setClearColor( 0x000000 ); this.glEl.node().appendChild(this.renderer.domElement); this.camera = new THREE.PerspectiveCamera(70, glRect.width / glRect.height, 1, 5000); this.camera.position.z = 1000; var ambientLight = new THREE.AmbientLight(this.sunColor); this.scene.add(ambientLight); var light = new THREE.DirectionalLight( this.sunColor, .85 ); light.position.set(this.camera.position.x, this.camera.position.y + glRect.height/2, this.camera.position.z); this.scene.add( light ); // base globe with 'water' var waterMaterial = new THREE.MeshPhongMaterial({ color: this.waterColor, transparent: true }); var sphere = new THREE.SphereGeometry(200, segments, segments); var baseGlobe = new THREE.Mesh(sphere, waterMaterial); baseGlobe.rotation.y = Math.PI + halfPI; // centers inital render at lat 0, lon 0 // base map with land, borders, graticule var baseMap = this.genMesh({ land: opts.land, borders: opts.borders }); // add the two meshes to the container object this.globe.scale.set(2.5, 2.5, 2.5); this.globe.add(baseGlobe); this.globe.add(baseMap); this.scene.add(this.globe); this.renderer.render(this.scene, this.camera); this.rotateTo(this.geoCache.countries, 0, this.geoCache.countries.length); var self = this; window.addEventListener('resize', function(evt) { requestAnimationFrame(function () { var glRect = self.glEl.node().getBoundingClientRect(); self.camera.aspect = glRect.width / glRect.height; self.camera.updateProjectionMatrix(); self.renderer.setSize(glRect.width, glRect.height); self.renderer.render(self.scene, self.camera); }); }); }, rotateTo : function (countries, cx, cLen) { var self = this; var globe = this.globe; var country = countries[cx]; var mesh = this.genMesh({country : country}); var from = { x: globe.rotation.x, y: globe.rotation.y }; var centroid = d3.geo.centroid(country) var to = { x: this.latToX3(centroid[1]), y: this.lonToY3(centroid[0]) } globe.add(mesh); var hasta = globe.getObjectByName(this.currentId); this.setSlug(country.name); if (hasta) { globe.remove(hasta) requestAnimationFrame(function() { self.renderer.render(self.scene, self.camera); }); } this.currentId = country.key; requestAnimationFrame(function() { self.renderer.render(self.scene, self.camera); }); d3.transition() .delay(500) .duration(1250) .each('start', function() { self.terpObj = d3.interpolateObject(from, to); }) .tween('rotate', function() { return function (t) { globe.rotation.x = self.terpObj(t).x; globe.rotation.y = self.terpObj(t).y; requestAnimationFrame(function() { self.renderer.render(self.scene, self.camera); }); }; }) .transition() .each('end', function () { cx += 1; if (cx >= cLen) { cx = 0; } self.rotateTo(countries, cx, cLen); }); }, genMesh : function (opts) { var rotation; var segments = 155; var texture = this.genTexture(opts); var material = new THREE.MeshPhongMaterial({ map: texture, transparent: true }); var mesh = new THREE.Mesh(new THREE.SphereGeometry(200, segments, segments), material); if ( opts.land ) { mesh.name = 'land'; mesh.rotation.y = Math.PI + halfPI; } else { mesh.name = opts.country.key; rotation = this.globe.getObjectByName('land').rotation; mesh.rotation.x = rotation.x; mesh.rotation.y = rotation.y; } return mesh; }, setSlug : function (countryname) { var self = this; this.slug.transition() .duration(500) .style('opacity', 0) .each('end', function () { self.slug.text(countryname); }) .transition() .duration(1250) .style('opacity', 1); }, genTexture : function(opts) { var graticule; var ctx = this.d3Canvas.node().getContext('2d'); ctx.clearRect(0, 0, 2048, 1024); var path = d3.geo.path() .projection(this.projection) .context(ctx); if (opts.land) { graticule = d3.geo.graticule(); ctx.fillStyle = this.landColor, ctx.beginPath(), path(opts.land), ctx.fill(); ctx.strokeStyle = this.borderColor, ctx.lineWidth = .5, ctx.beginPath(), path(opts.borders), ctx.stroke(); ctx.strokeStyle = this.gratiColor, ctx.lineWidth = .25, ctx.beginPath(), path(graticule()), ctx.stroke(); } if (opts.country) { ctx.fillStyle = this.countryColor, ctx.beginPath(), path(opts.country), ctx.fill(); } // DEBUGGING, disable when done. // testImg(canvas.node().toDataURL()); var texture = new THREE.Texture(this.d3Canvas.node()); texture.needsUpdate = true; return texture; }, /* x3ToLat & y3ToLon adapted from Peter Lux, http://www.plux.co.uk/converting-radians-in-degrees-latitude-and-longitude/ convert three.js rotation.x & rotation.y (radians) to lat/lon globe.rotation.x + blah === northward globe.rotation.y - blah === southward globe.rotation.y + blah === westward globe.rotation.y - blah === eastward */ x3ToLat : function(rad) { // convert radians into latitude // 90 to -90 // first, get everthing into the range -2pi to 2pi rad = rad % (Math.PI*2); // convert negatives to equivalent positive angle if (rad < 0) { rad = twoPI + rad; } // restrict to 0 - 180 var rad180 = rad % (Math.PI); // anything above 90 is subtracted from 180 if (rad180 > Math.PI/2) { rad180 = Math.PI - rad180; } // if greater than 180, make negative if (rad > Math.PI) { rad = -rad180; } else { rad = rad180; } return (rad/Math.PI*180); }, latToX3 : function(lat) { return (lat / 90) * halfPI; }, y3ToLon : function(rad) { // convert radians into longitude // 180 to -180 // first, get everything into the range -2pi to 2pi rad = rad % twoPI; if (rad < 0) { rad = twoPI + rad; } // convert negatives to equivalent positive angle var rad360 = rad % twoPI; // anything above 90 is subtracted from 360 if (rad360 > Math.PI) { rad360 = twoPI - rad360; } // if greater than 180, make negative if (rad > Math.PI) { rad = -rad360; } else { rad = rad360; } return rad / Math.PI * 180; }, lonToY3 : function(lon) { return -(lon / 180) * Math.PI; } }; function testImg(dataURI) { var img = document.createElement('img'); img.src = dataURI; img.width = 2048; img.height = 1024; document.body.appendChild(img); } var loaded = function (error, geojson, names) { var worldOpts = { selector : '#three-box', data : geojson, names : names }; world.init(worldOpts); }; window.addEventListener('DOMContentLoaded', function () { queue() .defer(d3.json, 'world.json') .defer(d3.tsv, 'world-country-names.tsv') .await(loaded); }); }());