/* * 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; }; var peeps = { data : [], keys : {}, init : function (people) { var self = this; this.data = people; this.data.forEach(function(peep, px){ var key = genKey([ peep.firstname, peep.lastname, px + '' ]) peep.id = key; self.keys[key] = { idx : px }; }); }, find : function(key) { return this.data[this.keys[key].idx]; } }; var list = { el: {}, selectBox : {}, listBox : {}, init : function (opts) { var self = this; var template = opts.template; var select = document.createElement('select'); var ul = document.createElement('ul'); this.el = d3.select(opts.selector); peeps.data.forEach(function (peep) { select.appendChild(list.crtOption(peep)); ul.appendChild(list.crtLi(peep, opts.template)); }); var showAll = { firstname : 'Show', lastname : 'All', id : 'all'} select.appendChild(this.crtOption(showAll)); this.selectBox = d3.select(opts.selector + ' .select-box'); this.selectBox.node().appendChild(select); this.listBox = d3.select(opts.selector + ' .list-box'); this.listBox.node().appendChild(ul); this.el.selectAll('li img').on('click', function () { var id = this.parentElement.parentElement.id; var peep = peeps.find(id); world.rotateTo([ peep ], 0, 1); }); this.el.select('select').on('change', function () { if ( this.value == 'all' ) { world.rotateTo(peeps.data, 0, peeps.data.length); } else { world.rotateTo([peeps.find(this.value)], 0, 1); } }); }, crtOption : function (opt) { var option = document.createElement('option'); option.value = opt.id; option.textContent = opt.firstname + ' ' + opt.lastname; return option; }, crtLi : function (peep, template) { var li = document.createElement('li'); li.id = peep.id; var peepBox = document.querySelector(template).cloneNode(true); var peepName = peepBox.querySelector('.peep-name'); peepName.textContent = peep.firstname + ' ' + peep.lastname; var peepImg = peepBox.querySelector('img'); peepImg.src = peep.img; peepImg.alt = 'image of ' + peep.firstname + ' ' + peep.lastname; var peepLink = peepBox.querySelector('a'); peepLink.href = 'https://twitter.com/' + peep.twitter var peepHandle = peepBox.querySelector('.twitter'); peepHandle.textContent = '@' + peep.twitter; li.appendChild(peepBox); return li; }, transScroll : function (id) { var offsetTop = document.querySelector('#' + id).offsetTop; var scrollTween = function (t) { return function() { var terpRound = d3.interpolateRound(this.scrollTop, offsetTop); return function(t) { this.scrollTop = terpRound(t); }; }; }; this.selectBox.select('select').node().value = id; this.listBox.transition() .duration(1250) .tween('scrollTween', scrollTween(0)); } }; var twoPI = Math.PI * 2; var halfPI = Math.PI / 2;; var world = { el : {}, init : function (opts) { d3.select('.loading').transition() .duration(500) .style('opacity', 0) .remove(); this.el = d3.select(opts.selector); this.slug = this.el.select(".slug"); this.gratiColor = d3.rgb(this.sunColor).darker().toString(); this.landColor = d3.rgb(this.countryColor).darker(0.5).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); }, geoCache : { keys : {}, 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){ self.keys[country.id] = { name: country.name, idx : cx }; }); }, find : function(id) { var id = id + ''; return this.keys[id]; } }, sunColor : '#fbfccc', countryColor : '#1d721d', waterColor : '#0419a0', gratiColor : '', landColor : '', borderColor : '', initD3 : function(opts) { // creates the 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; } ); var mapTexture = this.genTexture({ land: land, borders : borders }); var self = this; // var start = Date.now(); peeps.data.forEach(function(peep, px) { var marker = self.genGeoMarker( [.7, .4], peep.location.lon, peep.location.lat ); var idx = self.geoCache.find(peep.location.id).idx; var country = self.geoCache.countries[idx]; peep.texture = self.genTexture({ country: country, marker: marker }); }); // console.log('Peep textures genned in ' + (Date.now() - start) + 'ms') this.initThree({ selector: opts.selector, mapTexture : mapTexture }); }, glEl : {}, 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 this.glEl = this.el.select('.three-box'); 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 mapMaterial = new THREE.MeshPhongMaterial({ map: opts.mapTexture, transparent: true }); var baseMap = new THREE.Mesh(new THREE.SphereGeometry(200, segments, segments), mapMaterial); baseMap.name = 'land'; baseMap.rotation.y = Math.PI + halfPI; // add the two meshes to the container object this.globe.scale.set(2.8, 2.8, 2.8); this.globe.add(baseGlobe); this.globe.add(baseMap); this.scene.add(this.globe); this.renderer.render(this.scene, this.camera); 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 (spinTo, sx, sLen) { var self = this; var globe = this.globe; var peep = spinTo[sx]; var from = { x: globe.rotation.x, y: globe.rotation.y }; var to = { x: this.latToX3(peep.location.lat), y: this.lonToY3(peep.location.lon) } var peepMesh = this.genMesh(peep); globe.add(peepMesh); var hasta = globe.getObjectByName(this.currentId); list.transScroll(peep.id); this.setSlug(peep); if (hasta) { globe.remove(hasta) requestAnimationFrame(function() { self.renderer.render(self.scene, self.camera); }); } this.currentId = peep.id; 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 () { sx += 1; if (sx < sLen) { self.rotateTo(spinTo, sx, sLen); } else { return; } }); }, setSlug : function (peep) { var self = this; this.slug.transition() .duration(500) .style("opacity", 0) .each('end', function () { self.slug.select(".slug-img") .style("background-image", "url(" + peep.img + ")"); self.slug.select(".name") .text(peep.firstname + " " + peep.lastname + " - "); self.slug.select("a") .attr("href", "https://twitter.com/" + peep.twitter) self.slug.select(".twitter") .text("@" + peep.twitter); self.slug.select('.city') .text(peep.location.city + ", "); self.slug.select('.loc').text( function () { return peep.location.state ? peep.location.state + ", " + peep.location.country : peep.location.country; }); }) .transition() .duration(1250) .style("opacity", 1); }, genGeoMarker : function (angles, lon, lat) { var marker = []; angles.forEach(function (angle) { marker.push( d3.geo.circle().origin([lon, lat]).angle(angle)() ); }); return marker; }, genTexture : function(opts) { var projection = d3.geo.equirectangular().translate([1024, 512]).scale(325); var graticule; var canvas = d3.select('body').append('canvas') .style('display', 'none') .attr('width', '2048px') .attr('height', '1024px'); var ctx = canvas.node().getContext('2d'); var path = d3.geo.path() .projection(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(); } if (opts.marker) { ctx.fillStyle = '#fff', ctx.beginPath(), path(opts.marker[0]), ctx.fill(); ctx.strokeStyle = '#000', ctx.lineWidth = 1.5, ctx.beginPath(), path(opts.marker[0]), ctx.stroke(); ctx.fillStyle = '#e500ff', ctx.beginPath(), path(opts.marker[1]), ctx.fill(); } // DEBUGGING, disable when done. // testImg(canvas.node().toDataURL()); var texture = new THREE.Texture(canvas.node()); texture.needsUpdate = true; canvas.remove(); return texture; }, genMesh : function (peep) { var material, mesh, rotation; var segments = 155; material = new THREE.MeshPhongMaterial({ map: peep.texture, transparent: true }); mesh = new THREE.Mesh(new THREE.SphereGeometry(200, segments, segments), material); mesh.name = peep.id; rotation = this.globe.getObjectByName('land').rotation; mesh.rotation.x = rotation.x; mesh.rotation.y = rotation.y; return mesh; }, /* 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 everything 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, people, geojson, names) { var listOpts = { selector : '.peeps-box', template : '#templates .peep-box' }; var worldOpts = { selector : '.globe-box', data : geojson, names : names }; peeps.init(people); world.init(worldOpts); list.init(listOpts); }; window.addEventListener('DOMContentLoaded', function () { queue() .defer(d3.json, 'peeps.json') .defer(d3.json, 'world.json') .defer(d3.tsv, 'world-country-names.tsv') .await(loaded); }); }());