function geoFlow() { var data = { countries: { features: [] }, cities: { features: [] }, occasions: { features: [] } }; var options = { id: undefined, width: window.innerWidth, height: window.innerHeight, display: { stars: 0, cities: true, occasions: true, flows: false, transform: 1.6, sizeToFit: false, // parent element externally sized }, zoom: { northup: true, lngLmt: 80, time: 1500, }, user: { location: true, update: false, // updates on load; when true can interrupt transitions stroke: "blue", fillOpacity: 0.2, fill: "white" }, world: { surround: undefined, velocity: [.01, -0], }, map: { dragging: true, ortho: true }, cities: { "fill": "#1075fe", "fillOpacity": 0.5, "stroke": "#1075fe", "strokeOpacity": 0.2, }, graticule: { outline: { fill: '#def', stroke: '#000', width: '0px', }, fill: '#def', stroke: '#FFF', width: '0px' }, geodesic: { fill: 'none', stroke: '#FFF', width: '0px' }, oceans: { fill: '#def', }, land: { focus: undefined, fill: undefined, stroke: '#fff' }, tour: { delay: 1500, } }; // consider replacing with an externally definable function options.radius = Math.min(options.height * .45, options.width * .45); var events = { 'ready': null, 'settings': { 'click': null }, 'update': { 'begin': null, 'end': null }, 'land': { 'mouseover': null, 'mouseout': null, 'click': null }, 'city': { 'mouseover': null, 'mouseout': null, 'click': null }, }; var starList = createStars(300); var country_names = {}; var spin_rotation; var spin_start; var projectionGlobe; var projectionGraticule; var projectionMap; var projection; var spacePath; var background; var geoPath; var world; var surface; var poi; var update; var user_position; navigator.geolocation.getCurrentPosition(function(pos){ user_position = pos; if (typeof update == 'function' && options.user.update) update(); }); function globe(selection) { selection.each(function () { var dom_parent = d3.select(this); var color = d3.scaleOrdinal(d3.schemeCategory20); world = dom_parent.append("svg") background = world.append("g").attr('id', 'space'); surface = world.append("g").attr('id', 'surface'); poi = world.append("g").attr('id', 'poi'); var graticule = d3.geoGraticule(); var g_outline = surface.append("g") .attr("id", "g_outline") .append("path") .attr("class", "graticule outline path") .datum(graticule.outline) var oceans = surface.append("g") .attr('id', 'oceans') var ocean_defs = oceans.append('defs'); var ocean = ocean_defs.append("path") .datum({type: "Sphere"}) .attr("id", "sphere") .attr("class", "sphere ocean path") oceans.append("use") .attr("class", "sphere") .attr("xlink:href", "#sphere"); var grid = surface.append("g").attr('id', 'graticule'); var landfeatures = surface.append("g").attr('id', 'land'); var cityfeatures = poi.append("g").attr('id', 'cities'); var occasions = poi.append('g').attr('id', 'occasions'); var g_line = grid.append("path") .datum(graticule) .attr("class", "graticule line path"); var gd_line; if (d3.geodesic) { gd_line = grid.append("path") .datum(d3.geodesic.multilinestring(7)) .attr("class", "graticule path") } update = function(opts) { // resize to dimensions of containing element if (options.display.sizeToFit || (opts && opts.sizeToFit)) { var dims = dom_parent.node().getBoundingClientRect(); options.width = Math.max(dims.width, 200); options.height = Math.max(Math.min(dims.height, window.innerHeight), 200); } options.radius = Math.min(options.height * .45, options.width * .45); world // .attr({ .attrs({ 'width': options.width, 'height': options.height, }) // .style({ .styles({ 'background': options.world.surround, 'pointer-events': 'all' }); // preserve any prior scale if (options.map.ortho) { var scale = projection ? projection.scale() : options.radius; var scale = opts && opts.sizeToFit ? options.radius : scale; } else { var scale = projection ? projection.scale() : options.radius / options.display.transform; var scale = opts && opts.sizeToFit ? options.radius / options.display.transform : scale; } var space = d3.geoAzimuthalEquidistant() .scale(450) .rotate([80,-90,0]) .center([0, 0]); spacePath = d3.geoPath() .projection(space) .pointRadius(2); projectionGlobe = d3.geoOrthographic() .scale(scale) .center([0, 0]) .translate([options.width / 2, options.height / 2]) .clipAngle(90); projectionMap = d3.geoEquirectangular() .scale(scale) .center([0, 0]) .translate([options.width / 2, options.height / 2]) // preserve any prior rotation var rotation = projection ? projection.rotate() : [0, 0, 0]; projection = options.map.ortho ? projectionGlobe : projectionMap; projection.rotate(rotation); geoPath = d3.geoPath().projection(projection); ocean .attr("d", geoPath) .styles({ 'fill': options.oceans.fill, 'stroke': 'none', 'stroke-width': '1px', }); g_line .attr("d", geoPath) .styles({ 'fill': options.graticule.fill, 'stroke': options.graticule.stroke, 'stroke-width': options.graticule.width, }); if (d3.geodesic) { gd_line .attr("d", geoPath) .styles({ 'fill': options.geodesic.fill, 'stroke': options.geodesic.stroke, 'stroke-width': options.geodesic.width, }); } if (rotation[0] == 0 && rotation[1] == 0 && rotation[2] == 0) { g_outline .attr("d", geoPath) .styles({ 'fill': options.graticule.outline.fill, 'stroke': options.graticule.outline.stroke, 'stroke-width': options.graticule.outline.width }); } var λ = d3.scaleLinear() .domain([0, options.width]) .range([-180, 180]); var φ = d3.scaleLinear() .domain([0, options.width]) .range([90, -90]); world .call(d3.drag() .subject(function() { var r = projection.rotate(); return {x: λ.invert(r[0]), y: φ.invert(r[1]) }; }) .on("drag", function() { if (options.map.dragging) { var lat = λ(d3.event.x); var lng = φ(d3.event.y); lng = lng > options.zoom.lngLmt ? options.zoom.lngLmt : lng < -options.zoom.lngLmt ? -options.zoom.lngLmt : lng; // insure that spinning globe is in sync with this transition spin_rotation = [lat, lng]; spin_start = Date.now(); projection.rotate([lat, lng]); space.rotate([-lat, -lng]); reDraw(); } })) addLand(); addCities(); addStars(); addOccasions(); addFlows(); addUserLocation(); reDraw(); function addLand() { var land = landfeatures.selectAll("path.countries") .data(data.countries.features) land.exit().remove(); land.enter() .append("path") .attr("class", "countries path") .on("mouseover", events.land.mouseover) .on("mouseout", events.land.mouseout) .on("mousemove", events.land.mousemove) .on("click", events.land.click) .merge(land) .attr("d", geoPath) .styles({ 'fill': landColor, // disables CSS functionality 'stroke': options.land.stroke, }); function landColor(d, i) { if (data.neighbors) { if (d.color) return d.color; return color(d.color = d3.max(data.neighbors[i], function(n) { return data.countries.features[n].color; }) + 1 | 0); } else { return options.land.fill; } } } function addCities() { if (!options.display.cities) { cityfeatures.selectAll("path.cities").remove(); return; } var population_array = data.cities.features.map(function(f) { return f.properties.population; }); var max_population = population_array.sort(d3.descending)[0]; if (max_population) { var rMin = 0; var peoplePerPixel = 20000000 / options.radius; // consider making this configurable var rMax = Math.sqrt(max_population / (peoplePerPixel * Math.PI)); // var rScale = d3.scale.sqrt(); var rScale = d3.scaleSqrt(); rScale.domain([0, max_population]); rScale.range([rMin, rMax]); data.cities.features.forEach(function(c) { c.properties.radius = rScale(c.properties.population); }); } var cities = cityfeatures.selectAll("path.cities") .data(data.cities.features) cities.exit().remove(); cities.enter() .append("path") .attr("class", "cities") .merge(cities) .attr("d", pointPath) .styles({ "fill": options.cities.fill, "fill-opacity": options.cities.fillOpacity, "stroke": options.cities.stroke, "stroke-opacity": options.cities.strokeOpacity, }) .on("click", function(d, i) { globe.zoom2(i, 'city'); d3.selectAll('.cities').classed('city-selected', false); d3.select(this).classed("city-selected", true); }); } function addOccasions() { if (!options.display.occasions || !data.occasions.features.length) { occasions.selectAll('g').remove(); return; } var og = occasions.selectAll('g') .data(data.occasions.features); og.exit().remove(); og.enter().append('g') .attrs({ 'class': 'occasion', 'id': function(d) { return d.id; }, }) var pc = og.selectAll('.pulse-circle') .data(function(d) { return [d]; }, get_key); pc.exit().remove(); pc.enter() .append("path") .attr('class', 'occ pulse-circle') .merge(pc) .style("fill", 'white') .attr("d", pointPath) var oc = og.selectAll('.oc-circle') .data(function(d) { return [d]; }, get_key); oc.exit().remove(); oc.enter() .append('path') .attr('class', 'occ oc-circle') .merge(oc) .attr("d", pointPath) .styles({ "fill" : 'red', 'opacity': 0.75, }) var oct = oc.selectAll('.oct') .data(function(d) { return [d]; }, get_key); oct.exit().remove(); oct.enter().append('title') .attr('class', 'oct') .merge(oct) .text(function(d) { return 'Magnitue ' + d.properties.mag + ' ' + d.properties.place; }) } function addFlows() { if (!options.display.flows || !data.occasions.features.length) { cityfeatures.selectAll(".arcPath").remove(); return; } var points = data.occasions.features.map(m => m.geometry.coordinates) for (var n = 1, e = points.length, coords = []; ++n < e;) { coords.push({ type: "LineString", coordinates: [points[n-1], points[n]] }); } var arcs = cityfeatures.selectAll(".arcPath") .data(coords); arcs.exit().remove(); arcs .enter() .append("path") .merge(arcs) .attrs({ 'class': 'arcPath path', 'fill': 'none', 'stroke': 'red', 'stroke-width': '1px' }) .attr("d", geoPath) } function addUserLocation() { if (!options.user.location || !user_position) { cityfeatures.selectAll('.userloc').remove(); return; } var coords = [user_position.coords.longitude, user_position.coords.latitude]; var locations = cityfeatures.selectAll('.userloc') .data([coords]); locations.exit().remove(); locations.enter().append("circle") .attr('class', 'userloc') .merge(locations) .attrs({ cx: function(d) { return projection(d)[0] }, cy: function(d) { return projection(d)[1] }, r: options.radius / 20, // consider making this configurable }) .styles({ "stroke": options.user.stroke, "fill-opacity": options.user.fillOpacity, "fill": options.user.fill }); } function addStars() { if (!options.display.stars) { background.selectAll('path').remove(); } var constellations = background.selectAll(".star") .data(starList); constellations.exit().remove(); constellations.enter() .append("path") .attrs({ "class": "star", "fill": "white" }) .attr("d", function(d){ spacePath.pointRadius(d.properties.radius); return spacePath(d); }); } } }); } // -------------------------- FUNCTIONS ---------------------------- var get_key = function(d) { return d && d.key; }; function animateTransition(interProj) { return new Promise(function (resolve, reject) { world.transition() .duration(2500) .tween("projection", function() { return function(_) { interProj.alpha(_); reDraw(true); }; }) .on('end', resolve); }); } var pointPath = function(d, i, data, r) { if (d.properties && d.properties.radius != undefined) { r = r || d.properties.radius; } r = r || 1.5; var coords = [d.geometry.coordinates[0], d.geometry.coordinates[1]]; var pr = geoPath.pointRadius(globe.scale() / 100 * r); var rez = pr({ type: "Point", coordinates: coords }); return rez; } var circlePath = function(d) { var circle = d3.geo.circle(); var coords = [d.geometry.coordinates[0], d.geometry.coordinates[1]]; var cc = circle.origin(coords).angle(.5)(); return geoPath(cc); } function reDraw() { background.selectAll("path").attr("d", spacePath); surface.selectAll("path").attr("d", geoPath); poi.selectAll(".path").attr("d", geoPath); poi.selectAll('.cities').attr('d', pointPath) poi.selectAll('.occ').attr('d', pointPath) poi.selectAll(".userloc") .attr('cx', function(d) { return projection(d)[0] }) .attr('cy', function(d) { return projection(d)[1] }) } globe.changeFocus = changeFocus; function changeFocus(focusID) { surface.selectAll("path") .classed("focused", function(d, i) { return focusID && d && d.id && d.id == focusID ? options.land.focus = d.id : false; }); } // scale_pct is % of parent element (visible space); won't go below 100; globe.scale = function(scale_pct) { if (!arguments.length) return +((projection.scale() / options.radius) * 100).toFixed(2); var scale = scale_pct / 100 * options.radius; return globe.coords(undefined, scale, true); } globe.coords = function(coords, scale) { return new Promise(function (resolve, reject) { var current = projection.rotate(); coords = coords || projection.rotate(); scale = scale || projection.scale(); scale = Math.max(options.map.ortho ? options.radius : options.radius / options.display.transform, scale); // if already at target coordinates, do nothing; resolve. if (current[0] == coords[0] && current[1] == coords[1] && current[2] == coords[2] && scale == projection.scale()) { resolve(); return; } // insure that spinning globe is in sync with this transition spin_rotation = coords; spin_start = Date.now(); world.transition() .duration(options.zoom.time) .tween("rotate", function() { var r = d3.interpolate(projection.rotate(), coords); var s = d3.interpolate(projection.scale(), scale); return function(t) { projection.rotate(r(t)).scale(s(t)); reDraw(); }; }) .on('end', resolve); }); }; globe.g2m = function() { return new Promise(function (resolve, reject) { if (!options.map.ortho) { globe.rotate2().then(resolve); return; } var current = projection.rotate(); if (current[0] == 0 && current[1] == 0 && current[2] == 0) { transform(); } else { globe.rotate2().then(transform); } function transform() { projection = interpolatedProjection(projectionGlobe, projectionMap, false); projection.scale(options.radius / options.display.transform); geoPath.projection(projection); animateTransition(projection) .then(resolve, reject); surface.selectAll("path").classed("ortho", options.map.ortho = false); } }); } globe.m2g = function() { return new Promise(function (resolve, reject) { if (options.map.ortho) { globe.rotate2().then(resolve); return; } var current = projection.rotate(); if (current[0] == 0 && current[1] == 0 && current[2] == 0) { transform(); } else { globe.rotate2().then(transform); } function transform() { projection = interpolatedProjection(projectionMap, projectionGlobe, true); projection.scale(options.radius); geoPath.projection(projection); animateTransition(projection) .then(resolve, reject); surface.selectAll("path").classed("ortho", options.map.ortho = true); } }) } globe.snap2 = function(coords, scale) { coords = coords || [0, 0, 0]; scale = scale || options.radius; projection.rotate(coords).scale(scale); reDraw(); } globe.rotate2 = function(coords) { coords = coords || [0, 0, 0]; return globe.coords(coords, options.map.ortho ? options.radius : options.radius / options.display.transform); } globe.reset = function() { return new Promise(function (resolve, reject) { globe.spin(false); globe.tour(false); changeFocus(undefined); globe.rotate2() .then(finish, reject) function finish() { globe.update({sizeToFit: true}); resolve(); } }); } globe.pause = function(time) { return new Promise(function (resolve, reject) { setTimeout(function() { resolve(); }, time); }); } globe.bounce2 = function(what, which) { return new Promise(function (resolve, reject) { globe.rotate2().then(function() { globe.zoom2(what, which).then(resolve, reject); }, reject) }); } var tour_countries; // start can specify first country or false to terminate tour globe.tour = function(start, bounce) { start = start == undefined ? true : start; return new Promise(function (resolve, reject) { if (!start) { tour_countries = []; return resolve(); } tour_countries = Object.keys(country_names).sort(); if (typeof start == 'string') { var index = tour_countries.indexOf(start); if (index >= 0) { tour_countries = tour_countries.slice(index).concat(tour_countries.slice(0, index)).reverse(); } else { tour_countries.reverse(); } } nextCountry(); function nextCountry() { if (!tour_countries.length) { return resolve(); } country = tour_countries.pop(); if (!country) { nextCountry(); } else { console.log(country); if (bounce) { globe.bounce2(country) .then(delayNext, reject); } else { globe.zoom2(country, undefined, false) .then(delayNext, reject); } } } function delayNext(result) { setTimeout(function() { nextCountry(); }, options.tour.delay); } }); } globe.zoom2 = function(what, which, zoom) { return new Promise(function (resolve, reject) { which = which || 'country'; zoom = zoom == undefined ? true : zoom; var coords = [0, 0, 0]; var scale = options.radius; if (!isNaN(what) && what < data.countries.features.length && which == 'country') { coords = d3.geoCentroid(data.countries.features[what]).map(function(m) { return -1 * m; }); changeFocus(undefined); } else if (!isNaN(what) && what < data.cities.features.length && which == 'city') { coords = d3.geoCentroid(data.cities.features[what]).map(function(m) { return -1 * m; }); changeFocus(undefined); } else if (Object.keys(country_names).indexOf(what) >= 0) { coords = d3.geoCentroid(country_names[what]).map(function(m) { return -1 * m; }); var b = d3.geoBounds(country_names[what]); var dx = b[1][0] - b[0][0]; var dy = b[1][1] - b[0][1]; var x = (b[0][0] + b[1][0]) / 2; var y = (b[0][1] + b[1][1]) / 2; bbox = .1 / Math.max(dx / options.width, dy / options.height); scale = zoom ? options.radius * bbox : options.radius; scale = Math.max(scale, options.radius); changeFocus(country_names[what].id); } else { changeFocus(undefined); } if (options.zoom.northup && coords.length == 2) coords.push(0); globe.coords(coords, scale) .then(resolve, reject); }); } function interpolatedProjection(a, b, ortho) { // var projection = d3.geo.projection(raw).scale(1), var projection = d3.geoProjection(raw).scale(1), center = projection.center, translate = projection.translate, clip = projection.clipAngle, α; function raw(λ, φ) { var pa = a([λ *= 180 / Math.PI, φ *= 180 / Math.PI]), pb = b([λ, φ]); return [(1 - α) * pa[0] + α * pb[0], (α - 1) * pa[1] - α * pb[1]]; } projection.alpha = function(_) { if (!arguments.length) return α; α = +_; var ca = a.center(), cb = b.center(), ta = a.translate(), tb = b.translate(); center([(1 - α) * ca[0] + α * cb[0], (1 - α) * ca[1] + α * cb[1]]); translate([(1 - α) * ta[0] + α * tb[0], (1 - α) * ta[1] + α * tb[1]]); if (ortho === true) {clip(180 - α * 90);} return projection; }; projection.alpha(0).scale = b.scale; delete projection.translate; delete projection.center; return projection.alpha(0); } globe.update = function(opts) { if (events.update.begin) events.update.begin(); if (typeof update === 'function') update(opts); if (events.update.end) events.update.end(); } // allows updating individual options and suboptions // while preserving state of other options globe.options = function(values) { if (!arguments.length) return options; keyWalk(values, options); return globe; } function keyWalk(valuesObject, optionsObject) { if (!valuesObject || !optionsObject) return; var vKeys = Object.keys(valuesObject); var oKeys = Object.keys(optionsObject); for (var k=0; k < vKeys.length; k++) { if (oKeys.indexOf(vKeys[k]) >= 0) { var oo = optionsObject[vKeys[k]]; var vo = valuesObject[vKeys[k]]; if (typeof oo == 'object' && typeof vo !== 'function' && oo.constructor !== Array) { keyWalk(valuesObject[vKeys[k]], optionsObject[vKeys[k]]); } else { optionsObject[vKeys[k]] = valuesObject[vKeys[k]]; } } } } globe.events = function(functions) { if (!arguments.length) return events; keyWalk(functions, events); return globe; } globe.width = function(value) { if (!arguments.length) return options.width; options.width = value; return globe; }; globe.height = function(value) { if (!arguments.length) return options.height; options.height = value; return globe; }; globe.neighbors = function(neighbors) { if (!arguments.length) return data.neighbors; if (!neighbors || typeof neighbors != 'object' || !neighbors.length) return globe; data.neighbors = neighbors; return globe; } function geometryTypes(features) { types = []; for (element in features) { var type = features[element].geometry.type; if (types.indexOf(type) < 0) types.push(type); } return types; } globe.data = function(new_data) { if (!arguments.length) return data; if (!new_data || typeof new_data != 'object') return globe; if (new_data.type == 'Topology') { var countries = topojson.feature(new_data, new_data.objects.countries); globe.neighbors(topojson.neighbors(new_data.objects.countries.geometries)); if (!countries.features) return globe; data.countries = countries; data.countries.features.forEach(function(c) { country_names[c.properties.name] = c; }); } else if (new_data.type == 'FeatureCollection') { var elements = new_data if (!elements.features) return globe; var feature_types = geometryTypes(elements.features); if (feature_types.indexOf('Polygon') >= 0 || feature_types.indexOf('MultiPolygon') >=0) { data.countries = elements; data.countries.features.forEach(function(c) { country_names[c.properties.name] = c; }); } else if (feature_types.length == 1 && feature_types.indexOf('Point') >= 0) { data.cities = elements; } } return globe; } globe.occasions = function(new_occasions) { if (!arguments.length) return data.occasions; data.occasions = new_occasions; return globe; } globe.duration = function(time) { if (!arguments.length) return options.zoom.time; options.zoom.time = time; return globe; } // both graticule fill, outline fill, and a sphere are used in an attempt to // maintain consistent background color during transitions... globe.oceans = function(color) { if (!arguments.length) return options.oceans.fill; options.oceans.fill = color; options.graticule.fill = color; options.graticule.outline.fill = color; return globe; } // set color to 'undefined' to re-enable CSS functionality globe.land = function(color) { if (!arguments.length) return options.land.fill; options.land.fill = color; return globe; } globe.boundaries = function(color) { if (!arguments.length) return options.land.stroke; options.land.stroke = color; return globe; } globe.cities = function(color) { if (!arguments.length) return options.cities.fill; options.cities.fill = color; options.cities.stroke = color; return globe; } globe.surround = function(color) { if (!arguments.length) return options.world.surround; options.world.surround = color; return globe; } globe.initialize = function(error, world, cities) { if (error) { return error; } globe.data(world); globe.data(cities); update(); if (typeof events.ready == 'function') events.ready(); } var spin_timer; globe.spin = function(spin){ spin = spin == undefined ? true : spin; spin_rotation = projection.rotate(); spin_start = Date.now(); if (spin) { spin_timer = d3.timer(function() { var dt = Date.now() - spin_start; projection.rotate([spin_rotation[0] + options.world.velocity[0] * dt, spin_rotation[1] + options.world.velocity[1] * dt]); reDraw(); }); } else if (spin_timer) { spin_timer.stop(); spin_timer = undefined; } } globe.pulse = function() { poi.selectAll('.pulse-circle') .attr("d", function(d) { return pointPath(d, 0, 0, 0); }) .style("fill-opacity", 1) .transition() .delay(function(d, i) { return i * 200; }) .duration(3000) .style("fill-opacity", 0) .attrTween("d", function(d) { rinterp = d3.interpolate(0, 10); var fn = function(t) { d.r = rinterp(t); return pointPath(d, 0, 0, d.r) || 'M0,0'; }; return fn; }); } globe.flow = function() { d3.selectAll('.arcPath') .transition() .ease('linear') .duration(750) .attrTween("stroke-dashoffset", function() { return d3.interpolate(16, 0); }) .each("end", globe.flow); } function createStars(number){ var data = []; for(var i = 0; i < number; i++){ data.push({ geometry: { type: 'Point', coordinates: randomLonLat() }, type: 'Feature', properties: { radius: Math.random() * 1.5 } }); } return data; } function randomLonLat(){ return [Math.random() * 360 - 180, Math.random() * 180 - 90]; } return globe; }