欧洲大战:1914-1918






4.98/5 (42投票s)
一个交互式地图,使用C#、SVG、JavaScript、Angular和YouTube
作者笔记
引用下载代码后,在Visual Studio中将Great War文件夹作为网站打开
引言
本文旨在构建一个交互式地图SPA(单页应用程序),以纪念第一次世界大战(1914-1918)100周年。一百年前,这场战争波及了世界大部分地区,将欧洲拖入了毁灭和社会混乱的动荡之中。
该应用程序是作为一个教育工具而构建的,旨在通过融合地理历史地图、时代事件和关于第一次世界大战的现代视频纪录片,帮助学生和历史爱好者提高知识和巩固理解。
背景
有几种方法可以使用不同的技术构建交互式地图。其中一种是通过Web地图,使用地理信息系统(GIS),例如Open Street Map,它提供了大量信息和许多内置工具,但对于我们简单的应用程序来说可能有点过于复杂。另一种解决方案涉及一种更简单的方法,通过直接绘制地图和控制图形元素。为此,我们可以使用一种久经考验的技术(这里有点双关语),例如Adobe Flash,它曾经风光一时,但现在对于新项目来说基本不可行。现在它们通常使用HTML5构建,这意味着使用canvas或可缩放矢量图形(SVG)。在这两个选项中,由于地图需要大量的缩放和平移,SVG似乎是最合理的选择。Canvas速度非常快,但由于它作为位图(非矢量图形)工作,缩放可能很棘手且占用大量处理器资源,而SVG作为一组独立的矢量构建的DOM元素工作,可以更自然地根据需要进行缩放。
工作原理
交互式地图
SVG(Bonsai JS)
对于处理SVG的任务,考虑了一些替代库:Raphaël、Velocity JS、SVG JS、Walkway、Snap SVG、Bonsai JS、Lazy Line Painter、Vivus、Progressbar JS和Two JS。它们都功能齐全,各有优点,但在这其中,我选择了Bonsai JS,因为它具有以下特点:
- 架构上分离的运行器和渲染器
- iFrame、Worker和Node运行上下文
- 路径
- 资源(音频、视频、图像、字体、短片)
- 关键帧和基于时间的动画(也包括缓动函数)
- 路径变形
Bonsai JS入门
Bonsai JS只需要三样东西
- Bonsai脚本
<script src="http://cdnjs.cloudflare.com/ajax/libs/bonsai/0.4/bonsai.min.js"></script>
movie
div
元素<div id="movie"></div>
<class>
bonsai.run
代码以开始创建绘图var movie = bonsai.run( document.getElementById('movie') , { urls: ['js/linq.min.js', 'js/model.js', 'js/great-war-worker.js'], width: 800, height: 400 });
上面脚本的以下一行声明了将在Web Worker
线程上下文中运行的JavaScript文件。在此行中,我们不仅必须声明主应用程序文件(great-war-worker.js),还必须声明其依赖项(linq.min.js和model.js)。请记住,<class>
Bonsai代码在一个单独的线程中运行。因此,在主网页上下文中运行的任何JavaScript代码都将保持不可见和无法访问。
urls: ['js/linq.min.js', 'js/model.js', 'js/great-war-worker.js'],
Web Worker消息传递
由于Bonsai JS要求JavaScript代码运行在Web Worker和单独的线程中,包含Bonsai相关对象(即SVG元素)的代码不能直接访问DOM,反之亦然。因此,我们应该通过Bonsai专有的消息传递建立双向通信。
我们必须首先创建专门的处理器来监听来自DOM线程和Web Worker线程的每种类型消息。一旦处理器准备就绪,我们就可以发送消息,将信息传递给参与方和从参与方传递。
<class>
Web Page端的处理器设置如下。请注意,事件仅在默认的<class>
load消息调用后附加。这表示通信已建立。然后是与Country
元素相关的消息(overCountry
/outCountry
/clickLocation
)。最后是ready
消息,它调用zoom and panning
控件(稍后将解释)。在其中,我们可以看到zoomChanged
消息(在onZoom
事件中)如何从Web Page发送到Web Worker,以便SVG地图可以根据比例参数修改其外观(稍后将详细介绍)。监听/发送消息的命令是<class>
movie的函数。
var movie = bonsai.run(...);
// emitted before code gets executed
movie.on('load', function () {
// receive event from the runner context
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
});
},
,
, // a bunch of zooming/panning configurations
,
});
// zoom out
panZoomInstance.zoom(1.0);
});
});
另一方面,在<class>
Web Worker中,监听或发出消息的命令附加到<clas>
stage对象,该对象充当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主脚本(great-war-worker.js)
great-war-worker.js的结构是为了给三个主要实体提供分离的功能:Country
、BigCity
和SmallCity
。BigCity
和SmallCity
都通过prototype chain继承自BaseCity
。
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) {...}
<class>
WorldWarOne
对象是持有我们Web Worker JavaScript代码中所有其他实例的实例。正如您所见,这些对象中的每一个都是根据model.js文件提供的数据构建的。
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
...
}
...
}
国家
看着地图,您会立即注意到许多现代国家缺失。那是1914年,是激烈民族主义的时代,但也是更强帝国主义的时代。冲突结束时,一些帝国即将永远崩溃(或者至少在接下来的21年里,直到另一场灾难性战争的发生)。
代表国界的线条是从d-maps.com提供的现有SVG文件中提取的。这个SVG文件是一组<class>
Path
对象,每个<class>
Path
代表地图上一个独立国家的领土。
<class>
Path
对象已从原始文件中提取,并放入我们的/js/model.js文件中,以便可以通过代码(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 ...'},
.
.
.
],
.
.
.
地图上的国家根据冲突期间存在的三个政治集团进行划分:Entente
、Central Powers
和Neutral Countries
。每个组都分配了不同的颜色。
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'] }
}
每个国家的形状都根据其形状(“path
”属性)和政治集团颜色(中立=灰色,协约国=蓝色,同盟国=鲑鱼色)构建。
...
...
...
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);
}
...
...
...
平移和缩放
平移和缩放使我们能够自由地查看地图的细节,在观察了整体图像之后。但仅仅将地图嵌入我们的页面将不允许我们按需进行平移和缩放。相反,我们应该提供一些控件按钮来实现它(以及将图像向任何方向移动以及使用鼠标滚轮进行缩放的能力)。
幸运的是,我们在SVG-Pan-Zoom库中有一个方便的工具集。正如他们自己所描述的那样:
HTML中SVG的简单平移/缩放解决方案。它添加了鼠标滚动、双击和拖动的事件监听器,此外还提供可选的:
- 用于控制平移和缩放行为的JavaScript API
- onPan和onZoom事件处理程序
- 屏幕上的缩放控件
- 它跨浏览器工作,并支持内联SVG以及HTML对象或embed元素中的SVG。
屏幕上的缩放控件正如预期的那样工作,正如他们演示中所见。
SVG-Pan-Zoom
库使用一组配置。
viewportSelector
可以是querySelector字符串
或SVGElement
。panEnabled
必须是true
或false
。默认为true
。controlIconsEnabled
必须是true
或false
。默认为false
。zoomEnabled
必须是true
或false
。默认为true
。dblClickZoomEnabled
必须是true
或false
。默认为true
。mouseWheelZoomEnabled
必须是true
或false
。默认为true
。preventMouseEventsDefault
必须是true
或false
。默认为true
。zoomScaleSensitivity
必须是一个标量。默认为0.2
。minZoom
必须是一个标量。默认为0.5
。maxZoom
必须是一个标量。默认为10
。fit
必须是true
或false
。默认为true
。contain
必须是true
或false
。默认为false
。center
必须是true
或false
。默认为true
。refreshRate
必须是数字或auto。beforeZoom
必须是一个在缩放更改之前调用的回调函数。onZoom
必须是在缩放更改时调用的回调函数。beforePan
必须是一个在平移更改之前调用的回调函数。onPan
必须是在平移更改时调用的回调函数。customEventsHandler
必须是一个包含init
和destroy
作为函数的对象。eventsListenerElement
必须是一个SVGElement
或null
。
我们如下设置我们的SVG-Pan-Zoom
实例:
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
});
请注意,我们必须传入将要处理的SVG
元素的选择器(在本例中是id='svg-id'
)。
好消息是,您不必修改您的Bonsai JS代码(即Web Worker代码)来进行平移/缩放:这一切都在主Web Page线程中完成。
调用上述代码后,SVG-Pan-Zoom控件将出现在SVG图像上方。
如果您滚动鼠标滚轮或按下屏幕上的加号按钮,您将看到SVG图像放大。
……再放大一点……
如果我们以任何地图应用程序为例,您会看到,尽管地图中包含大量数据,但信息仅在需要时显示。如果您缩小,您应该只看到最相关的数据。一旦您放大,您就开始看到细节。
因此,每次缩放级别更改时,我们会向Web Worker线程发送一条消息(将新比例作为参数),该线程随后将执行一些操作,例如显示/隐藏次要城市、更改字体大小以及使国家边界变细,以使这些放大后的元素不会使可视化混乱。
var panZoomInstance = svgPanZoom('#svg-id', {
, onZoom: function (scale) {
movie.sendMessage('zoomChanged', {
scale: scale
});
}
});
主要城市
主要城市(即首都和即将成为新首都的城市)在应用程序中被区别对待。它们的名字有较大的字体大小,并且即使没有应用任何缩放级别,它们也可见。
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 }
]
.
.
.
};
战斗地点
这类地点以较小的字体大小显示,并且不立即可见。只有当用户开始放大到最小缩放级别时,它们才会出现。
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
});
}
};
当这些地点在时间线列表中被选中时,地图上的相应位置将由一个地图图钉图像标记(类似于著名的Google Maps图钉)。
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 });
}
选择国家
有两种选择国家的方式。第一种是当鼠标悬停在地图上的国家上时(此时,国家会暂时高亮显示),第二种是当用户单击该国家时。单击一次会切换国家的选中状态,再次单击则可以取消选中。
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);
};
...
当用户切换国家时,时间线列表会自动按该国家名称过滤,因此只有与该国家相关的事件才会显示。
时间线面板
服务器端服务
虽然其余部分都在客户端浏览器中运行,但这是唯一包含服务器端功能的组件,位于Generic Handler(/services/GetTimeline.ashx文件)中。
该处理程序接受两个参数:
lastId
:最后一个事件id。意味着服务应该只检索给定Event Id
之后的项(时间线事件)。默认为零。txt
:用于过滤的标准文本。默认为空字符串。
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
在项目中 O使用Angular JS有许多好处。它们包括更结构化的JavaScript代码、模板化、双向数据绑定、模块化开发,并且非常适合像这样的SPA(单页应用程序)。
首先,我们声明Angular
应用程序名称(ng-app
)和控制器名称(ng-controller
)。
<div class="" ng-app="greatWarApp" ng-controller="greatWarCtrl">
然后,我们通过ng-bind
属性将时间线事件绑定到我们的HTML。我们还通过声明迭代ng-repeat
属性来确保时间线网格显示timeline.events
中的每个event
。
...
<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>
...
在时间线中显示之前,每个事件文本都会在Angular App
代码(文件:/js/great-war-app.js)中进行修改,以便每个国家都显示为链接。这是通过将来自服务中的国家名称的纯文本替换为anchor
HTML元素(<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;
});
不过,这种方法存在一个问题。当您简单地绑定包含HTML标签的文本时,它们会被转换为纯文本,并被Angular
绑定引擎原样显示。如果我们想自动转换绑定中的任何HTML标签,我们应该通过一个专门的指令进行compile
,例如:
var app = angular.module("greatWarApp",
["angular-bind-html-compile", "youtube-embed", "infinite-scroll"]);
下一步将用专门的bind-html-compile
指令的属性替换通常的ng-bind-html
指令,并且绑定值将神奇地被解释为HTML代码。
<div bind-html-compile="event.text"></div>
同时请注意,每个国家名称都被替换为一个带有country-link
属性的anchor
元素。此属性会调用自定义指令<class>
countryLink
,该指令随后会处理国家链接上的mouseenter
、mouseleave
和click
事件。
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');
}
});
}
};
})
无限滚动
当您需要显示大量数据时,将其划分为您在浏览时检索的独立页面是有意义的。一种常见的分页方式是提供一个more
按钮,用户可以在需要加载更多内容时点击它,或者当用户滚动到当前列表底部时自动发出请求以获取下一块数据,这就是我们称之为Infinite Scrolling
。
有很多方法可以实现这种无限滚动,幸运的是,我们有一些<class>
Angular指令可以完成这项工作,包括ngInfiniteScroll指令,它非常直接且易于实现。
<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>
上面的代码显示了<class>
nextPage
函数调用<class>
search
函数,该函数继而产生对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) {
// called asynchronously if an error occurs
// or server returns response with an error status.
}.bind(this));
}
过滤
单击国家(或在搜索条件中键入)将触发对服务(Generic Handler,位于/services/GetTimeline.ashx)的请求,因此只有包含该条件的事件才会显示在时间线列表中。
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
时间线中的某些事件显然比其他事件更重要,为了提供更多关于它们的信息,该应用程序提供了观看相应Great War剧集的YouTube视频的能力。
The Great War是一个很棒的频道,可能是关于第一次世界大战的最好的YouTube频道。它由美国演员、作家和历史学家<a href="https://www.youtube.com/watch?v=eFqExXJSwRw">Indy Neidell</a>
主持,他来自德克萨斯州,目前居住在瑞典斯德哥尔摩。该频道提供了大量宝贵的信息、精美的图片、视频片段,以及Indy主持风格的魅力和幽默。
YouTube团队早已提供了一个完整的JavaScript API,用于将YouTube视频嵌入网页。问题是,在某个时候,我们需要将该YouTube代码与我们的<class>
Angular JS应用程序集成。
因此,Matthew Brandly花时间创建了出色的Angular YouTube Embed,这是一个旨在集成<class>
Angular JS和YouTube JavaScript客户端代码的指令。
首先,我们必须在Angular应用程序的设置中声明youtube-embed
外部指令。
var app = angular.module("greatWarApp",
["angular-bind-html-compile", "youtube-embed", "infinite-scroll"]);
然后我们实现youtube-video
指令,将video-id
作为参数传递。请注意,没有附加视频代码的事件将不会显示视频图标。
<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>
此应用程序的另一个有趣功能是,当Youtube视频中提到国家时,会自动在地图上选中它们。
此时,Indy Neidell正在谈论三个国家:德国、奥匈帝国和俄国。
一旦字幕出现在屏幕上,这三个国家(德国、奥匈帝国、俄国)就会被选中。
起初,这似乎不是什么大不了的事,但它开启了许多可能性。只需考虑使用Youtube视频的教育工具,提供地图、图像和其他资源来补充视频中实际教授的主题!
但是这是如何工作的呢?首先,一些Youtube视频(并非所有,不幸的是)有与之关联的transcripts
(字幕)。
Youtube视频字幕可以通过URL访问:http://video.google.com/timedtext?lang=en&v=[VIDEO_CODE]
一旦视频打开,字幕通过get
方法获取,视频对象的transcript
属性被设置。
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;
}
(生成的字幕以XML数据形式接收。请注意使用x2js库,它提供XML到JSON的转换以及反向转换。)
随着视频播放,<class>
timeoutFunction
函数搜索当前时间所在的具体字幕行。如果找到一行,则解析口头表达的行并解释为搜索国家或城市名称。如果在字幕文本中找到一个或多个地点,则这些地点将在地图上高亮显示,并且将一直保持高亮显示,直到播放器到达下一行字幕。
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);
最终思考
就这样!我很高兴能参与一个不仅涉及技术,还涉及历史主题的项目。作为一个历史爱好者(对第一次世界大战了解不多),我觉得将我正在学习的主题(编程和历史)结合起来比仅仅为了技术而学习技术更令人兴奋。
我希望您喜欢这篇文章和附带的应用程序代码。如果您喜欢,请在下方发表评论,如果您觉得它可能对您的朋友和同事有用,请不要忘记在Facebook、Twitter、LinkedIn和其他社交媒体上分享。
历史
- 2016年7月7日:第一个版本
- 2016年7月8日:下载说明
- 2016年7月15日:在线演示
- 2016年8月4日:修复Linq查询
- 2016年8月5日:解释字幕行