8.4 跨越空间和时间
之前的那些例子让我们能够对多种数据类型(既有定性的也有定量的)进行可视化。我们可以根据要讲的故事选择合适的颜色、类型和标记符号,为地图添加注释突出显示具体的地区或热点,还可以放大到以国家或县为单位。不过,这还不是全部,我们还能更进一步。如果再引入另一维度的数据,我们就能看到涵盖时间和空间的变化。
在第4章我们用抽象的线条和图表对时间进行了可视化,这很有用。但如果在数据中附加了位置信息,就能通过地图更为直观地了解到模式和变化,而且观察那些物理距离相近的地区分类和群组也容易得多。
最棒的是,在涵盖时间和空间的数据可视化中,我们可以用上已经学到的理论和技巧。
8.4.1 系列组图
我们在第6章中见识过这一技巧。当时可视化的是跨越多个分类的数据之间的关系,但其实它同样也适用于空间数据,如图8-24所示。与之前的小型柱状图表不同,这次用的是小尺寸的地图,每一张地图代表一个时间片段。将这些地图从左往右或从上往下排列,我们的视线自然就会随之移动,了解到发生了哪些变化。
图8-24 地图系列组图
比如说,在2009年底我设计了一幅显示美国失业率的图表(见图8-25)。实际上我用的就是上一节中大家看到的代码(稍有变化),但我将其应用到了多个时间片段上。
图8-25 2004—2009年的美国失业率
很容易看到2004—2006年的变化,如图8-26所示。全美的平均失业率在这一阶段呈现出下降趋势。
图8-26 2004—2006年的美国失业率
然后到了2008年(见图8-27),我们开始看到失业率有了抬头的趋势,尤其是在加利福尼亚州、俄勒冈州、密歇根州,以及美国东南部的一些县。
图8-27 2008年的失业率
而到了2009年,情况大不相同了,如图8-28所示。全美平均值增长了4个百分点,而县的颜色变得非常深。
图8-28 2009年9月的失业率
这是我发布在FlowingData上的图表中最受欢迎的图表之一,因为人们能很清楚地看到这种在数年相对停滞后突发的戏剧性转变。我同时也用到了OpenZoom Viewer,它能让读者放大到高分辨率图片,以便着重关注自己所在地区的失业率是如何变化的。
►当高分辨率图片过大、无法在一屏中完全显示时,将图片放到OpenZoom Viewer(http://open-zoom.org)里面去会很有帮助,读者既能看到缩小后的全图,又能随时放大观察细节。
我也可以将这些数据可视化为一幅时间序列图表,每一条线代表一个县。但是美国有3000多个县,图表会非常密集,而且如果它不可交互,没人能说出哪条线代表哪个县。
8.4.2 抓住差额
并不是只能创建系列地图才能表现出变化。有时候在一幅地图中只对变化进行可视化反而会更有意义。这种做法能节省空间,而且它突出的是变化而非单个的时间片段,如图8-29所示。
图8-29 关注变化本身
这次我们从世界银行下载城市居民的人口数据,与之前获取安全水资源一例中的数据比较相似。每一行代表一个国家,每一列代表一年。然而,城市人口数据是指一个国家居住在城市地区的人数的粗略计数,基于这种计数创建的等值区域图,必然会突出显示那些较大的国家,因为这些国家的总人口数更多。所以要想用两幅地图来表现2005年和2009年的城市人口差距,就必须把绝对数值改为比例,否则根本没有意义。而要做到这一点,我们必须下载2005年和2009年所有国家的人口数据,然后做一些运算。这个步骤并不太难,但总归需要额外的工作。此外,如果变化并不明显,在多幅地图中也是很难被发现的。
与其这样,我们不如找出其中的差额部分,并将其显示在一张地图上。你可以在Excel里很容易地计算出来,或者修改之前的Python脚本,然后绘制出单独的地图,如图8-30所示。
图8-30 2005-2009年间城市人口的变化(另见彩插图8-30)
在对差额进行可视化之后,很容易看出来哪些国家的变化较大,哪些国家的变化较小。与之相比,图8-31显示了2005年各个国家居住在城市的人口比例。
图8-31 2005年居住在城市地区的人口百分比
而图8-32显示了2009年的这一数据。它看上去和图8-31很相似,我们很难察觉出其中的不同。
图8-32 2009年居住在城市地区的人口百分比
对于这个例子来说,很明显单幅地图反而更能传达信息。我们必须让读者在理解城市人口的变化时尽量少花脑筋。很容易看出,尽管与世界其他国家相比,非洲许多国家居住在城市的人口比例较低,但最近几年中他们在这方面的增长是最快的。
别忘了再加上图例、数据来源和标题,以面向更广泛的读者群体,如图8-33所示。
图8-33 带有说明的表现差额的地图
8.4.3 动画
更明显的一种可视化空间和时间变化的方式是让数据“动”起来。不再是通过单个地图显示各个时间片段,我们可以在单个可交互地图上显示这些变化,活生生地展示它们发生的过程。这种做法保持了地图的直观性,同时又允许读者按自己的方法来探索数据。
几年前,我设计了一张地图,展示了沃尔玛在美国的发展史,如图8-34所示。动画始自于1962年阿肯色州罗杰斯市开办的第一家沃尔玛店铺,然后一直前进到2010年。每一家新店开张,地图上就会多出一个点。最初沃尔玛的增长比较缓慢,之后却开始像病毒一样在全美蔓延。它不断发展、成长,在各地进行大规模的收购后爆发。不知不觉中,沃尔玛已经无处不在了。
图8-34 表现沃尔玛增长的动画地图(另见彩插图8-34)
►访问http://datafl.ws/197查看完整的沃尔玛店铺分布地图。
在当时,我做这张地图只是为了学习Flash和ActionScript而已,但它被四处转载,浏览量达到了数百万次。后来我又创建了一幅相似的地图,显示了塔吉特百货公司(Target)的发展历程(见图8-35),同样得到了广泛的传播。
图8-35 表现塔吉特百货增长的动画地图(另见彩插图8-35)
►访问http://datafl.ws/198观看塔吉特百货的发展历程。
人们对此如此感兴趣,有两个主要的原因。首先是因为动画地图能让他们看到在时间序列图中无法看到的变化模式。常规的图表只能显示每年新开张的店铺数量(如果你的故事只涉及这一层面,当然没问题),而动画地图则能更加“有机”地展现出增长的状态,对于沃尔玛这种级别的对象来说尤其如此。
第二个原因是这幅地图对普通大众来说非常容易理解。动画一开始我们就能明白自己在看什么。我并不是说那些需要花时间才能理解的可视化作品没有价值,实际情况往往是相反的。然而,网络读者对时间的忍耐力是有限的,而这幅地图的直观性(以及可以针对自己感兴趣的地点进行放大)自然会有助于人们分享的欲望。
创建动态增长地图
在本例中,我们用ActionScript来创建沃尔玛的增长地图。我们会用到ActionScript的地图库Modest Maps来生成可交互的基础地图。而其他代码则需要我们自己完成。大家可以在http://book.flowingdata.com/ch08/Openings_src.zip上下载完整的源代码。本节中我们不会详细讲解每一行代码和文件,而只会介绍最重要的部分。
►访问http://modestmaps.com下载Modest Maps。
在第5章中,当我们用ActionScript和Flare可视化工具包创建堆叠面积图时,我曾强烈建议用Adobe的Flex Builder。它能让ActionScript的编写更为容易,同时让代码更有条理。当然,你也可以在一个标准文本编辑器里面写代码,但Flex Builder可以一站式解决你的编辑、调试和编译工作。本例假设你已经安装了Flex Builder,不过你要在Adobe网站上弄个ActionScript 3编译器下来自然也是没问题的。
说明 最近Adobe已经将Flex Builder改名为Flash Builder。两者间有一些小的变化,但不影响我们使用。
►请下载完整的增长地图代码,以跟上本例的讲解。下载地址是http://book.flowingdata.com/ch08/Openings_src.zip。
首先打开Flex Builder 3,在左侧显示当前项目列表的边栏内单击右键,然后在弹出菜单中选择Import(导入),如图8-36所示。
图8-36 导入ActionScript项目
选择Existing Projects into Workspace(现有项目到工作空间中),如图8-37所示。
图8-37 现有项目
然后,如图8-38所示,单击Browse(浏览)按钮找到我们存放刚才下载代码的目录。在选择其根目录后,Openings项目会出现在项目窗口中。
图8-38 导入Openings项目
我们在Flex Builder中的工作区界面应该类似于图8-39所示。
图8-39 导入项目后的工作区
所有的代码都在src文件夹中。这其中包含了com文件夹中的Modest Maps和gs文件夹中的TweenFilterLite,它们有助于实现过渡效果。
在导入Openings项目后,我们就可以开始构建地图了。这一过程分两部分,第一部分是创建可交互的基础地图,第二部分是添加标记符号。
● 添加可交互的基础地图
在Openings.as中,前面几行代码导入了所需要的工具包。
- import com.modestmaps.Map;
- import com.modestmaps.TweenMap;
- import com.modestmaps.core.MapExtent;
- import com.modestmaps.geo.Location;
- import com.modestmaps.mapproviders.OpenStreetMapProvider;
- import flash.display.Sprite;
- import flash.display.StageAlign;
- import flash.display.StageScaleMode;
- import flash.events.Event;
- import flash.events.MouseEvent;
- import f1ash.filters.ColorMatrixFilter;
- import flash.geom.ColorTransform;
- import f1ash.text.TextField;
- import flash.net.*;
第一段代码从Modest Maps工具包中导入了几个类,而第二段导入的是Flash提供的显示对象和事件类。每个类的名称是什么现在并不重要,在我们使用它们时自然会清楚。不过,第一段中的命名模式和其目录结构是相符的,以com开始,然后是modestmaps,最后以Map结尾。在你自己写ActionScript代码时,绝大多数时候也会这样来导入类。
在public class Openings extends Sprite之上,有关编译后的Flash文件的几个变量(宽度、高度、背景颜色和帧速率)都进行了初始化。
- [SWF(width="900", height="450", backgroundColor="#ffffff", frameRate="32")]
然后,在类声明的后面,我们需要指定一些变量,并初始化一个地图对象。
- private var stageWidth:Number = 900;
- private var stageHeight:Number = 450;
- private var map:Map;
- private var mapWidth:Number = stageWidth;
- private var mapHeight:Number = stageHeight;
在Openings( )函数的括号里面,我们可以利用Modest Maps来创建自己的第一个可交互地图。
- stage.scaleMode = StageScaleMode.No_SCALE;
- stage.align = StageAlign.TOP_LEFT;
- //Initialize map
- map = new TweenMap(mapWidth, mapHeight, true, new OpenStreetMapProvider( ));
- map.setExtent(new MapExtent(50.259381, 24.324408, -128.320313, -59.941406));
- addChild(map);
和在Illustrator里面一样,我们可以把整个交互图表看作多个图层的累加。在ActionScript和Flash里面,第一层是舞台(stage,或者说画布)。我们将其设置为当放大地图时不要缩放对象,同时以舞台的左上方为标准来排放对象。之后我们用已经指定好的变量mapWidth和mapHeight对地图进行初始化,打开交互功能,并使用了来自OpenStreetMap的地图贴图。再之后我们设定了地图范围(MapExtent),将地图的框架限定在了美国。
MapExtent( )函数中的坐标就是经纬度,它们可以控制边框以显示世界地图的哪个区域。第一个和第三个数字分别是左上角的经度和纬度,第二个和第四个数字分别是右下角的经度和纬度。
最后,通过addChild( )函数将地图添加到舞台上。如果不对地图添加任何滤镜效果,编译代码后的地图就如图8-40所示。我们可以点Flex Builder左上角的Play(播放)按钮,也可以在主菜单里选择Run(运行)→Run Openings(运行Openings)。
图8-40 使用了OpenStreetMap贴图的空白地图
运行Openings之后,结果会在系统默认的Web浏览器里打开。现在地图上还什么都没有,但我们可以随意拖动它,感觉还是很酷的。另外,如果你不太喜欢这种风格的地图贴图,也可以试试微软的道路地图(Microsoft road map,参见图8-41)或者雅虎的混合地图(Yahoo! hybrid map,参见图8-42)。
图8-41 使用了微软道路地图的空白地图
图8-42 使用了雅虎混合地图的空白地图
►如果你愿意,也可以用你自己的贴图。在Modest Maps网站上有很棒的教程。
我们也可以应用滤镜来尝试修改地图的颜色。比如说,我们可以在刚才已写的代码下面添加以下地图,从而将地图改为灰度显示。其中的mat数组的长度为20,取值范围为0~1。每个数值代表各个像素点获得了多少红色、绿色、蓝色和透明度。
- var mat:Array = [0.24688,0.48752,0.0656,0,44.7,0.24688,0.48752,
- 0.0656,0,44.7,0.24688,0.48752,0.0656,0,44.7,0,0,0,1,0];
- var colorMat:ColorMatrixFilter = new ColorMatrixFilter(mat);
- map.grid.filters = [colorMat];
如图8-43所示,地图完全变灰了,这会为突出显示我们即将叠加在地图上的数据带来方便。地图的作用更像是背景,而不会喧宾夺主,转移读者的注意力。
图8-43 在应用滤镜之后得到的灰度地图
我们也可以通过颜色转换(color transform)来反转颜色。
- map.grid.transform.colorTransform =
- new ColorTransform(-1,-1,-1,1,255,255,255,0);
这样会把白色变成黑色,黑色变成白色,如图8-44所示。
图8-44 通过颜色转换后黑白颠倒的地图
要想创建放大和缩小按钮,首先要写一个生成按钮的函数。你可能会认为应该会有一种默认的快速方法能实现这一点,但其实还是需要不少代码的。关于makeButton( )的函数定义位于Openings类的底部。
- public function makeButton(clip:Sprite, name:String, labelText:String,
- action:Function):Sprite
- {
- var button:Sprite = new Sprite( );
- button.name = name;
- clip.addChild(button);
- var label:TextField = new TextField( );
- label.name = 'label';
- label.selectable = false;
- label.textColor = 0xffffff;
- label.text = labelText;
- label.width = label.textWidth + 4;
- label.height = label.textHeight + 3;
- button.addChild(label);
- button.graphics.moveTo(0, 0);
- button.graphics.beginFin(0xFDBB30, 1);
- button.graphics.drawRect(0, 0, label.width, label.height);
- button.graphics.endFill( );
- button.addEventListener(MouseEvent.CLICK, action);
- button.useHandCursor = true;
- button.mouseChildren = false;
- button.buttonMode = true;
- return button;
- }
然后创建另一个函数来使用这个函数,并且绘制出我们想要的按钮。以下代码利用makeButton( )函数创建了两个按钮,一个是放大,一个是缩小,并将它们安置在地图的左下角。
- // 绘制导航按钮
- private function drawNavigation( ):void
- {
- // 导航按钮(放大和缩小)
- var buttons:Array = new Array( );
- navButtons = new Sprite( );
- addChild(navButtons);
- buttons.push(makeButton(navButtons, 'plus', '+', map.zoomIn));
- buttons.push(makeButton(navButtons, 'minus', '-', map.zoomOut));
- var nextX:Number = 0;
- for(var i:Number = 0; i < buttons.length; i++) {
- var currButton:Sprite = buttons[i];
- Sprite(buttons[i]).scaleX = 3;
- Sprite(buttons[i]).scaleY = 3;
- Sprite(buttons[i]).x = nextX;
- nextX += 3*Sprite(buttons[i]).getChildByName('label').width;
- }
- navButtons.x = 2; navButtons.y = map.height-navButtons.height-2;
- }
不过,由于这是一个函数,如果我们不调用它,代码是不会执行的。在Openings( )函数,也就是所谓的构造函数中,在滤镜代码的下面添加drawNavigation( )。现在我们可以放大感兴趣的位置了,如图8-45所示。
图8-45 能够放大缩小的地图
基础地图已经全部搞定了。我们选择了贴图,设置了变量,还让交互变得可行。
● 添加标记符号
下面的步骤是载入沃尔玛的店铺地址数据,并为每一家新开张的店铺创建标记符号。在构造函数里,以下代码从某个URL载入了一个XML文件。当文件完成下载后,就会调用一个名为onLoadLocations( )的函数。
- var urlRequest:URLRequest =
- new URLRequest('http://projects.flowingdata.com/walmart/walmarts_
- new.xml');
- urlLoader = new URLLoader( );
- urlLoader.addEventListener(Event.COMPLETE, onLoadLocations);
- urlLoader.load(urlRequest);
很明显,下一步是创建这个onLoadLocations( )函数。它会读取XML文件并将数据存储到一个数组中,方便后面使用。不过在此之前,我们需要在navButtons后面先对其他一些变量进行初始化。
- private var urlLoader:URLLoader;
- private var locations:Array = new Array( );
- private var openingDates:Array = new Array( );
这些变量会用在onLoadLocations( )里面。经度和纬度被存储在locations里面,而开张日期根据其具体年份被存储在openingDates里面。
- private function onLoadLocations(e:Event):void {
- var xml:XML = new XML(e.target.data);
- for each(var w:* in xml.walmart) {
- locations.push(new Location(w.latitude, w.longitude));
- openingDates.push(String(w.opening_date));
- }
- markers = new MarkersClip(map, locations, openingDates);
- map.addChild(markers);
- }
下一步是创建MarkersClip类。在之前讨论的那个目录结构中,在com目录下有一个名为flowingdata的目录,而在flowingdata目录下有一个gps目录。最终,在com → flowingdata → gps目录下是MarkersClip类。这就是即将存放所有沃尔玛标记符号(或者说在地图之上的数据图层)的容器。
和之前一样,我们需要导入将要用到的类。通常我们可以在需要的时候再在代码中添加它们,但为了简单起见,我们也可以一次性添加所有会用到的类。
- import com.modestmaps.Map;
- import com.modestmaps.events.MapEvent;
- import flash.display.Sprite;
- import flash.events.TimerEvent;
- import flash.geom.Point;
- import f1ash.utils.Timer;
前两个来自于Modest Maps,而后四个是Flash自有的类。然后我们在MarkersClip( )函数之前设置好变量。同样地,我们可以在需要时才添加它们,但也可以现在就全部添加,从而直接前进到类里面的函数。
- protected var map:Map; // 底图
- public var markers:Array; // 存放标记符号的容器
- public var isStationary:Boolean;
- public var locations:Array;
- private var openingDates:Array;
- private var storesPerYear:Array = new Array( );
- private var spyIndex:Number = 0; //每年开张的店铺数索引
- private var currentYearCount:Number = 0; // 目前已显示的店铺数
- private var currentRate:Number; // 待显示的店铺数
- private var totalTime:Number = 90000; // 约1.5分钟
- private var timePerYear:Number;
- public var currentYear:Number = 1962; // 从初始年起
- private var xpoints:Array = new Array( ); // 转换过的经度
- private var ypoints:Array = new Array( ); // 转换过的纬度
- public var markerIndex:Number = 0;
- private var starting:Point;
- private var pause:Boolean = false;
- public var scaleZoom:Boolean = false;
在MarkersClip( )构造函数中,我们存储好即将被传递到类的变量,并计算出诸如每年的时间和店铺的坐标值这些东西。你可以把这一步看作是设置。
变量storesPerYear存储的是在给定年份中有多少家新店开张。比如说,第一年有一家店铺开张,而第二年没有店铺开张。如果你要将这段代码用于自己的数据,就需要适当地更新storesPerYear这个变量。你也可以写一个函数来计算出每年开张的店铺数量或地址,以便提高代码的可重用性。为了简单起见,本例中直接将数组写死了。
- this.map = map;
- this.x = map.getWidth( ) / 2;
- this.y = map.getHeight( ) / 2;
- this.locations = locations;
- setPoints( );
- setMarkers( );
- this.openingDates = openingDates;
- var tempIndex:int = 0;
- storesPerYear = [1,0,1,1,0,2,5,5,5,15,17,19,25,19,27,
- 39,34,43,54,150,63,87,99,110,121,142,125,131,178,
- 163,138,156,107,129,53,60,66,80,105,106,114,96,
- 130,118,37];
- timePerYear = totalTime / storesPerYear.length;
在MarkersClip类中还有其他两个函数:setPoints( )和setMarkers( )。前一个将经度和纬度坐标转换成x轴和y轴坐标,而后一个则将标记符号放置在地图上(但并未显示)。以下是对setPoints( )的定义。它使用了Modest Maps提供的一个内置函数来计算x值和y值,然后将新的坐标值存储在xpoints和ypoints中。
- public function setPoints( ):void {
- if (locations == null) {
- return;
- }
- var p:Point;
- for (var i:int = 0; i < locations.length; i++) {
- p = map.locationPoint(locations[i], this);
- xpoints[i] = p.x;
- ypoints[i] = p.y;
- }
- }
第二个函数setMarkers( )使用了setPoints( )存储的点坐标,然后将相应的标记符号放置在地图上。
- protected function setMarkers( ):void
- {
- markers = new Array( );
- for (var i:int = 0; i < locations.length; i++)
- {
- var marker:Marker = new Marker( );
- addChild(marker);
- marker.x = xpoints[i]; marker.y = ypoints[i];
- markers.push (marker);
- }
- }
该函数还用到了一个定制的类Marker,如果下载了完整的源代码,我们就可以在com → flowingdata → gps → Marker.as中找到它。它基本上是一个容器,当你调用它的play( )函数时,它就会被“点亮”。
现在我们有了位置数据,而且标记符号也载入到了地图上面。然而,如果现在编译代码并运行文件,我们依然只能看到一幅空白的地图。下一步是循环处理这些标记符号,让它们在正确的时间点亮。
playNextStore( )函数调用了下一个标记符号的play( ),然后为再下一个作好准备。startAnimation( )和onNextYear( )函数使用了计时器来递增显示每一个店铺。
- private function piayNextStore(e:TimerEvent):void
- {
- Marker(markers[markerIndex]).play( );
- markerIndex++;
- }
现在再编译并运行动画,我们能够看到那些亮点,但它们并不配合地图一块进行缩放和平移,如图8-46所示。当我们左右拖动或者放大缩小地图时,那些绿色的气泡依然保持静止不动。
图8-46 不能缩放和平移亮点的增长地图
现在往构造函数里添加一些监听器,以便在地图移动时那些亮点也会跟着移动。无论何时,只要一个MapEvent被Modest Maps触发,一个在MarkersClip.as里定义的相应函数就会被调用。比如下面的第一行代码,只要用户单击地图的缩放按钮,onMapStartZooming( )就会被调用。
- this.map.addEventListener(MapEvent.START_ZOOMING,
- onMapStartZooming);
- this.map.addEventListener(MapEvent.STOP_ZOOMING,
- onMapStopZooming);
- this.map.addEventListener(MapEvent.ZOOMED_BY, onMapZoomedBy);
- this.map.addEventListener(MapEvent.START_PANNING,
- onMapStartPanning);
- this.map.addEventListener(MapEvent.STOP_PANNING,
- onMapStopPanning);
- this.map.addEventListener(MapEvent.PANNED, onMapPanned);
这样我们就得到了最终的地图,如图8-47所示。
图8-47 显示沃尔玛增长情况的完整可交互地图
沃尔玛开店的故事显示了一种有机的增长。公司从一个地方开始,缓慢地向外扩张。很明显,并不是所有连锁店都是如此。比如,塔吉特的发展看上去就没有这么有计划性。好市多的发展不像前两者那样充满戏剧性,因为它扩张的区域较少,但其发展战略似乎是先在沿海地区站稳脚跟,然后向内地进军。
不管怎样,这是一种非常有趣的浏览数据方式。不停增长的地图似乎能激发人们的想象力,而且他们会想知道其他连锁公司(例如麦当劳或星巴克)的扩张情况。现在我们已经有了代码,实现这一点应该会容易得多。困难的部分只在于如何找到数据了。