65.9K
CodeProject 正在变化。 阅读更多。
Home

欧洲大战:1914-1918

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (42投票s)

2016年7月7日

CPOL

14分钟阅读

viewsIcon

52307

downloadIcon

413

一个交互式地图,使用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ëlVelocity JSSVG JSWalkwaySnap SVGBonsai JSLazy Line PainterVivusProgressbar JSTwo JS。它们都功能齐全,各有优点,但在这其中,我选择了Bonsai JS,因为它具有以下特点:

  • 架构上分离的运行器和渲染器
  • iFrame、Worker和Node运行上下文
  • 路径
  • 资源(音频、视频、图像、字体、短片)
  • 关键帧和基于时间的动画(也包括缓动函数)
  • 路径变形

Bonsai JS入门

Bonsai JS只需要三样东西

  1. Bonsai脚本
    <script src="http://cdnjs.cloudflare.com/ajax/libs/bonsai/0.4/bonsai.min.js"></script>
  2. movie div元素
    <div id="movie"></div>
    
  3. <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.jsmodel.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专有的消息传递建立双向通信。

Web Worker事件模型 - 图片来自:Tom Pascall的Web workers and responsiveness

我们必须首先创建专门的处理器来监听来自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的结构是为了给三个主要实体提供分离的功能:CountryBigCitySmallCityBigCitySmallCity都通过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 ...'},
    .
    .
    .
],
.
.
.

地图上的国家根据冲突期间存在的三个政治集团进行划分:EntenteCentral PowersNeutral 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必须是truefalse。默认为true
  • controlIconsEnabled必须是truefalse。默认为false
  • zoomEnabled必须是truefalse。默认为true
  • dblClickZoomEnabled必须是truefalse。默认为true
  • mouseWheelZoomEnabled必须是truefalse。默认为true
  • preventMouseEventsDefault必须是truefalse。默认为true
  • zoomScaleSensitivity必须是一个标量。默认为0.2
  • minZoom必须是一个标量。默认为0.5
  • maxZoom必须是一个标量。默认为10
  • fit必须是truefalse。默认为true
  • contain必须是truefalse。默认为false
  • center必须是truefalse。默认为true
  • refreshRate必须是数字或auto。
  • beforeZoom必须是一个在缩放更改之前调用的回调函数。
  • onZoom必须是在缩放更改时调用的回调函数。
  • beforePan必须是一个在平移更改之前调用的回调函数。
  • onPan必须是在平移更改时调用的回调函数。
  • customEventsHandler必须是一个包含initdestroy作为函数的对象。
  • eventsListenerElement必须是一个SVGElementnull

我们如下设置我们的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,该指令随后会处理国家链接上的mouseentermouseleaveclick事件。

    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日:解释字幕行
© . All rights reserved.