Author's note

Quote: After downloading the code, open the Great War folder as a Web Site in Visual Studio:

Introduction

This article is an exercise to build an interactive map SPA (Single Page Application) aimed at remembering the 100 years of the fateful days of the Great War of 1914-1918. One hundred years ago, it was involving a sizable part of the world, and dragging Europe into a turmoil of destruction and social chaos.

The application was a built as an educational tool to help students and history geeks to improve knowledge and consolidate their understading, by merging geographical historical maps, chronological events and modern video documentaries about the Great War.

Background

There are some ways to build an interactive map using different technologies. One of them is through Web mapping, using Geographical Information Systems (GIS), such as Open Street Map, which delivers a lot of information and many built-in tools, but might be easily an overkill for our simple application. Another solutions involve a simpler approach, through direct drawing of the map and controlling the graphic elements. For this purpose, we could use a battle-hardened technology (some pun intended) such as Adobe Flash, which had its days of glory, but now mostly a no-go for new projects. Now they are generally built with HTML5, which means using canvas or Scalable Vector Graphics (SVG). Of these two options, the SVG seemed to be the most reasonable choice because of all the zooming and panning that is needed for maps. Canvas is really fast, but since it works as a bitmap (not vector graphics), zooming can be tricky and processor intensive, while SVG works as a set of independent vector-constructed DOM elements, which can be more naturally zoomed as needed.

How It Works

Interactive Map

SVG (Bonsai JS)

For the task of manipulating SVG, some alternatives for libraries were considered: Raphaël, Velocity JS, SVG JS, Walkway, Snap SVG, Bonsai JS, Lazy Line Painter, Vivus, Progressbar JS and Two JS. All of them pretty full-featured and have their own merits, but from these, I chose Bonsai JS because of the following features:

Architecturally separated runner and renderer

iFrame, Worker and Node running contexts

Paths

Assets (Audio, Video, Images, Fonts, SubMovies)

Keyframe and time based animations (easing functions too)

Path morphing

Getting Started with Bonsai JS

Bonsai JS requires only three things:

The Bonsai script: < script src =" http://cdnjs.cloudflare.com/ajax/libs/bonsai/0.4/bonsai.min.js" > < / script > The movie div element: < div id =" movie" > < /div > The <class>bonsai.run code to start creating drawing: var movie = bonsai.run( document .getElementById( ' movie' ) , { urls: [ ' js/linq.min.js' , ' js/model.js' , ' js/great-war-worker.js' ], width: 800 , height: 400 });

The following line of the above script declares which JavaScript files will run in the context of the Web Worker thread. In this line we must declare not only the main application file (great-war-worker.js) but also its dependencies (linq.min.js and model.js). Keep in mind that the <class>Bonsai code runs in a separate thread. For this reason, whatever JavaScript code runs in the main web page context will remain invisible and inaccessible.

urls: [ ' js/linq.min.js' , ' js/model.js' , ' js/great-war-worker.js' ],

Web Worker Messaging

Since Bonsai JS requires the JavaScript code to run in a Web Worker and in a separate thread, the code containing Bonsai-related objects (i.e. SVG elements), cannot access the DOM directly, and vice versa. For this reason, we should establish a two-way communication through Bonsai's proprietary messaging.

Event model for web workers - Image by: Tom Pascall's Web workers and responsiveness.

We must first create handlers dedicated to listening each kind of messages, from both the DOM thread and the Web Worker thread. Once the handlers are ready, we can send the messages that will pass information to and from the parts involved.

The handlers in the <class>Web Page side are set up as follows. Notice that the events are attached only after the default <class>load message is called. This signals that the communication is already established. Then comes the messages related to Country elements (overCountry/outCountry/clickLocation). Finally comes the ready message, which invokes the zoom and panning control (that will be explained soon). And inside that, we can see how the zoomChanged message (inside onZoom event) is sent from the Web Page to Web Worker, so that the SVG map can modify its appearance based on the scale parameter (more on that soon). The commands to listen to/send messages are functions of the <class>movie

var movie = bonsai.run(...); movie.on( ' load' , function () { movie.on( ' message:overCountry' , function (countryData) { ... ... highlight the country wherever it is found on the timeline list ... }); movie.on( ' message:outCountry' , function (countryData) { ... ... undo highlighting the country wherever it is found on the timeline list ... }); movie.on( ' message:clickLocation' , function (countryData) { ... ... toggle highlighting for the selected country and start a new search by that country name ... }); movie.on( ' message:ready' , function () { $( ' svg' ).attr( ' id' , ' svg-id' ); var panZoomInstance = svgPanZoom( ' #svg-id' , { onZoom: function (scale) { movie.sendMessage( ' zoomChanged' , { scale: scale }); }, , , , }); panZoomInstance.zoom( 1 . 0 ); }); });

In the <class>Web Worker, on the other hand, the commands to listen to or emit messages are attached to the <clas>stage object, which functions as the root instance for the hierarchy of objects in Bonsai JS.

stage.on( ' message:enterCountry' , function (data) {... stage.on( ' message:leaveCountry' , function (data) {... stage.on( ' message:clickLocation' , function (data) {... stage.on( ' message:zoomChanged' , function (data) {... stage.on( ' message:timeLineEventChanged' , function (data) {... ... stage.sendMessage( ' ready' , {}); ... stage.sendMessage( ' overCountry' , scope.countryData); ... stage.sendMessage( ' outCountry' , scope.countryData); ... stage.sendMessage( ' clickLocation' , scope.countryData); ...

Web Worker Main Script (great-war-worker.js)

The great-war-worker.js is structured as to provide separated functionality for three main entities: Country, BigCity and SmallCity. Both BigCity and SmallCity are inherited from BaseCity via prototype chain.

function WorldWarOne(data) {...} var CountryObject = function (p, data, i, myColor) {...} var BaseCity = function (parent, data) {...} var BigCity = function (parent, data) {...} var SmallCity = function (parent, data, citySize) {...}

The <class>WorldWarOne object is the instance that holds all other instances inside our Web Worker JavaScript code. As it can be seen, each one of these objects are constructed according to the data provide by the model.js file.

function WorldWarOne(data) { var scope = this ; ... ...local vars go here... ... for ( var i = 0 ; i < model.countries.length ; i++) { ... ... country shapes are built here ... } for ( var i = 0 ; i < model.cities.length ; i++) { ... ... major city shapes are built here ... } for ( var i = 0 ; i < model.locations.length ; i++) { ... ... minor city shapes are built here ... } ... }

Countries

Looking at the map you immediately notice that many modern countries are missing. That's 1914, the age of fierce nationalism, but also the age of even stronger imperialism. And at the end of the conflict some of these empires are about to collapse forever (or at least for the following 21 years, until another catastrophic war ensued).

The lines that represent the country borders have been extracted from an existing SVG file provided by d-maps.com. This SVG file is a set of <class>Path objects grouped together, each <class>Path for the territories of an independent country on the map.

The <class>Path objects have been extracted from the original file, and put in our /js/model.js file, so that they can be easily manipulated via code (JavaScript).

var model = { countries: [ { code: ' SWE' , name: ' Sweden' , path: ' M175 32.4866c-0.1298,0 -0.2026,0.0572...' }, { code: ' AUS' , name: ' Austria-Hungary' , path: ' M159.488 141.139l-0.4984 0.0106...' }, { code: ' ROM' , name: ' Romania' , path: ' M229.156 119.752l-1.3951 -0.194c-0.7951,...' }, { code: ' BUL' , name: ' Bulgaria' , path: ' M193.766 132.125c0.2347,0.0681...' }, { code: ' SER' , name: ' Serbia' , path: ' M175.82 138.657c0.0416,0.1672 0.6748,1.3374...' }, { code: ' MON' , name: ' Montenegro' , path: ' M175.058 149.117l0.7225 -1.5865c0.1538,...' { code: ' GER' , name: ' Germany' , path: ' M152.074 55.2057c-0.0882,-0.0052 -0.135,-0.0208 ...' }, . . . ], . . .

The map countries are divided according to the 3 exising political groups during the conflict: Entente, Central Powers and Neutral Countries. Each of these groups are assigned with a different color.

var model = { . . . tripleEntente: { color: ' #80c0ff' , countries: [ ' POR' , ' GBR' , ' FRA' , ' BEL' , ' ITA' , ' RUS' , ' ROM' , ' SER' , ' MON' , ' ALB' , ' GRE' ] }, centralPowers: { color: ' #ffc080' , countries: [ ' GER' , ' AUS' , ' BUL' , ' TUR' ] }, neutral: { color: ' #808080' , countries: [ ' NOR' , ' SWE' , ' DEN' , ' NET' , ' SWI' , ' SPA' ] } }

Each country shape is built according to its shape ("path" property) and political block color (Neutral = grey, Entente = blue, Central Powers = salmon).

... ... ... for ( var i = 0 ; i < model.countries.length ; i++) { var c = model.countries[i]; var myColor = ' #808080' ; if (model.tripleEntente.countries.indexOf(c.code) >= 0 ) { myColor = model.tripleEntente.color; } else if (model.centralPowers.countries.indexOf(c.code) >= 0 ) { myColor = model.centralPowers.color; } else if (model.neutral.countries.indexOf(c.code) >= 0 ) { myColor = model.neutral.color; } var countryObject = new CountryObject( this , c, i, myColor) this .countries.push(countryObject); } ... ... ...

Panning and Zooming

Panning and zooming is what allows us to freely see the map in its details, after observing the whole picture. But simply embedding a map in our page will not allow us to pan and zoom as we wish. Instead, we should provide some control buttons to do it (along with the ability to move the image towards any direction and use mouse wheel to scale it up and down.

Thankfully, we have a handy toolset at SVG-Pan-Zoom library. As they describe it themselves:

Simple pan/zoom solution for SVGs in HTML. It adds events listeners for mouse scroll, double-click and pan, plus it optionally offers:

JavaScript API for control of pan and zoom behavior

onPan and onZoom event handlers

On-screen zoom controls

It works cross-browser and supports both inline SVGs and SVGs in HTML object or embed elements.

The on-screen zoom controls work precisely as intended, as one can see by their demo.

The SVG-Pan-Zoom library works with a set of configurations.

viewportSelector can be querySelector string or SVGElement.

can be querySelector string or SVGElement. panEnabled must be true or false. Default is true.

must be true or false. Default is true. controlIconsEnabled must be true or false. Default is false.

must be true or false. Default is false. zoomEnabled must be true or false. Default is true.

must be true or false. Default is true. dblClickZoomEnabled must be true or false. Default is true.

must be true or false. Default is true. mouseWheelZoomEnabled must be true or false. Default is true.

must be true or false. Default is true. preventMouseEventsDefault must be true or false. Default is true.

must be true or false. Default is true. zoomScaleSensitivity must be a scalar. Default is 0.2.

must be a scalar. Default is 0.2. minZoom must be a scalar. Default is 0.5.

must be a scalar. Default is 0.5. maxZoom must be a scalar. Default is 10.

must be a scalar. Default is 10. fit must be true or false. Default is true.

must be true or false. Default is true. contain must be true or false. Default is false.

must be true or false. Default is false. center must be true or false. Default is true.

must be true or false. Default is true. refreshRate must be a number or auto

must be a number or auto beforeZoom must be a callback function to be called before zoom changes.

must be a callback function to be called before zoom changes. onZoom must be a callback function to be called when zoom changes.

must be a callback function to be called when zoom changes. beforePan must be a callback function to be called before pan changes.

must be a callback function to be called before pan changes. onPan must be a callback function to be called when pan changes.

must be a callback function to be called when pan changes. customEventsHandler must be an object with init and destroy arguments as functions.

must be an object with init and destroy arguments as functions. eventsListenerElement must be an SVGElement or null.

We set up our SVG-Pan-Zoom instance as follows:

var panZoomInstance = svgPanZoom( ' #svg-id' , { zoomEnabled: true , controlIconsEnabled: true , dblClickZoomEnabled: true , mouseWheelZoomEnabled: true , preventMouseEventsDefault: true , zoomScaleSensitivity: 0 . 25 , minZoom: 1 , maxZoom: 10 , fit: true , contain: false , center: true , refreshRate: ' auto' , beforeZoom: function () { } , onZoom: function (scale) { movie.sendMessage( ' zoomChanged' , { scale: scale }); } , beforePan: function () { } , onPan: function () { } , eventsListenerElement: null });

Notice that we must pass the selector of the SVG element that will be dealt with (in this case, id='svg-id').

The good news is that you don't have to mess with your Bonsai JS code (that is, Web Worker code) to perform panning/zooming: it's all within the main Web Page thread.

Once the above code is called, the SVG-Pan-Zoom controls pop up over the SVG image:

If you scroll your mouse wheel or push the on-screen plus button, you will see the SVG image scaling up:

...a little more...

If you take any map application as an example, you will see that, despite the volume of data contained in the map, the information is only displayed on demand. If you zoom out, you should see only the most relevant data. Once you zoom in, you start seeing the details.

For this reason, each time the zoom changes, we send a message to the Web Worker thread (with the new scale as the parameter), which in turn will perform some operations, such as showing/hiding minor cities, changing font sizes and making countries's borders thinner, so that these zoomed in elements don't clutter the visualization.

var panZoomInstance = svgPanZoom( ' #svg-id' , { , onZoom: function (scale) { movie.sendMessage( ' zoomChanged' , { scale: scale }); } });

Major Cities

Major cities (that is, capital cities and cities that would soon become new countries' capital cities) are treated differently in the application. Their name have a bigger font size, and they appear visible even when no scale is applied.

var model = { . . ., cities: [ { name: ' London' , x: 166 , y: 136 }, { name: ' Paris' , x: 175 , y: 178 }, { name: ' Lisbon' , x: 21 , y: 278 }, { name: ' Madrid' , x: 84 , y: 278 }, { name: ' Bern' , x: 220 , y: 215 }, { name: ' Brussels' , x: 202 , y: 153 }, { name: ' Amsterdam' , x: 212 , y: 129 }, { name: ' Copenhagen' , x: 275 , y: 92 }, { name: ' Oslo' , x: 277 , y: 34 }, { name: ' Stockholm' , x: 330 , y: 40 }, { name: ' Berlin' , x: 290 , y: 148 }, { name: ' Prague' , x: 294 , y: 180 }, { name: ' Vienna' , x: 305 , y: 208 }, { name: ' Rome' , x: 263 , y: 296 }, { name: ' Sarajevo' , x: 324 , y: 272 }, { name: ' Athens' , x: 384 , y: 353 }, { name: ' Constantinople' , x: 441 , y: 302 }, { name: ' Bucarest' , x: 406 , y: 260 }, { name: ' Sofia' , x: 374 , y: 287 }, { name: ' Belgrade' , x: 344 , y: 262 }, { name: ' Budapest' , x: 331 , y: 218 }, { name: ' Warsaw' , x: 351 , y: 147 }, { name: ' Moscow' , x: 481 , y: 78 }, { name: ' Dublin' , x: 117 , y: 93 }, { name: ' Belfast' , x: 127 , y: 75 }, { name: ' Tunis' , x: 229 , y: 364 }, { name: ' Kiev' , x: 436 , y: 167 }, { name: ' Minsk' , x: 403 , y: 121 }, { name: ' Vilnius' , x: 384 , y: 111 }, { name: ' Riga' , x: 372 , y: 78 }, { name: ' Edimburg' , x: 152 , y: 63 }, { name: ' Rabat' , x: 22 , y: 359 }, { name: ' Algiers' , x: 144 , y: 357 }, { name: ' Zagreb' , x: 299 , y: 241 } ] . . . };

Battle Locations

This kind of sites appear with smaller font size, and are not immediately visible. It would appear only whe the user start zooming to a minimum scale.

stage.on( ' message:zoomChanged' , function (data) { scope.scale = data.scale; ... for ( var i = 0 ; i < scope.locations.length ; i++) { var l = scope.locations[i]; l.zoomChanged(scope.scale); } ... }); SmallCity.prototype.zoomChanged = function (value) { var scope = this ; scope.cityPath.attr({ scale: 2 ^ ( 1 . 0 / (value * . 8 )) }); if (value > 2 ) { scope.cityPath.attr({ visible: false }); scope.textGroup.attr({ visible: true }); } else { scope.cityPath.fill( ' #000' ); scope.cityPath.attr({ visible: false }); scope.textGroup.attr({ visible: false }); } };

When these locations are selected in the timeline list, the corresponding position in the map will be marked by a Map Pin image (similar to the famous Google Maps pin).

stage.on( ' message:timeLineEventChanged' , function (data) { if (data.event) { var event = data.event; if (event) { if (event.position) { scope.mapPin .attr({ x: event.position.x + 15 + MAP_OFFSET.x, y: event.position.y - 5 + scope.scale * 2 - (scope.scale - 1 ) * 1 . 65 + MAP_OFFSET.y, visible: true }); scope.mapPin.animate( ' .5s' , { fillColor: ' #880' }, { repeat: 10000 }); } else { scope.mapPin .attr({ visible: false }); } } else { scope.mapPin.attr({ visible: false }); } } else { scope.mapPin.attr({ visible: false }); }

Selecting Countries

There are two modes of selection of countries. The first is when the mouse is moving over the country in the map (at this point the country is temporarily highlighted), and the second is when the user clicks on the country. This single click toggles the selection of the country, and can also unselect it a second time.

stage.on( ' message:enterCountry' , function (data) { for ( var i = 0 ; i < scope.countries.length ; i++) { var c = scope.countries[i]; if (c.countryData.name == data.country) { c.animateCountrySelection(); } } }); stage.on( ' message:leaveCountry' , function (data) { for ( var i = 0 ; i < scope.countries.length ; i++) { var c = scope.countries[i]; var selectedCountryName = scope.selectedCountry ? scope.selectedCountry.countryData.name : ' ' ; if (c.countryData.name == data.country && data.country != selectedCountryName) { c.animateCountryUnselection(); } } }); stage.on( ' message:clickLocation' , function (data) { for ( var i = 0 ; i < scope.countries.length ; i++) { var c = scope.countries[i]; if (c.countryData.name == data.country) { scope.click(c); c.animateCountrySelection(); } } }); ... var CountryObject = function (p, data, i, myColor) { var scope = this ; scope.over = function () { if (!parent.ready) { parent.setReady(); } stage.sendMessage( ' overCountry' , scope.countryData); scope.animateCountrySelection(); }; scope.animateCountrySelection = function () { scope.countryPath.animate( ' .2s' , { fillColor: scope.kolor.darker(. 3 ) }, { easing: ' sineOut' }); } scope.animateCountryUnselection = function () { scope.countryPath.animate( ' .2s' , { fillColor: scope.kolor }, { easing: ' sineOut' }); } scope.out = function () { stage.sendMessage( ' outCountry' , scope.countryData); if (!scope.selected) { scope.animateCountryUnselection(); if (parent.selectedCountry) { stage.sendMessage( ' overCountry' , parent.selectedCountry.countryData); } } } scope.click = function () { parent.click(scope); stage.sendMessage( ' clickLocation' , scope.countryData); }; ...

When the user toggles a country, the timeline list is autmatically filtered by that country's name, so only the events relevant for that country will be visible.

Timeline Panel

Server Side Service

While all the rest is running on the client browser, this one the only piece of server-side functionality, contained in the Generic Handler (/services/GetTimeline.ashx file).

The handler accepts two parameters:

lastId : the last event id. Means that the service should retrieve only the items (timeline events) following the given Event Id . Default is zero.

: the last event id. Means that the service should retrieve only the items (timeline events) following the given . Default is zero. txt: the criteria text to filter by. Default is empty string.

using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Net; using System.Web; public class GetTimeline : IHttpHandler { const int PAGE_SIZE = 8 ; const int MIN_SEARCH_TERM_SIZE = 4 ; public void ProcessRequest (HttpContext context) { var uri = new Uri( new Uri(context.Request.Url.AbsoluteUri), " timeline.json" ); List<TimelineEvent> list = new List<TimelineEvent>(); var timelineJson = new WebClient().DownloadString(uri.ToString()); list = JsonConvert.DeserializeObject<List<TimelineEvent>>(timelineJson); int id = 1 ; list.ForEach(i = > i.id = id++); var lastId = int .Parse(context.Request[ " lastId" ]); var searchText = context.Request[ " txt" ]; var query = list .Where(q = > string .IsNullOrEmpty(searchText) || searchText.Length < MIN_SEARCH_TERM_SIZE || CultureInfo.CurrentCulture .CompareInfo .IndexOf(q.text, searchText, CompareOptions.IgnoreCase) > = 0 ) .Where(i = > i.id > lastId) .Take(PAGE_SIZE); var result = query.ToList(); var json = JsonConvert.SerializeObject(result); context.Response.ContentType = " application/json" ; context.Response.Write(json); } public bool IsReusable { get { return false ; } } } public class TimelineEvent { public int id { get ; set ; } public int year { get ; set ; } public string date { get ; set ; } public string text { get ; set ; } public int [] position { get ; set ; } public string videoCode { get ; set ; } }

[ { " year" : 1914 , " date" : " June 28" , " text" : " Assassination of Archduke Franz Ferdinand of Austria, heir to the Austro-Hungarian throne, who was killed in Sarajevo along with his wife Duchess Sophie by Bosnian Serb Gavrilo Princip.[1]" , " position" : [ 324 , 272 ], " videoCode" : " ZmHxq28440c" }, { " year" : 1914 , " date" : " July 5" , " text" : " @Austria-Hungary seeks German support for a war against @Serbia in case of Russian militarism. @Germany gives assurances of support.[2]" }, { " year" : 1914 , " date" : " July 23" , " text" : " @Austria-Hungary sends an ultimatum to @Serbia. The Serbian response is seen as unsatisfactory.[3]" }, { " year" : 1914 , " date" : " July 28" , " text" : " @Austria-Hungary declares war on @Serbia. @Russia mobilizes.[4]; The @Netherlands declare neutrality." }, { " year" : 1914 , " date" : " July 31" , " text" : " @Germany warns @Russia to stop mobilizing. @Russia says mobilization is against @Austria-Hungary.; @Germany declares war on @Russia.[5]; @Italy declares its neutrality.; Denmark declares its neutrality.[6]; @Germany and the @Ottoman Empire sign a secret alliance treaty.[7]; August 2; Skirmish at Joncherey, first military action on the Western Front" }, { " year" : 1914 , " date" : " August 2–26" , " text" : " @Germany besieges and captures fortified Longwy 'the iron gate to Paris' near the @Luxembourg border, opening @France to mass German invasion" , " position" : [ 208 , 177 ] }, . . . { " year" : 1918 , " date" : " November 12" , " text" : " Austria proclaimed a republic." }, { " year" : 1918 , " date" : " November 14" , " text" : " Czechoslovakia proclaimed a republic." }, { " year" : 1918 , " date" : " November 14" , " text" : " German U-boats interned." }, { " year" : 1918 , " date" : " November 14" , " text" : " 3 days after the armistice, fighting ends in the East African theater when General von Lettow-Vorbeck agrees a cease-fire on hearing of @Germany's surrender." }, { " year" : 1918 , " date" : " November 21" , " text" : " @Germany's Hochseeflotte surrendered to the @United Kingdom.[63]" }, { " year" : 1918 , " date" : " November 22" , " text" : " The Germans evacuate @Luxembourg." }, { " year" : 1918 , " date" : " November 25" , " text" : " 11 days after agreeing a cease-fire, General von Lettow-Vorbeck formally surrenders his undefeated army at Abercorn in present-day Zambia." }, { " year" : 1918 , " date" : " November 27" , " text" : " The Germans evacuate @Belgium." }, { " year" : 1918 , " date" : " December 1" , " text" : " Kingdom of Serbs, Croats and Slovenes proclaimed." } ]

Angular JS

There are many benefits of using Angular JS in this project. They include a more structured JavaScript code, templating, two-way-data-binding, modular development and fits nicely in a SPA (Single Page Application) like this.

First we declare the Angular app name (ng-app) and the controller name (ng-controller).

< div class =" " ng-app =" greatWarApp" ng-controller =" greatWarCtrl" >

Then we bind the timeline events to our HTML via the ng-bind attribute. And we also ensure the timeline grid will show each event in timeline.events by declaring iteration through the ng-repeat attribute.

... < div ng-repeat =" event in timeline.events" > < div class =" col-xs-2 col-md-2" > < div > < span class =" date" ng-bind =" event.date" > < /span > < /div > < div > < span class =" year" ng-bind =" event.year" > < /span > < /div > < /div > < div class =" col-xs-8 col-md-8" > < div bind-html-compile =" event.text" > < /div > < /div > < /div > ...

Before showing in the timeline, each event text is modified in the Angular App code (file: /js/great-war-app.js), so that each country shows as a link. This is done by replacing the plain text of the country's name that comes from the service by an anchor HTML element (<a>).

angular.forEach(model.countries, function (v2, k2) { var c = model.countries[k2]; ev.text = ev.text.replace( new RegExp ( ' \@' + c.name, ' g' ) , ' <a country-link="' + c.name + ' ">' + c.name + ' </a>' ); if (k2 == 0 ) this .year = v2.year; });

There is a problem with this approach, though. When you simply bind a text containing HTML tags, they will be converted as plain text and shown as-is by Angular binding engine. If we want to automatically converted any HTML tags inside text in the binding, we should combile through a specialized directive, such as:

var app = angular.module( " greatWarApp" , [ " angular-bind-html-compile" , " youtube-embed" , " infinite-scroll" ]);

The next step will be to replace the usual ng-bind-html directive by the attribute of the specialized bind-html-compile directive, and the binding value will magically be interpreted as HTML code:

< div bind-html-compile =" event.text" > < /div >

Notice also that each country name is replaced by an anchor element with the attribute country-link. This attribute invokes the custom directive <class>countryLink, which in turn will handle the mouseenter, mouseleave and click events on the country's link.

app.directive( ' countryLink' , function () { var SELECTED_HIGHLIGHTED = ' highlighted' ; return { restrict: ' A' , scope: { countryLink: ' @' }, link: function (scope, element) { element.on( ' mouseenter' , function () { movie.sendMessage( ' enterCountry' , { country: scope.countryLink }); }); element.on( ' mouseleave' , function () { movie.sendMessage( ' leaveCountry' , { country: scope.countryLink }); }); element.on( ' click' , function () { movie.sendMessage( ' clickLocation' , { country: scope.countryLink }); var countryName = element.attr( ' country-link' ); if (element.hasClass(SELECTED_HIGHLIGHTED)) { $( ' .events-grid a[country-link="' + countryName + ' "]' ).removeClass( ' highlighted' ); } else { $( ' .events-grid a[country-link]' ).removeClass( ' highlighted' ); $( ' .events-grid a[country-link="' + countryName + ' "]' ).addClass( ' highlighted' ); } }); } }; })

Infinite Scrolling

When you have a big mass of data to show, it makes sense to partition it in distinct pages that you retrieve as you browse. One common way to paginate is to provide a more button that your user hit when he/she wants to load more, or automatically make a request to the next chunk of data as the user reach the bottom of the current list, and this is what we call Infinite Scrolling.

There are many ways to implement such infinite scrolling, and thankfully we have some <class>Angular directives to do the job, including the ngInfiniteScroll directive, which is quite straightforward and quick to implement.

< div panel-type =" timeline" infinite-scroll =' timeline.nextPage()' infinite-scroll-disabled =' timeline.scrollDisabled()' infinite-scroll-distance =' 1' infinite-scroll-container =' ".events-grid"' > < div class =" row row-eq-height scrollbox-content" ng-repeat =" event in timeline.events" event-position =" {{event.position}}" > < div class =" col-xs-12 col-md-12" ng-hide =" !(event.video.code) || !event.video.containerVisible" > < div ng-class =" event.video.containerVisibleCSSClass()" > < youtube-video video-id =" event.video.code" player-width =" 450" player-height =" 276" player =" event.video.player" player-vars =" event.video.vars" > < /youtube-video > < /div > < /div > < div class =" col-xs-2 col-md-2 icon-container" > < span class =" icon-helper" > < /span > < span > < img src =" img/icons/video.svg" width =" 32" height =" 32" ng-class =" event.video.iconCSSClass()" ng-hide =" !(event.video.code)" ng-click =" event.video.click()" / > < /span > < /div > < div class =" col-xs-2 col-md-2" > < div > < span class =" date" ng-bind =" event.date" > < /span > < /div > < div > < span class =" year" ng-bind =" event.year" > < /span > < /div > < /div > < div class =" col-xs-8 col-md-8" > < div bind-html-compile =" event.text" > < /div > < /div > < div style =' clear: both;' > < /div > < /div > < /div >

The above code show the <class>nextPage function invoking the <class>search function, which in turn produces a request to the service at GetTimeline.ashx.

Timeline.prototype.nextPage = function () { var after = function (context) { context.lastId = context.events[context.events.length - 1 ].id; } this .search(after); }; . . . Timeline.prototype.search = function (after) { if ( this .busy) return ; this .busy = true ; var url = " /services/GetTimeline.ashx?lastId=" + this .lastId + ' &txt=' + this .searchText; $http({ method: ' GET' , url: url }).then( function successCallback(response) { var addedEvents = response.data; this .processTimelineResponse(addedEvents, this .events); this .busy = false ; if (after) after( this ); this .noMoreResults = (addedEvents.length < PAGE_SIZE); }.bind( this ), function errorCallback(response) { }.bind( this )); }

Filtering

Clicking on the country (or typing in a search criteria) will trigger a request to the service (Generic Handler, contained in /services/GetTimeline.ashx), so only the events containing that criteria will show up in the timeline list.

public class GetTimeline : IHttpHandler { ... public void ProcessRequest (HttpContext context) { ... var lastId = int .Parse(context.Request[ " lastId" ]); var searchText = context.Request[ " txt" ]; var query = list .Where(q = > string .IsNullOrEmpty(searchText) || searchText.Length < MIN_SEARCH_TERM_SIZE || CultureInfo.CurrentCulture .CompareInfo .IndexOf(q.text, searchText, CompareOptions.IgnoreCase) > = 0 ) .Where(i = > i.id > lastId) .Take(PAGE_SIZE); var result = query.ToList(); ... } }

Youtube Embedded

Some events in the timeline are obviously more relevant than others, and in order to provide more information about them, the application provides the ability to watch a YouTube video for the corresponding Great War episode.

The Great War is a great channel, probably the best YouTube channel on the Great War. It is hosted by American actor, writer and historian Indy Neidell, who is from Texas and lives currently in Stockholm, Sweden. The channel provide a lot of invaluable information, amazing pics, video clips plus the charm and humor of Indy's hosting.

The YouTube team has long provided a complete JavaScript API for embedding YouTube videos in web pages. The problem is that at some point we would have to integrate that YouTube code with our <class>Angular JS app.

For this reason, Matthew Brandly has taken the time to create the awesome Angular YouTube Embed, a directive aimed at integrating <class>Angular JS and YouTube JavaScript client code.

First we have to declare the youtube-embed external directive at the setup of our Angular app.

var app = angular.module( " greatWarApp" , [ " angular-bind-html-compile" , " youtube-embed" , " infinite-scroll" ]);

Then we implement the youtube-video directive, passing the video-id as a parameter. Notice that the events without a video code attached will not show the video icon.

<div class = " col-xs-12 col-md-12" ng-hide= " !(event.video.code) || !event.video.containerVisible" > <div ng-class= " event.video.containerVisibleCSSClass()" > <youtube-video video-id= " event.video.code" player-width= " 450" player-height= " 276" player= " event.video.player" player-vars= " event.video.vars" > </ youtube-video > </ div > </ div >

Another interesting feature of this application is the automatic selection of countries in the map as they are mentioned in the Youtube Video.

At this moment, Indy Neidell is talking about three countries: Germany, Austro-Hungary and Russia.

As soon as that subtitle appears on screen, those three countries (Germany, Austro-Hungary, Russia) are selected:

At first this does not appears to be a big deal, but it opens up a lot of possibilities. Just think about educational tools using Youtube videos and providing maps, images and other assets as a complement to the actual subject being taught on the videos!

But how does this work? first of all, some Youtube videos (not all, unfortunately) have transcripts associated to them.

Youtube video transcripts ara accessible via the url: http://video.google.com/timedtext?lang=en&v=[VIDEO_CODE]

Once the video is opened, the transcript is obtained via get method, and the <class>trancript property of the video object is set.

ev.video.player.playVideo(); setInterval( function () { this .timeoutFunction(ev); }.bind( this ), 1000 ); $http({ method: ' GET' , url: ' http://video.google.com/timedtext?lang=en&v=' + ev.videoCode }).then( function successCallback(response) { var xmlStr = response.data; var x2js = new X2JS(); var jsonStr = JSON .stringify(x2js.xml_str2json(xmlStr)); if (jsonStr) { ev.video.transcript = JSON .parse(jsonStr).transcript; }

(The resulting transcript is received as XML data. Notice the use of the x2js library which provides XML to JSON and vice versa.)

As the video is playing, the <class>timeoutFunction function searches for the specific transcript line where the current time falls between. If one line is found, the spoken line is parsed and interpreted to search for country or city names. If one or more location are found in the transcript text, those locations are highlighted in the map, and will remain so until the player reaches the next transcript line.

this .timeoutFunction = function (ev) { if (ev.video.player.getPlayerState() == 1 ) { ev.video.time = ev.video.player.getCurrentTime(); for ( var i = 0 ; i < ev.video.transcript.text.length; i++) { var transcriptItem = ev.video.transcript.text[i]; if (ev.video.time > parseFloat (transcriptItem._start) && ev.video.time < parseFloat (transcriptItem._start) + parseFloat (transcriptItem._dur)) { for ( var j = 0 ; j < model.countries.length; j++) { var country = model.countries[j]; if (transcriptItem.__text.indexOf(country.name) > -1 || transcriptItem.__text.indexOf(country.demonym) > -1) { movie.sendMessage( ' enterCountry' , { country: country.name }); } else { movie.sendMessage( ' leaveCountry' , { country: country.name }); } } var locationFound = false ; for ( var j = 0 ; j < model.cities.length; j++) { var city = model.cities[j]; if (transcriptItem.__text.indexOf(city.name) > -1) { movie.sendMessage( ' timeLineEventChanged' , { event: { position: { x: city.x, y: city.y } } }); locationFound = true ; } } for ( var j = 0 ; j < model.locations.length; j++) { var location = model.locations[j]; if (transcriptItem.__text.indexOf( location .name) > -1) { movie.sendMessage( ' timeLineEventChanged' , { event: { position: { x: location .x, y: location .y } } }); locationFound = true ; } } if (!locationFound) { movie.sendMessage( ' timeLineEventChanged' , { event: {} }); } break ; } } } }.bind( this );

Final Considerations

That is it. I was happy to work on a project that deals not only with technology but also history topics. As a history geek (whith little knowledge on the Great War) I feel it was more exciting to combine the subjects I'm learning together (programming and history) than to approach technology just for the sake of technology.

I hope you enjoyed both the article and the attached application code. If you like it, post a comment below, and if you feel it might be useful for your friends and colleagues, please don't forget to share it on Facebook, Twitter, Linked In and other social media.

History