每天坐地鐵,經常看地鐵圖,有一天突然想到,地鐵圖不也是一種拓撲結果嗎?TWaver到底能與地鐵圖擦出怎樣的火花呢?
想到就干,先到網上找幅參考圖。各種風格的地鐵圖還挺多,甚至有大學生自主設計制作,受到地鐵相關人士的認可和贊揚。不過看到他花了3周時間,我就比較同情他了,如果學會了TWaver,我保他連3天都不用就可以完成,而且還是純矢量、可交互、有動態效果、無失真縮放的拓撲圖。
我們就以上面這幅地鐵圖為模版來進行制作。
一、數據整理
俗話說兵馬未動糧草先行,沒有數據再好的創意也白搭。
數據格式,自然首選JavaScript原生支持的json文件,直觀方便。
1. 數據結構
數據結構是整理數據的重中之重,一個好的結構設計會讓后面的編程輕松方便。一種很容易想到的結構是以線路為基礎,每條線路依次為各個站點,但是這里面有許多站點存在多線路共用的情況,如何復用就很麻煩。另一種是以站點為基礎,再為每個站點添加線路屬性,但這樣線路的站點次序不夠清晰,在程序中很難對線路進行遍歷和循環操作。
那么比較好的辦法,就是將線路和站點分開,這樣將來無論是對站點還是對線路進行操作,都會比較方便。
{
"stations":{
"l01s01":{ },
…………
}
"lines":{
"l01":{……},
…………
}
"sundrys":{
"railwaystationshanghai":{……},
…………
}
}
其中第3部分“sundrys”,是需要在圖中標識的火車站、飛機場等相關元素。
當然,大家看到網上例子,有的會把label也單獨出來,這樣雖然可以靈活定義label 的位置,但卻使得站點和label兩張皮,而且也增加了數據采集的工作量。TWaver有對label豐富的自定義功能,所以完全沒有必要將label單拎出去,只需給其一個位置屬性就可以了。
2. 站點數據
每個站點,首先要有個屬性名。屬性名是由6位字符組成的,是由最先經過此站的線路名與站點在此線路上的序號組合而成。例如“l01s01”,表示1號線第1個站點。站點的“id”,與站點屬性名完全一致。站點的“label”屬性,是站點顯示名字相對站點的位置。
"l01s01":{
"id":"l01s01",
"name":"莘庄",
"loc":{"x":419,"y":1330},
"label":"bottomright.bottomright",
},
…………
3. 線路數據
線路屬性名是3位字符組成。首字符為線路類型:普通線路以“l”開頭,支線以“b”開頭,延伸線以“e”開頭,磁懸浮以“m”開頭。后兩位數字為線路的序號。線路的“id”,與線路屬性名完全一致。線路的“stations”屬性,包含了此線路上的所有站點,不過不要以為各站點的屬性名和屬性值都是一樣的,各站點的屬性名是嚴格按照線路中的順序命名的,但屬性值卻是站點的id。比如人民廣場站,其id為“l01s13”,但其在不同線路中的屬性名可能分別是“l01s13”、“l02s11”、“l08s16”。這樣既確保了對線路操作的方便性,又實現了對換乘站點的復用。
"l01":{
"id":"l01",
"name":"1號線",
"color":"#e52035",
"stations":{
"l01s01":"l01s01",
"l01s02":"l01s02",
……
}
},
……
4. 雜項數據
除了站點和線路以外的其他需要展示的元素都可以放到雜項數據中。雜項屬性名盡量完整表達此項目的名稱。雜項的“sign”屬性,是顯示圖標的注冊名稱。雜項的“station”屬性,是其臨靠的地鐵站id。雜項的“offset”屬性,是其顯示圖標相對地鐵站的方位。
"airporthongqiao":{
"sign":"airport",
"station":"l02s20",
"name":"虹橋國際機場",
"offset":{"x":0, "y":-1}
},
……
二、站點創建
地鐵線路就是一個拓撲網絡,那么站點也就是網絡的節點,創建站點也就是新建Node的過程。
1. 文件導入
所有的數據都存放在json文件中,首先要能夠讀取進來。
function loadJSON(path,callback){
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function(){
if (xhr.readyState === 4) {
if (xhr.status === 200) {
dataJson = JSON.parse(xhr.responseText);
callback && callback();
}
}
};
xhr.open("GET", path, true);
xhr.send();
}
因為讀取文件是一個異步的過程,所以要程序的展開都要放在文件讀取函數的內部。
function init(){
loadJSON("shanghaiMetro.json", function(){
initNetwork(dataJson);
initNode(dataJson);
});
}
2. 站點初創
開始我們先不管站點的類型,對所有站點進行一次遍歷,將站點的基本信息添加到站點Node中。有心人會發現這里沒有直接設定Node的位置,而只是將位置信息存到了“location”自定義屬性中,這是因為以后統一定位可以避免由於image大小不同等原因造成的位置偏移。
for(staId in json.stations){
var station = json.stations[staId];
staNode = new twaver.Node({
id: staId,
name: station.name,
image:'station',
});
staNode.s('label.color','rgba(99,99,99,1)');
staNode.s('label.font','12px 微軟雅黑');
staNode.s('label.position',station.label);
staNode.setClient('location',station.loc);
box.add(staNode);
}
3. 站點分類
站點主要有3種不同的類型:普通站點、換乘站點、支線共用站點。換乘站和共用站的區別,是換乘站在不同的線路中,一般並不是同一個空間,車跑的也完全不是同一個線路;而共用站卻完全是同一個地點,車也在同一條線路上跑,但不同時間跑的車可能是不同支線的車。不過對於始發或終到的共用站點,一般也都作為換乘站處理。
對於不同站點的判斷,無需在原始數據中指定,完全可以通過邏輯判斷來設定:只在某一條線路中出現的就是普通站點;僅在支線中重復出現的就是支線共用站點;在非支線的不同線路中重復出現的就是換乘站點。
最后,對不同的類型,用不同圖標顯示出來,讓用戶一目了然分辨站點。
4. 顯示站名
由於地鐵線路交錯復雜,站名的顯示位置就變得非常重要,如果不進行判斷和設置,很有可能會造成遮擋重疊,畫面會非常難看。這也是有些程序員甚至將其獨立於站點之外,作為單獨的網元重新統計創建的原因。在TWaver中可以很方便地定義label的顯示位置,甚至可以調整其顯示的角度和距離。當然可以通過程序,對站點周圍的空間進行判斷,智能調整顯示位置。但是由於有些地方過於密集和復雜,邏輯判斷的難度會非常大,不如直接在數據中手動添加位置信息來的方便。
5. 站點圖標
按照站點的分類,設計了三種不同的站點圖標,與參考地鐵圖相比,增加了支線共用站圖標。換乘站圖標沒有選擇參考地鐵圖的長方形,而是采用了更為靈活簡便的圓形圖標,省卻了方向和旋轉,方便了程序設計。
三、線路設計
地鐵線路由TWaver的Link實現,具有豐富的定制功能,完全可以滿足不同情況下線路顯示的需求。
1. 連接站點
對數據文件中的各條線路進行遍歷,再對每條線路中的各個站點進行遍歷,在站點間依次創建Link,基本的地鐵圖就呈現出來了。
for(lineId in json.lines) {
……
for(staSn in line.stations) {
……
var link = new twaver.Link(linkId,prevSta,staNode);
link.s('link.color', line.color);
link.s('link.width', linkWidth);
link.setToolTip(line.name);
box.add(link);
}
}

可能有的地鐵圖也就到此為止了,基本的示意功能已經具備了嘛。但追求完美的TWaver怎么可能忍受,起碼線路走向要規整一些,不能兩個站點間直線一連就完事了。
2. 連線分型
觀察參考地鐵圖,是對線路進行了美化,基本只保留了橫平豎直和正斜的走向。這就需要對一些站點間的連線,加上必要的拐點,使得連線始終按照橫、豎和正斜的方向來走。
參考地鐵圖中,拐點的添加有時比較隨意。在一段路徑上,有的只添加一個拐點,有的又會添加兩個拐點,規律性不是很強,用程序很難模仿。
為了方便程序實現,這里最多只在相鄰兩個站點間添加一個拐點,以達到使線路方向只有直或正斜的效果。這樣的話,所有的連線就只有無拐點和有拐點兩種了。其中,無拐點連線,包括橫向、縱向、正斜向(與x軸夾角45°或-45°)三種;有拐點連線又可分為先直后斜和先斜后直兩種。
3.智能拐點
首先我們要找到需要添加拐點的連線,這個很簡單,只需要把斜率不是1的斜線找出來就可以了。下一步才是關鍵,就是判斷拐點類型,是先直后斜還是先斜后直。
添加拐點的一個原則,就是拐點前后,要盡量保持平直;如果必須產生夾角,也要選夾角更大的,這樣整理后的路線,才比較美觀,不會有過多不合理的轉角。
var setTrunType = function(json){
box.forEach(function (ele) {
var id = ele.getId();
if(ele instanceof twaver.Link){
var link = ele;
var f = link.getFromNode().getCenterLocation();
var t = link.getToNode().getCenterLocation();
if(needAddPoint(f, t)){
var so=0, os=0;
if(link.getClient('prevLink')){
so += byPrevPoint(f,t,link).so;
os += byPrevPoint(f,t,link).os;
}
if(link.getClient('nextLink')){
os += byNextPoint(f,t,link).os;
so += byNextPoint(f,t,link).so;
}
p = os>so ? obliqueStraight(f, t) : straightOblique(f, t);
link.setClient('point', p);
link.setClient('truntype', os>so?'os':'so');
}
}
});
}

4. 人工拐點
上面考慮的智能拐點的添加,但其也有局限性,不夠靈活,碰到比較復雜的情況就招架不住了。比如磁懸浮線,只有始發和終到站,而且線路比較長,只添加一個拐點無法反映真實情況,這時就必須可以人工添加多個拐點了。
人工拐點需要在數據中添加拐點的位置信息,然后在連線上添加拐點。人工拐點可以用setLinkPathFunction方法,但在與智能拐點混用的情況下,智能拐點判斷就比較麻煩。還有一種思路,就是將人工拐點設成一個隱形的節點,實現起來就非常容易了。
var createTurnSta = function(line, staSn){
staTurn = new twaver.Node(staSn);
staTurn.setImage();
staTurn.setClient('lineColor',line.color);
staTurn.setClient('lines',[line.id]);
var loc = line.stations[staSn];
staTurn.setClient('location',loc);
box.add(staTurn);
return staTurn;
}

5.接點偏移
地鐵圖中,有些路段是兩條線路並行的。在某些線路交叉的地方,有時甚至會在局部出現多條線段並行的情況。如果不進行設計和處理,要么多條線會重合在一起,只能顯示出其中的一條;要么兩條線會隨意分合,線路在站點處出現不美觀的彎曲。
當然有多種思路來解決這個問題,本例中是采取了虛擬站點的辦法。就是在站點的旁邊,添加一個Follower(但並不顯示出來),讓並行的不同線路連接到不同的Follower上。通過調整Follower的位置,就可以完美顯示線路的並行效果了。
var createFollowSta = function(json, line, staNode, staId){
staFollow = new twaver.Follower(staId);
staFollow.setImage();
staFollow.setClient('lineColor',line.color);
staFollow.setClient('lines',[line.id]);
staFollow.setHost(staNode);
var az = azimuth[staId.substr(6,2)];
var loc0 = json.stations[staId.substr(0,6)].loc;
var loc = {x:loc0.x+az.x, y:loc0.y+az.y};
staFollow.setClient('location',loc);
box.add(staFollow);
return staFollow;
}

當然具體到每條線路在某個站點怎么偏移,很難用程序智能判斷和調整(希望有高手可以用簡潔的方式實現)。本例是手動修改線路數據,在站點的原id后添加了方位代碼。比如原為l01s11的站點,在某條線路中將其改為l01s11tt,就實現了該線路在站點頂部經過效果。具體方位代碼定義如下:
var azimuth = {
bb: {x: 0, y: linkWidth*zoom/2},
tt: {x: 0, y: -linkWidth*zoom/2},
rr: {x: linkWidth*zoom/2, y: 0},
ll: {x: -linkWidth/2, y: 0},
br: {x: linkWidth*zoom*0.7/2, y: linkWidth*zoom*0.7/2},
bl: {x: -linkWidth*zoom*0.7/2, y: linkWidth*zoom*0.7/2},
tr: {x: linkWidth*zoom*0.7/2, y: -linkWidth*zoom*0.7/2},
tl: {x: -linkWidth*zoom*0.7/2, y: -linkWidth*zoom*0.7/2},
BB: {x: 0, y: linkWidth*zoom},
TT: {x: 0, y: -linkWidth*zoom},
RR: {x: linkWidth*zoom, y: 0},
LL: {x: -linkWidth, y: 0},
BR: {x: linkWidth*zoom*0.7, y: linkWidth*zoom*0.7},
BL: {x: -linkWidth*zoom*0.7, y: linkWidth*zoom*0.7},
TR: {x: linkWidth*zoom*0.7, y: -linkWidth*zoom*0.7},
TL: {x: -linkWidth*zoom*0.7, y: -linkWidth*zoom*0.7}
};
四、動態顯示
TWaver做出的圖,可不是一張死圖,而是能呈現許多動態效果的生動的活的圖片。
1. 文本提示
動態鼠標提示,是TWaver的基本功能。每一個網元,不管是節點還是連線,只要設置了name屬性,鼠標移入后,默認都會以彈窗的方式將name顯示出來。當然,用戶也可以定制彈窗顯示的內容。比如,我們可以把某個站點首班和末班車的時間顯示出來,也可以把換乘信息等顯示出來,只需要一個setToolTip就可以了。
2. 站點顯示
當鼠標移入站點的時候,我們希望站點能有所變化,以給出動態提示。這是通過在注冊站點矢量圖形時,加入動態判斷實現的。以下代碼是普通站點的矢量圖形:
twaver.Util.registerImage('station',{
w: linkWidth*1.6,
h: linkWidth*1.6,
v: function (data, view) {
var result = [];
if(data.getClient('focus')){
result.push({
shape: 'circle',
r: linkWidth*0.7,
lineColor: data.getClient('lineColor'),
lineWidth: linkWidth*0.2,
fill: 'white',
});
result.push({
shape: 'circle',
r: linkWidth*0.2,
fill: data.getClient('lineColor'),
});
}else{
result.push({
shape: 'circle',
r: linkWidth*0.6,
lineColor: data.getClient('lineColor'),
lineWidth: linkWidth*0.2,
fill: 'white',
});
}
return result;
}
});

3. 站點動畫
在換乘站圖標中,還實現了旋轉的動態效果,這對於來說TWaver也很容易,只不過對rotae屬性進行了動態改變而已。
twaver.Util.registerImage('rotateArrow', {
w: 124,
h: 124,
v: [{
shape: 'vector',
name: 'doubleArrow',
rotate: 360,
animate: [{
attr: 'rotate',
to: 0,
dur: 2000,
reverse: false,
repeat: Number.POSITIVE_INFINITY
}]
}]
});
另外,本例還實現了站點selected和loading的動畫效果,方法都是大同小異的。
五、交互功能
交互功能是TWaver的精髓,如果只是為了圖畫的漂亮,那完全可以選擇其他作圖工具了。
1. 拖拽回彈
為了判斷是不是一張死圖,大家往往會下意識地去拖拽站點,看看能不能拖動。既然我們做的不是一張死圖,當然要讓站點能夠拖動。但如果站點會被隨便拖走,那么很快整個地鐵圖就會變得亂七八糟了,所以在松開鼠標后站點必須還能回到原來位置。
要說這個功能有什么用,我也只能呵呵了。但無聊的時候可以隨便玩上幾十分鍾我也是信的。
2. 混合縮放
既然是矢量圖,當然可以實現無失真縮放。TWaver還實現了綜合物理縮放和邏輯縮放優勢的混合縮放模式:在放大時使用邏輯縮放,更好展現站點邏輯關系;縮小時使用物理縮放,避免圖形失真。當然還有縮小后文字自動隱藏等貼心小功能,就不一一列舉了。
network.setZoomManager(new twaver.vector.MixedZoomManager(network));
network.setMinZoom(0.2);
network.setMaxZoom(3);
network.setZoomVisibilityThresholds({
label : 0.6,
});

3. 經過路線
連續單擊同一站點(注意不是雙擊),可以將經過此站點的所有線路突出顯示出來。
4. 路徑規划
連續單擊不同的兩個站點,則自動規划兩站之間的合理路徑。
5. 電子地圖
一張地鐵圖,即使做的再復雜,功能也是有限的,有時候調用其他軟件是擴展功能的一個好辦法,比如雙擊站點后顯示站點周圍的電子地圖。
network.addInteractionListener(function(e){
if(mapDiv){
mapDiv.style.display = 'none';
mapDiv = null;
dbclickSta = null;
}
if(e.kind == 'doubleClickElement' && e.element && e.element.getClassName() == 'twaver.Node' && e.element.getId().length == 6){
dbclickSta = e.element;
if(dbclickSta.getClient('coord')){
coord = dbclickSta.getClient('coord');
mapDiv = createMap(coord, e.event);
}else{
dbclickSta.setClient('dbclick', true);
var lineName = json.lines[dbclickSta.getId().substr(0,3)].name;
var stationName = dbclickSta.getName();
var addr = "上海市地鐵" + lineName + stationName;
var geocoder = new qq.maps.Geocoder();
geocoder.getLocation(addr);
geocoder.setComplete(function(result) {
coord = result.detail.location;
mapDiv = createMap(coord, e.event);
dbclickSta.setClient('dbclick', false);
});
geocoder.setError(function() {
var coord = {"lat":31.188,"lng":121.425};
mapDiv = createMap(coord, e.event);
});
}
}
});
在電子地圖中定位站點,可以通過在站點數據中加入站點的經緯度,也可以通過站點關鍵字在電子地圖中直接查詢。
當然,TWaver能實現不僅僅是例子中展示的這一點點,只有你想不到,沒有你做不到。你完全可以賦予地鐵圖更強大的功能,也可以舉一反三做出高鐵圖、交通圖等等類似實例。
(需要源碼的可私信索取)
