Knockout JS and Bing Maps

Combining Knockout with Bing Maps Ajax control will give you bindable properties similar to what you may have experienced with the Bing Maps Silverlight control. Further it has a great way to communicate with your JOSN web services. I’ve put together a library that implements a Knockout custom binding for the Bing Maps V7 control. With it you can create a view model with properties like a pin collection, a mapview, the current map type and have the map and any other HTML control simply bind to it. With Knockouts full support for Ajax calls to JSON data it provides a great basis for your next Bing Maps application.

Download the library and example here

Recently I’ve been building some mapping applications back in the HTML/JavaScript world after spending many years with Silverlight. I knew I wanted a better framework then just the usual hand rolled JavaScript loosely following some classes and at the recent DDDBrisbane event Joseph Cooney introduced the Knockout JavaScript library.

What is Knockout?

It’s a well-used, robust JavaScript library that allows you to define a view model and then two-way bind to HTML objects. It has some great stuff in it including templates, even recursive templates (great for your layer panel), observable collections, support for JSON data (including from web services) and more. If you’re looking at building your next rich single page app in HTML and JavaScript you have to check it out.

Checkout the full introduction and great examples here: http://knockoutjs.com/documentation/introduction.html

Bing Maps AJAX Control V7

This is the current control for Bing Maps, you can use it in any browser, combine it with phone gap to power your mobile apps or even use it slightly modified for your Windows8 app. It is modular, fast and a great experience for your users. It is a commercial control with petabytes of data at your disposal, sign up for a developer account here, you will need Your Bing Maps Key to run the examples: https://www.bingmapsportal.com/

Checkout the interactive SDK here if it’s your first time or if you haven’t played with the latest version: http://www.bingmapsportal.com/isdk/ajaxv7

Why would you want to combine Knockout and Bing Maps?

Simple, you want to have a clean view model that you can update from your logic and/or web services and simply have the map react. You want to separate the map’s functionality (UI) from your data and follow a MVVM pattern.

Simple example – binding the map type

You want a set of radio buttons to control the current map type (road, aerial or auto), simple example I know.

The old way: This is how you may implement it currently using some JQuery:

The new way: Instead this is how we do it using Knockout, if you’re following along (there is a full download at the end of the article) clear out all the above script code and reference:

<script
type=”text/javascript”
src=”Scripts/knockout-2.2.0.js”></script>

Step 1, define a view model:

Step 2, create the bing maps custom binding:

Step 3, modify the HTML to use bindings:

Step 4, Bind it up:

As usual something simple like this took far less code the old way, but nothing stays simple for long, later we will add more properties and data but as a quick example, you have a go at adding 3 more maps to the page and synchronise all the maps to same map type. Using the new technique I simply add the 3 divs and bind to the same view model, easy! Not a typical scenario but a good demo of the power of binding.


ADVANCED: Ah so that explains the complex code that is storing each map against the element’s id in the custom binding, this allows us to have many maps on the page all bound to the same or different parts of a view model. But was is _map1Id used for you ask? In the full listing we bind the center, zoom and bounds of the maps, all four maps are synchronised but the bounds as each map maybe a different size can only come from the first map.

Pins

Let’s do something more interesting, let’s have a collection of pins, these could quite happily come from a web service, see http://knockoutjs.com/documentation/plugins-mapping.html, but to keep it simple I’ll just add them to a viewmodel:

Step 1, define a view model:


Step 2a, add a helper to observable array so we can subscribe to changes in the collect, ie when pins get added and removed. This is from Michael Best.


Step 2b, modify the bing maps custom binding using our new helper above:

Step 3, modify the HTML to use bindings:

Step 4, Bind it up, add some data

Pretty cool hey? Note the custom event to add a remove button to the table data, purely optional. Here you can add more functions. I’m sure you’re asking where do I put any interaction logic with the pins, in the createpin() method on your viewmodel you have complete control. This is where you could extend to support polygons and lines.

Full implementation

Currently I’m supporting:

  • Multiple maps on the same page
  • Only bind the properties you want to use
  • Map Type
  • Mapview: centre, zoomlevel and bounds
  • Pins, although not too hard to make this into any entity
  • TileLayers
  • Map Info, 4x4px bounds on the mouse click, handy for tile layers, gives a nice bounds string to pass off to your server for info tooling raster layers
  • Full Options object can be passed in to configure the map how you like.
  • Works really well from JSON web services, trick is to set the collection using the Knockout mapping on an empty array, then load in the data async:
    self.pins = ko.mapping.fromJS([]);

Download the library and example here. If you have any questions or feedback catch me on http://twitter.com/soulsolutions

For completeness here is the full listing for the BingMapsKO.js library as text, feel free to do with it what you like, I’ll put it under Ms-PL license that gives you maximum flexibility to use it however you like. If you enhance it or find a bug feel free to let me know (or not).

//Bing Maps library for KnockoutJS.com

//John O’Brien Soul Solutions 2012-12-31

//Microsoft Public License (Ms-PL)

//See blog post for examples and updates: http://www.soulsolutions.com.au/Blog/tabid/73/EntryId/818/Knockout-JS-and-Bing-Maps.aspx

 

 

//add helper to ko.observableArray

ko.observableArray.fn.subscribeArrayChanged = function (addCallback, deleteCallback) {


var previousValue = undefined;


this.subscribe(function (_previousValue) {

previousValue = _previousValue.slice(0);

}, undefined, ‘beforeChange’);


this.subscribe(function (latestValue) {


var editScript = ko.utils.compareArrays(previousValue, latestValue);


for (var i = 0, j = editScript.length; i < j; i++) {


switch (editScript[i].status) {


case
“retained”:


break;


case
“deleted”:


if (deleteCallback)

deleteCallback(editScript[i].value);


break;


case
“added”:


if (addCallback)

addCallback(editScript[i].value);


break;

}

}

previousValue = undefined;

});

};

 

ko.computed.fn.subscribeArrayChanged = function (addCallback, deleteCallback) {


var previousValue = undefined;


this.subscribe(function (_previousValue) {

previousValue = _previousValue.slice(0);

}, undefined, ‘beforeChange’);


this.subscribe(function (latestValue) {


var editScript = ko.utils.compareArrays(previousValue, latestValue);


for (var i = 0, j = editScript.length; i < j; i++) {


switch (editScript[i].status) {


case
“retained”:


break;


case
“deleted”:


if (deleteCallback)

deleteCallback(editScript[i].value);


break;


case
“added”:


if (addCallback)

addCallback(editScript[i].value);


break;

}

}

previousValue = undefined;

});

};

 

 

ko.bindingHandlers.bingmaps = {

_uniqueid: 0,

_map1id: ,

_map: {},

_entityGeoLookup: {},

_entityLayerLookup: {},

init: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {


var _valueAccessor = valueAccessor();

 


if (!_valueAccessor.map) {

alert(‘Map property not set on KO Bing Maps’);


return;

}

 


//create map and store against the element’s ID.


var map = new Microsoft.Maps.Map(element, _valueAccessor.map.options);

ko.bindingHandlers.bingmaps._map[element.id] = map;


if (ko.bindingHandlers.bingmaps._map1id == ) ko.bindingHandlers.bingmaps._map1id = element.id;

 


if (_valueAccessor.map.mapview) {


//check if we need to bind to the event at all


if (_valueAccessor.map.mapview.zoom || _valueAccessor.map.mapview.center || _valueAccessor.map.mapview.bounds) {

Microsoft.Maps.Events.addHandler(map, ‘viewchange’, function (e) {


if (_valueAccessor.map.mapview.zoom) _valueAccessor.map.mapview.zoom(map.getTargetZoom());


if (_valueAccessor.map.mapview.center) _valueAccessor.map.mapview.center(map.getTargetCenter());


//clear the bounds during the animation, only set it at the viewchangeend, for multiple maps only the first map can set the bounds.


if (_valueAccessor.map.mapview.bounds && ko.bindingHandlers.bingmaps._map1id == element.id) _valueAccessor.map.mapview.bounds();

});

}

 


//check if we need to bind to the event at all, for multiple maps only the first map can set the bounds.


if (_valueAccessor.map.mapview.bounds && ko.bindingHandlers.bingmaps._map1id == element.id) {

Microsoft.Maps.Events.addThrottledHandler(map, ‘viewchangeend’, function (e) {

_valueAccessor.map.mapview.bounds(map.getBounds().toNwse());

}, 100);

}

}

 


if (_valueAccessor.maptype) {

Microsoft.Maps.Events.addHandler(map, ‘maptypechanged’, function (e) {

_valueAccessor.maptype(map.getMapTypeId());

});

}

 


if (_valueAccessor.info) {

Microsoft.Maps.Events.addHandler(map, ‘click’, function (e) {


if (_valueAccessor.info) _valueAccessor.info(map.getBoundsFromPixelXY(e.getX(), e.getY()));

});

}

 


if (_valueAccessor.pins) {

_valueAccessor.pins.subscribeArrayChanged(function (data) {


//add


if (_valueAccessor.createPin) {


var geo = _valueAccessor.createPin(data);

map.entities.push(geo);


//add a unique id to the object


if (typeof data.__uniqueid == “undefined”) {

data.__uniqueid = ++ko.bindingHandlers.bingmaps._uniqueid;

}

ko.bindingHandlers.bingmaps._entityGeoLookup[element.id + data.__uniqueid] = geo;

}

}, function (data) {


//delete


if (_valueAccessor.createPin) {


var geo = ko.bindingHandlers.bingmaps._entityGeoLookup[element.id + data.__uniqueid];

map.entities.remove(geo);


delete ko.bindingHandlers.bingmaps._entityGeoLookup[element.id + data.__uniqueid];

}

});

}

 


if (_valueAccessor.layers) {

_valueAccessor.layers.subscribeArrayChanged(function (data) {


//add


if (_valueAccessor.createTileLayer) {


//add a unique id to the object


if (typeof data.__uniqueid == “undefined”) {

data.__uniqueid = ++ko.bindingHandlers.bingmaps._uniqueid;

}


var tilelayer = _valueAccessor.createTileLayer(data);

map.entities.push(tilelayer);

ko.bindingHandlers.bingmaps._entityLayerLookup[element.id + data.__uniqueid] = tilelayer;

}

}, function (data) {


//delete


if (_valueAccessor.createTileLayer) {


var layer = ko.bindingHandlers.bingmaps._entityLayerLookup[element.id + data.__uniqueid];

map.entities.remove(layer);


delete ko.bindingHandlers.bingmaps._entityLayerLookup[element.id + data.__uniqueid];

}

});

}

},

update: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {


var _valueAccessor = valueAccessor(), allBindings = allBindingsAccessor();

 


if (!_valueAccessor.map) {


return;

}


var map = ko.bindingHandlers.bingmaps._map[element.id];

 


if (_valueAccessor.map.mapview) {


var currentTargetCenter = map.getTargetCenter();


var currentCenter = map.getCenter();

 


var zoom = map.getTargetZoom();


if (_valueAccessor.map.mapview.zoom) zoom = parseInt(ko.utils.unwrapObservable(_valueAccessor.map.mapview.zoom()));


var center = currentTargetCenter;


if (_valueAccessor.map.mapview.center) center = ko.utils.unwrapObservable(_valueAccessor.map.mapview.center());


var bounds = ;


if (_valueAccessor.map.mapview.bounds) bounds = ko.utils.unwrapObservable(_valueAccessor.map.mapview.bounds());

 


if (zoom != map.getTargetZoom() || Math.abs(center.latitude – currentTargetCenter.latitude) + Math.abs(center.longitude – currentTargetCenter.longitude) > 0.00001) {

map.setView({ zoom: zoom, center: center });

} else
if (ko.bindingHandlers.bingmaps._map1id == element.id && bounds != && bounds != map.getBounds().toNwse() && zoom == map.getZoom() && Math.abs(center.latitude – currentCenter.latitude) + Math.abs(center.longitude – currentCenter.longitude) < 0.00001) {

map.setView({ bounds: Microsoft.Maps.LocationRect.fromString(bounds) });

}

}

 


if (_valueAccessor.maptype) {


var maptype = ko.utils.unwrapObservable(_valueAccessor.maptype());


if (maptype != map.getMapTypeId()) {

map.setMapType(maptype);

}

}

}

};

 

$(document).ready(function () {


//extension methods 🙂

Microsoft.Maps.LocationRect.prototype.toNwse = function () {


return
this.getNorth().toFixed(5) + ‘,’ + this.getWest().toFixed(5) + ‘,’ + this.getSouth().toFixed(5) + ‘,’ + this.getEast().toFixed(5);

};

 

Microsoft.Maps.Map.prototype.getBoundsFromPixelXY = function (x, y) {


var r = Microsoft.Maps.LocationRect.fromCorners(


this.tryPixelToLocation(new Microsoft.Maps.Point(x – 2, y – 2)),


this.tryPixelToLocation(new Microsoft.Maps.Point(x + 2, y + 2)));


return r.toNwse();

};

});