3D Elevation Models using HTML5 and Bing Maps

Recently we published a blog post on 3D Elevation Models with Bing Maps WPF. In this blog post we will show you how to create a tool for generating a 3D model of elevation data from the Bing Maps REST Elevation Service using HTML5 and Bing Maps.

There are a couple of different ways to create 3D models in HTML5. The method we are going to use to is to project 3D data onto a 2D canvas. There are several JavaScript libraries available online that can do this. You can also find an in-depth article on how to do the math required for this projection here if you want to write your own library. For this blog post we will use the second method and make use of a JavaScript library called K3D.

In this blog post we will create a web page that uses Bing Maps to select an area on the map. We will then use the elevation service to get the elevation data for the selected area on the map. We will then generate a 3D model using the HTML5 canvas and the K3D library. We will also make use of sliders from the jQuery UI library and add functionality for rotating and translating the model.

Note that by using a helper library called ExplorerCanvas you can add HTML5 canvas support to Internet Explorer 7 and 8 which represent 13.8% of the current web browser market share.

Setting up the project

To help make things clean we will put all of our JavaScript into a file called 3DElevationModel.js and put all JavaScript files into a folder called scripts. We will also need to include JavaScript libraries for jQuery, jQuery UI, K3D, and ExplorerCanvas. We will also put all our HTML into a file called index.html. Your project structure should look like this:

 

clip_image001

The HTML Layout

For this application we will want to have a map that the user will be able to use to select what area the 3D model should be created for. Once the user has selected the area they are interested in they will be able to press a button to generate the 3D model. Once the model is created the user will be able to view and interact with the 3D model.

We will need to ad references to 7 JavaScript files. The first reference will be to the Bing Maps V7 AJAX control. We will need references to the jQuery and jQuery UI controls to create the sliders for manipulating the 3D model. A reference to the CSS stylesheet for the jQuery UI library will also be needed. The K3D and its associated MathLib JavaScript libraries will need to be referenced to help generate the 3D model. We will also load in the Explorer Canvas JavaScript library if the browser is Internet Explorer with a version less than 9. Finally, we will need a reference to the JavaScript file where we will be putting all our code: 3DElevationModel.js.

For the body of the HTML document, we will need a div to host the map control, a button to generate the 3D model, a canvas element for rendering the model and 6 div’s for generating sliders. With this, we can create the markup for the index.html file. The HTML should look like this:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
<head>
<title></title>
<meta http-equiv='Content-Type' content='text/html; charset=utf-8' />
<script type='text/javascript' src='http://ecn.dev.virtualearth.net/mapcontrol/mapcontrol.ashx?v=7.0'></script>

    <!– jQuery UI libraries for creating Slider controls –>

    <link type=”text/css” href=”styles/ui-lightness/jquery-ui-1.8.21.custom.css” rel=”stylesheet” />

       <script type=”text/javascript” src=”scripts/jquery-1.7.2.min.js”></script>

       <script type=”text/javascript” src=”scripts/jquery-ui-1.8.21.custom.min.js”></script>

    <!–[if lt IE 9]><script type=”text/javascript” src=”scripts/excanvas.min.js”></script><![endif]–>

    <script type=”text/javascript” src=”scripts/mathlib-min.js”></script>

    <script type=”text/javascript” src=”scripts/k3d-min.js”></script>

    <!– Main JavaScript file where we put all our logic for this application –>

    <script type=”text/javascript” src=”scripts/3DElevationModel.js”></script>


<style type="text/css">
.slider
{
float:left;
width:30px;
}
</style>
</head>
<body onload='GetMap();'>
<div style='float:left;width:600px'>
<div id='myMap' style='position:relative;width:600px;height:400px;'></div>
<input type='button' onclick='GenerateModel();' value='Generate Model' />
</div>

<div style='float:left;width:600px;margin-left:10px;'>
<canvas id="canvas" width="600" height="400" style="background-color:#fff;"></canvas>

<div>
<fieldset style='width:90px;float:left;'>
<legend>Rotation</legend>
<div class="slider">
X
<div id="rotateSliderX"></div>
</div>
<div class="slider">
Y
<div id="rotateSliderY"></div>
</div>
<div class="slider">
Z
<div id="rotateSliderZ"></div>
</div>
</fieldset>

<fieldset style='width:90px;float:left;'>
<legend>Translate</legend>
<div class="slider">
X
<div id="translateX"></div>
</div>
<div class="slider">
Y
<div id="translateY"></div>
</div>
<div class="slider">
Z
<div id="translateZ"></div>
</div>
</fieldset>
</div>
</div>
</body>
</html>
 

Generating the 3D Elevation Model

All the JavaScript for this project will be added to a JavaScript file called 3DElevationModel.js. At the top of this file we will have a number of global variables which we will use throughout the application. Here are the global variables we will have at the top of the file:

 

var map,            //Reference to Map control
canvas, //Reference to the HTML5 canvas used to render the model
sessionKey, //Bing Maps session key used with REST services
mapBounds, //Area of where elevation data was requested
zoom, //Zoom level used to request elevation data
model, //Reference to the generate model
k3d, //Reference to K3D control
numRows = 30, //Number of rows to use for the elevation samples
numCols = 30, //Number of columns to use for the elevation samples
textureColor = [0, 255, 0]; //Color to render the model in
 
When the application starts, lets load the map. Since we will be using the REST elevation service, we will also generate a session key from the map which we can use with the REST services to make those transactions non-billable. And while we are at it, we will also store a reference to the canvas and put all this logic into the GetMap function (which is called when the page is loaded). Also, don’t forget to add in your Bing Maps key into this code.

 

function GetMap() {
// Initialize the map
map = new Microsoft.Maps.Map(document.getElementById('myMap'),
{
credentials: 'YOUR_BING_MAPS_KEY',
center: new Microsoft.Maps.Location(43.08, -79.075),
zoom: 16,
mapTypeId: Microsoft.Maps.MapTypeId.aerial
});

//Generate a Bing Maps Session key to make our call to the REST service non-billable.
map.getCredentials(function (sKey) {
sessionKey = sKey;
});

//Store a reference to the canvas
canvas = document.getElementById('canvas');
}

Now we can start adding the logic for requesting the elevation data. When the “Generate Model” button is pressed the GenerateModel function will be fired. In this method, we will want to clear the canvas of any previous model, store a reference of the current zoom level and map bounds, and create the request to the Bing Maps Elevation service. To make things a bit cleaner, we will round off the coordinates to six decimal places as additional decimal places make no difference. This function will look like this:

function GenerateModel() {
//Clear the Canvas
var context = canvas.getContext("2d");
context.clearRect(0, 0, canvas.width, canvas.height);

//Get the current zoom level and store it
zoom = map.getZoom();

//Only generates models when the user is zoomed in at a decent zoom level, otherwise the model will just be a flat sheet.
if (zoom < 8) {
alert("This zoom level is not supported. Please zoom in closer (>8).");
return;
}

mapBounds = map.getBounds();

//Get the elevation data for a bounding box area
var elevationURL = "http://dev.virtualearth.net/REST/v1/Elevation/Bounds?bounds=" +
SigDigit(mapBounds.getSouth(), 6) + "," + SigDigit(mapBounds.getWest(), 6) + "," + SigDigit(mapBounds.getNorth(), 6) + "," + SigDigit(mapBounds.getEast(), 6) + "&rows=" + numRows + "&cols=" + numCols + "&key=" + sessionKey;

CallRestService(elevationURL + "&jsonp=Render3DModel");
}

function CallRestService(request) {
var script = document.createElement("script");
script.setAttribute("type", "text/javascript");
script.setAttribute("src", request);
document.body.appendChild(script);
}

//Simple method that rounds numbers to a certain number of significant digits
function SigDigit(number, digits) {
var delta = Math.pow(10, digits);
return Math.round(number * delta) / delta;
}

When the elevation service responds to our request, it will fire our callback function called Render3DModel. This function will need all the logic to generate the 3D model and render it on the canvas. To create the 3D model, we will need to do the following:

(1) Loop through all the elevation data, calculate the relative coordinate, and then convert this coordinate to a pixel coordinate. We will also need to convert the elevation into a pixel length.

(2) Next, we can create a 3D mesh out of the data. To do this, we will need to specify all the data points as 3 dimensional coordinates, and then specify the texture coordinate vertices used to render the mesh. We will also need to specify the point indices used to create the triangles needed for the mesh.

(3) Finally, we can pass all this data to the K3D library to generate the 3D model.

The code for this function looks like this:

function Render3DModel(result) {
if (result != null &&
result.resourceSets != null &&
result.resourceSets.length > 0 &&
result.resourceSets[0] != null &&
result.resourceSets[0].resources != null &&
result.resourceSets[0].resources.length > 0) {

var elevations = result.resourceSets[0].resources[0].elevations;

//Get bounding coordinates of map
var tlLatitude = mapBounds.getNorth();
var brLatitude = mapBounds.getSouth();
var brLongitude = mapBounds.getEast();
var tlLongitude = mapBounds.getWest();

//Calculate the degree spacing between elevation samples
var dLat = Math.abs(tlLatitude - brLatitude) / numRows;
var dLon = Math.abs(tlLongitude - brLongitude) / numCols;

var x, y, z, p, m2p, idx, idx2, idx3, idx4;
var meshPoints = [], edgeData = [], vertices = [];

for (var r = 0; r < numRows; r++) {
y = tlLatitude - (dLat * r);

//Calculate meters per pixel for given latitude so we can scale the elevation to pixels
//This is based on: http://msdn.microsoft.com/en-us/library/aa940990.aspx
m2p = 156543.04 * Math.cos(y * Math.PI / 180) / Math.pow(2, zoom);

for (var c = 0; c < numCols; c++) {
idx = r * numCols + c;

x = tlLongitude + (dLon * c);

z = -elevations[idx] / m2p;
p = map.tryLocationToPixel(new Microsoft.Maps.Location(y, x));

meshPoints.push({ x: p.x, y: p.y, z: z });

//Create triangles for vreating the mesh model
if (r < numRows - 1 && c < numCols - 1) {
idx2 = idx + 1;
idx3 = idx + numCols;
idx4 = idx3 + 1;

edgeData.push({ a: idx, b: idx2 });
edgeData.push({ a: idx2, b: idx3 });
edgeData.push({ a: idx3, b: idx });
vertices.push({ vertices: [idx, idx3, idx2], color: textureColor });

edgeData.push({ a: idx2, b: idx4 });
edgeData.push({ a: idx4, b: idx3 });
edgeData.push({ a: idx3, b: idx2 });
vertices.push({ vertices: [idx2, idx3, idx4], color: textureColor });
}
}
}

//Bind a K3D Controller object to the canvas
k3d = new K3D.Controller(canvas, true);

// create a K3D object for rendering
model = new K3D.K3DObject();
with (model) {
drawmode = "solid";
shademode = "lightsource";
otheta = -45; // Intial rotation around X axis
ophi = 0; // Intial rotation around Y Axis
ogamma = 0; // Intial roation around Z axis
fillstroke = true; // Fill the model to help prevent seems along edges due to anti-aliasing
init(meshPoints, edgeData, vertices); //Initialize the model
}

//Add the object to the controller
k3d.addK3DObject(model);

//Render the model
k3d.tick();

//Reset Sliders
$("#rotateSliderX").slider('value', -45);
$("#rotateSliderY").slider('value', 0);
$("#rotateSliderZ").slider('value', 0);

$("#translateX").slider('value', 0);
$("#translateY").slider('value', 0);
$("#translateZ").slider('value', 0);
}
}

The K3D library, when iterating through data in an array, uses a method called forEach. This method is only available in newer browsers. Trying to use this library in Internet Explorer 7 or 8 will result in an error. To get around this error we can easily detect if this method exists for arrays, and if it doesn’t, then create the needed method. The following code can be used to do this:

if (!Array.prototype.forEach) {
Array.prototype.forEach = function (fun) {
var len = this.length;
if (typeof fun != "function")
throw new TypeError();

var param = arguments[1];
for (var i = 0; i < len; i++) {
if (i in this) {
fun.call(param, this[i], i, this);
}
}
};
}

Finally, we will need to add the code to create and initialize the sliders for manipulating the 3D model. The code looks like this:

$(function () {
var createSlider = function (id, min, max, propertyName) {
$(id).slider({
orientation: "vertical",
range: "min",
min: min,
max: max,
slide: function (event, ui) {
if (model) {
model[propertyName] = ui.value;
k3d.tick();
}
}
});
}

//Create sliders for rotating the model
createSlider("#rotateSliderX", -180, 180, 'otheta');
createSlider("#rotateSliderY", -180, 180, 'ophi');
createSlider("#rotateSliderZ", -180, 180, 'ogamma');

//Create Sliders for translating the model
createSlider("#translateX", -400, 400, 'offx');
createSlider("#translateY", -400, 400, 'offy');
createSlider("#translateZ", -400, 400, 'offz');
});

At this point we have everything we need to generate the 3D elevation model. Open up the web page in a browser and zoom into an area likely to have varying elevations and press the “Generate Model” button. Below is a screenshot of a rendered 3D elevation model of Niagara Falls.

3DNiagraFalls_HTML5

3DFrame_HTML5

Full Source Code

index.html

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
<head>
<title></title>
<meta http-equiv='Content-Type' content='text/html; charset=utf-8' />
<script type='text/javascript' src='http://ecn.dev.virtualearth.net/mapcontrol/mapcontrol.ashx?v=7.0'></script>

    <!– jQuery UI libraries for creating Slider controls –>

    <link type=”text/css” href=”styles/ui-lightness/jquery-ui-1.8.21.custom.css” rel=”stylesheet” />

       <script type=”text/javascript” src=”scripts/jquery-1.7.2.min.js”></script>

       <script type=”text/javascript” src=”scripts/jquery-ui-1.8.21.custom.min.js”></script>

    <!–[if lt IE 9]><script type=”text/javascript” src=”scripts/excanvas.min.js”></script><![endif]–>

    <script type=”text/javascript” src=”scripts/mathlib-min.js”></script>

    <script type=”text/javascript” src=”scripts/k3d-min.js”></script>

    <!– Main JavaScript file where we put all our logic for this application –>

    <script type=”text/javascript” src=”scripts/3DElevationModel.js”></script>

   <style type="text/css">
.slider
{
float:left;
width:30px;
}
</style>
</head>
<body onload='GetMap();'>
<div style='float:left;width:600px'>
<div id='myMap' style='position:relative;width:600px;height:400px;'></div>
<input type='button' onclick='GenerateModel();' value='Generate Model' />
</div>

<div style='float:left;width:600px;margin-left:10px;'>
<canvas id="canvas" width="600" height="400" style="background-color:#fff;"></canvas>

<div>
<fieldset style='width:90px;float:left;'>
<legend>Rotation</legend>
<div class="slider">
X
<div id="rotateSliderX"></div>
</div>
<div class="slider">
Y
<div id="rotateSliderY"></div>
</div>
<div class="slider">
Z
<div id="rotateSliderZ"></div>
</div>
</fieldset>

<fieldset style='width:90px;float:left;'>
<legend>Translate</legend>
<div class="slider">
X
<div id="translateX"></div>
</div>
<div class="slider">
Y
<div id="translateY"></div>
</div>
<div class="slider">
Z
<div id="translateZ"></div>
</div>
</fieldset>
</div>
</div>
</body>
</html>

3DElevationModel.js

/*
* This code makes use of the K3D libaray: http://www.kevs3d.co.uk/dev
* Documentation for K3D http://en.wikibooks.org/wiki/K3D_JavaScript_Canvas_Library/Tutorial
*/

var map, //Reference to Map control
canvas, //Reference to the HTML5 canvas used to render the model
sessionKey, //Bing Maps session key used with REST services
mapBounds, //Area of where elevation data was requested
zoom, //Zoom level used to request elevation data
model, //Reference to the generate model
k3d, //Reference to K3D control
numRows = 30, //Number of rows to use for the elevation samples
numCols = 30, //Number of columns to use for the elevation samples
textureColor = [0, 255, 0]; //Color to render the model in

//Add support for the "forEach" method on arrays if the browser does not have support for this.
//This is needed by the k3d library. Notable browsers that need this added are IE7 & IE8.
if (!Array.prototype.forEach) {
Array.prototype.forEach = function (fun) {
var len = this.length;
if (typeof fun != "function")
throw new TypeError();

var param = arguments[1];
for (var i = 0; i < len; i++) {
if (i in this) {
fun.call(param, this[i], i, this);
}
}
};
}

//Method used to load the map
function GetMap() {
// Initialize the map
map = new Microsoft.Maps.Map(document.getElementById('myMap'),
{
credentials: 'YOUR_BING_MAPS_KEY'
center: new Microsoft.Maps.Location(43.08, -79.075),
zoom: 16,
mapTypeId: Microsoft.Maps.MapTypeId.aerial
});

//Generate a Bing Maps Session key to make our call to the REST service non-billable.
map.getCredentials(function (sKey) {
sessionKey = sKey;
});

//Store a reference to the canvas
canvas = document.getElementById('canvas');
}

//Method used to start the process of generating the 3D elevation model
function GenerateModel() {
//Clear the Canvas
var context = canvas.getContext("2d");
context.clearRect(0, 0, canvas.width, canvas.height);

//Get the current zoom level and store it
zoom = map.getZoom();

//Only generates models when the user is zoomed in at a decent zoom level, otherwise the model will just be a flat sheet.
if (zoom < 8) {
alert("This zoom level is not supported. Please zoom in closer (>8).");
return;
}

mapBounds = map.getBounds();

//Get the elevation data for a bounding box area
var elevationURL = "http://dev.virtualearth.net/REST/v1/Elevation/Bounds?bounds=" +
SigDigit(mapBounds.getSouth(), 6) + "," + SigDigit(mapBounds.getWest(), 6) + "," + SigDigit(mapBounds.getNorth(), 6) + "," + SigDigit(mapBounds.getEast(), 6) + "&rows=" + numRows + "&cols=" + numCols + "&key=" + sessionKey;

CallRestService(elevationURL + "&jsonp=Render3DModel");
}

//This method generates and renders the 3D model based on the returned elevation data.
function Render3DModel(result) {
if (result != null &&
result.resourceSets != null &&
result.resourceSets.length > 0 &&
result.resourceSets[0] != null &&
result.resourceSets[0].resources != null &&
result.resourceSets[0].resources.length > 0) {

var elevations = result.resourceSets[0].resources[0].elevations;

//Get bounding coordinates of map
var tlLatitude = mapBounds.getNorth();
var brLatitude = mapBounds.getSouth();
var brLongitude = mapBounds.getEast();
var tlLongitude = mapBounds.getWest();

//Calculate the degree spacing between elevation samples
var dLat = Math.abs(tlLatitude - brLatitude) / numRows;
var dLon = Math.abs(tlLongitude - brLongitude) / numCols;

var x, y, z, p, m2p, idx, idx2, idx3, idx4;
var meshPoints = [], edgeData = [], vertices = [];

for (var r = 0; r < numRows; r++) {
y = tlLatitude - (dLat * r);

//Calculate meters per pixel for given latitude so we can scale the elevation to pixels
//This is based on: http://msdn.microsoft.com/en-us/library/aa940990.aspx
m2p = 156543.04 * Math.cos(y * Math.PI / 180) / Math.pow(2, zoom);

for (var c = 0; c < numCols; c++) {
idx = r * numCols + c;

x = tlLongitude + (dLon * c);

z = -elevations[idx] / m2p;
p = map.tryLocationToPixel(new Microsoft.Maps.Location(y, x));

meshPoints.push({ x: p.x, y: p.y, z: z });

//Create triangles for vreating the mesh model
if (r < numRows - 1 && c < numCols - 1) {
idx2 = idx + 1;
idx3 = idx + numCols;
idx4 = idx3 + 1;

edgeData.push({ a: idx, b: idx2 });
edgeData.push({ a: idx2, b: idx3 });
edgeData.push({ a: idx3, b: idx });
vertices.push({ vertices: [idx, idx3, idx2], color: textureColor });

edgeData.push({ a: idx2, b: idx4 });
edgeData.push({ a: idx4, b: idx3 });
edgeData.push({ a: idx3, b: idx2 });
vertices.push({ vertices: [idx2, idx3, idx4], color: textureColor });
}
}
}

//Bind a K3D Controller object to the canvas
k3d = new K3D.Controller(canvas, true);

// create a K3D object for rendering
model = new K3D.K3DObject();
with (model) {
drawmode = "solid";
shademode = "lightsource";
otheta = -45; // Intial rotation around X axis
ophi = 0; // Intial rotation around Y Axis
ogamma = 0; // Intial roation around Z axis
fillstroke = true; // Fill the model to help prevent seems along edges due to anti-aliasing
init(meshPoints, edgeData, vertices); //Initialize the model
}

//Add the object to the controller
k3d.addK3DObject(model);

//Render the model
k3d.tick();

//Reset Sliders
$("#rotateSliderX").slider('value', -45);
$("#rotateSliderY").slider('value', 0);
$("#rotateSliderZ").slider('value', 0);

$("#translateX").slider('value', 0);
$("#translateY").slider('value', 0);
$("#translateZ").slider('value', 0);
}
}

//Create the sliders for rotating and translating the 3D model
$(function () {
var createSlider = function (id, min, max, propertyName) {
$(id).slider({
orientation: "vertical",
range: "min",
min: min,
max: max,
slide: function (event, ui) {
if (model) {
model[propertyName] = ui.value;
k3d.tick();
}
}
});
}

//Create sliders for rotating the model
createSlider("#rotateSliderX", -180, 180, 'otheta');
createSlider("#rotateSliderY", -180, 180, 'ophi');
createSlider("#rotateSliderZ", -180, 180, 'ogamma');

//Create Sliders for translating the model
createSlider("#translateX", -400, 400, 'offx');
createSlider("#translateY", -400, 400, 'offy');
createSlider("#translateZ", -400, 400, 'offz');
});

/***** Helper Methods ****/

//Simple method for calling a REST service
function CallRestService(request) {
var script = document.createElement("script");
script.setAttribute("type", "text/javascript");
script.setAttribute("src", request);
document.body.appendChild(script);
}

//Simple method that rounds numbers to a certain number of significant digits
function SigDigit(number, digits) {
var delta = Math.pow(10, digits);
return Math.round(number * delta) / delta;
}

- Ricky Brundritt, EMEA Bing Maps Technology Solution Professional