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 built as an educational tool to help students and history geeks to improve knowledge and consolidate their understanding, 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 solution involves 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.
We must first create handlers dedicated to listening to 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 is 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 three existing 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
. panEnabled
must be true
or false
. Default is true
. controlIconsEnabled
must be true
or false
. Default is false
. zoomEnabled
must be true
or false
. Default is true
. dblClickZoomEnabled
must be true
or false
. Default is true
. mouseWheelZoomEnabled
must be true
or false
. Default is true
. preventMouseEventsDefault
must be true
or false
. Default is true
. zoomScaleSensitivity
must be a scalar. Default is 0.2
. minZoom
must be a scalar. Default is 0.5
. maxZoom
must be a scalar. Default is 10
. fit
must be true
or false
. Default is true
. contain
must be true
or false
. Default is false
. center
must be true
or false
. Default is true
. refreshRate
must be a number or auto. beforeZoom
must be a callback function to be called before zoom changes. onZoom
must be a callback function to be called when zoom changes. beforePan
must be a callback function to be called before pan changes. onPan
must be a callback function to be called when pan changes. customEventsHandler
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 has 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
These kind of sites appear with smaller font size, and are not immediately visible. It would appear only when the user started 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 automatically 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. 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 convert any HTML tags inside text in the binding, we should compile
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 shows 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 <a href="https://www.youtube.com/watch?v=eFqExXJSwRw">Indy Neidell</a>
, 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 appear 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 are 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 transcript
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 locations 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 (with 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
- 7th July, 2016: First version
- 8th July, 2016: Download instructions
- 15th July, 2016: Online demo
- 4th August, 2016: Fixed Linq query
- 5th August, 2016: Transcript lines explained