/worldMap

Primary LanguageJavaScript

WorldMap

Summary

WorldMap was a small Project I have done for „Clientside Web Engineering“ at university of applied sciences Salzburg. It utilizes the power of D3.js to generate SVG paths representing the shapes of countries.

These paths are later transformed to THREE.js Meshes and rendered in 3d space. You can zoom in to continents and hover over countries to see their name on the bottom.

Demo

For a live preview, see http://torghele.at/worldmap/

How it was done

When I began working on this project I realized, that it is impossible to render map/geo data directly in THREE.js. D3 is much better suited for such things and accepts geoJSON as input format. Fortunately, there is a project named d3-threeD which aims to integrate D3.js in THREE.js. With this library it is possible to render SVG elements from D3.js directly in a THREE.js scene. Because I wanted to control how the country shapes were rendered I only used the path transformation functionality and did the rendering on my own. After I was able to render the Map correctly I wanted to make the countries hoverable, which was done via raycasting. Finally I added camera animations to be able to "zoom" to a continent. I used Bootstrap to make everything look pretty.

  • get map/geo data of countries
  • transform this data to geoJSON
  • generate SVG paths with D3.js
  • transform these paths to THREE.js meshes
  • render these meshes in a 3D scene
  • make country meshes hover-able
  • add zooming
  • make everything look pretty

Where can I get shape files of all countries?

After some research I discovered the Natural Earth Database. It offers free vector and raster map data at 1:10m, 1:50m, and 1:110m scales. Data themes are available in three levels of detail. For this project I used the 1:110m Cultural Country Vectors which you can download here.

What is geoJSON?

GeoJSON is an open format for encoding a variety of geographic data structures. Spatial data format types supported in GeoJSON include points, polygons, multipolygons, features, geometry collections, and bounding boxes, which are stored along with feature information and attributes.

How to convert shapefiles (.shp) to geoJSON?

First I tried QuantumGIS but this is a rather big software package and overkill for our purpose. I ended up in using two online tools which worked out pretty well.

First I used MapShaper to reduce the amount of details in the shapefile because the 1:110m Cultural Country Vectors from Natural Earth converted to geoJSON were unnecessarily big.

Then I used an online Vector Converter to convert everything to geoJSON.

Initialising D3.js

init_d3: function() {

    geoConfig = function() {
        
        this.mercator = d3.geo.equirectangular();
        this.path = d3.geo.path().projection(this.mercator);
        
        var translate = this.mercator.translate();
        translate[0] = 500;
        translate[1] = 0;
        
        this.mercator.translate(translate);
        this.mercator.scale(200);
    }

    this.geo = new geoConfig();
}

In init_d3 I store the D3.js configuration in the geo object for later use. The configuration includes the d3.geo.equirectangular() mercator and some translation and scaling to move everything on the right place. You can read more about D3 geo-projections (mercators) here.

Initialising THREE.js

init_tree: function() {

    if( Detector.webgl ){
        this.renderer = new THREE.WebGLRenderer({
            antialias : true
        });
        this.renderer.setClearColorHex( 0xBBBBBB, 1 );
    } else {
        this.renderer = new THREE.CanvasRenderer();
    }

    this.renderer.setSize( this.WIDTH, this.HEIGHT );

    this.projector = new THREE.Projector();
    
    this.scene = new THREE.Scene();

    this.camera = new THREE.PerspectiveCamera(this.VIEW_ANGLE, this.WIDTH / this.HEIGHT,
                                              this.NEAR, this.FAR);
    this.camera.position.x = this.CAMERA_X;
    this.camera.position.y = this.CAMERA_Y;
    this.camera.position.z = this.CAMERA_Z;
    this.camera.lookAt( { x: this.CAMERA_LX, y: 0, z: this.CAMERA_LZ} );
    this.scene.add(this.camera);

    $("#worldmap").append(this.renderer.domElement);
}

In init_tree THREE.js is initilised. First of all I use Detector.js to determine if the current browser supports WebGL. If so I can use THREE.WebGLRenderer. If not the fallback THREE.CanvasRenderer is used. After that I create a new THREE.Scene and a THREE.PerspectiveCamera. The THREE.Projector is needed for the raycasting later on. At the end the renderer is appended to the DOM.

Adding countries to the scene

add_countries: function(data) {

    var countries = [];
    var i, j;

    // convert to threejs meshes
    for (i = 0 ; i < data.features.length ; i++) {
        var geoFeature = data.features[i];
        var properties = geoFeature.properties;
        var feature = this.geo.path(geoFeature);

        // we only need to convert it to a three.js path
        var mesh = transformSVGPathExposed(feature);

        // add to array
        for (j = 0 ; j < mesh.length ; j++) {
              countries.push({"data": properties, "mesh": mesh[j]});
        }
    }
    ...

In add_countries all the features out of the geoJSON are converted to THREE.js meshes. First we create a D3.Geo.Path out of every geoJSON feature. This features are afterwards converted to a THREE.Mesh via transformSVGPathExposed. These meshes, together with some data about the current country are pushed to a countries array.

    ...
    // extrude paths and add color
    for (i = 0 ; i < countries.length ; i++) {

        // create material color based on average		
        var material = new THREE.MeshPhongMaterial({
            color: this.getCountryColor(countries[i].data), 
            opacity:0.5
        }); 

        // extrude mesh
        var shape3d = countries[i].mesh.extrude({
            amount: 1, 
            bevelEnabled: false
        });

        // create a mesh based on material and extruded shape
        var toAdd = new THREE.Mesh(shape3d, material);
 
        ...

        this.scene.add(toAdd);
    }
}

Now we create a new THREE.MeshPhongMaterial with a calculated colour and opacity of 0.5 for every country. Afterwards we extrude the mesh by one so that it is no longer flat and create the final THREE.Mesh which we now can add to our scene.

Get a hex colour out of country codes

getCountryColor: function(data) {
    var multiplier = 0;

    for(i = 0; i < 3; i++) {
        multiplier += data.iso_a3.charCodeAt(i);
    }

    multiplier = (1.0/366)*multiplier;
    return multiplier*0xffffff;
}

In getCountryColor I add up the results of charCodeAt for each char in an iso_a3 country code. Then I normalize the result and multiply it to 0xFFFFFF which results in (mostly) different colours for each country.

Camera animations

First there we check in animate() if the camera is on the right position. If not the method moveCamera() is called as long as necessary:

moveCamera: function() {
    var speed = 0.2;
    var target_x = (this.CAMERA_X - this.camera.position.x) * speed;
    var target_y = (this.CAMERA_Y - this.camera.position.y) * speed;
    var target_z = (this.CAMERA_Z - this.camera.position.z) * speed;
    
    this.camera.position.x += target_x;
    this.camera.position.y += target_y;
    this.camera.position.z += target_z;
    
    this.camera.lookAt( {x: this.CAMERA_LX, y: 0, z: this.CAMERA_LZ } );
}

In moveCamera we move the camera to the new position with a certain speed and let it lock at the given coordinates.

Raycasting (hovering countries)

var vector = new THREE.Vector3( mouse.x, mouse.y, 1 );
this.projector.unprojectVector( vector, this.camera );
var raycaster = new THREE.Ray( this.camera.position, vector.subSelf( this.camera.position ).normalize() );

var intersects = raycaster.intersectObjects( this.scene.children );
var objects = this.scene.children;

if ( intersects.length > 1 ) {						
    if(this.INTERSECTED != intersects[ 0 ].object) {
        if (this.INTERSECTED) {
            for(i = 0; i < objects.length; i++) {
                if (objects[i].name == this.INTERSECTED.name) {
                    objects[i].material.opacity = 0.5;
                    objects[i].scale.z = 1;
                }
            }
            this.INTERSECTED = null;
        }
    }

    this.INTERSECTED = intersects[ 0 ].object;
    for(i = 0; i < objects.length; i++) {
        if (objects[i].name == this.INTERSECTED.name) {
            objects[i].material.opacity = 1.0;
            objects[i].scale.z = 5;
        }
    }
}

The above code is part of the animate() function. First we need a new THREE.Vector3 with the current mouse position. Next we need unprojectVector() to convert (x,y) mouse coordinates in the browser to the (x,y,z) coordinates in THREE.js canvas space.

Now we can create a new THREE.Ray which goes through the current position of our camera and our vector. With intersectObjects() we get all objects which intersect with our ray. These are the objects which are at the current position of our cursor. Now we can simply animate them by setting the opacity to 1 and scale them a little bit up. All other objects should be reversed to their original state.

Finally: the init function which glues all together

function init() {
		
    $.when(	$.getJSON("data/countries.json") ).then(function(data){ 
        
        worldMap = new Map();
        
        worldMap.init_d3();
        worldMap.init_tree();
        
        worldMap.add_light(0, 3000, 0, 1.0, 0xFFFFFF);		
        worldMap.add_plain(1400, 700, 30, 0xEEEEEE);
        
        worldMap.add_countries(data);
        
        // request animation frame
        var onFrame = window.requestAnimationFrame;
    
        function tick(timestamp) {
            worldMap.animate();
            
            if(worldMap.INTERSECTED) {
                $('#country-name').html(worldMap.INTERSECTED.name);
            } else {
                $('#country-name').html("move mouse over map");
            }
            
            onFrame(tick);
        }
    
        onFrame(tick);
        
        document.addEventListener( 'mousemove', onDocumentMouseMove, false );
        window.addEventListener( 'resize', onWindowResize, false );
        
    });
}

In init I first load the geoJSON data. After that I create a new Map opject and call all the above methods on it.

I use requestAnimationFrame to loop the animate() method and write the name of the current country to DOM. One big advantage of requestAnimationFrame is that if you're running the animation loop in a tab that's not visible, the browser won't keep it running.

Summary

It was fun and I learned a lot ;)