基於 Canvas 的 HTML5 互動式地鐵線路圖

圖撲軟體發表於2019-03-08

前言

前兩天在 echarts 上尋找靈感的時候,看到了很多有關地圖類似的例子,地圖定位等等,但是好像就是沒有地鐵線路圖,就自己花了一些時間搗鼓出來了這個互動式地鐵線路圖的 Demo,地鐵線路上的點是在網上隨便下載了一個,這篇文章記錄自己的一些收穫(畢竟我還是個菜鳥)以及程式碼的實現,希望能夠幫到一些朋友。當然,如果有什麼意見的可以直接跟我說,大家一起交流才會進步。

http://www.hightopo.com/demo/subway/index.html ←戳我看 Demo

Demo 效果圖:
圖片描述
地圖稍微內容有點多,要全部展示,字顯得有點小了,但是沒關係,可以按照需求放大縮小,字型和繪製的內容並不會失真,畢竟都是用向量繪製的~

介面生成

底層的 div 是通過 ht.graph.GraphView 元件生成的,然後就可以利用 HT for Web 提供的方法,呼叫 canvas 畫筆隨便繪製就好,先來看看怎麼生成底層 div:

var dm = new ht.DataModel(); // 資料容器
var gv = new ht.graph.GraphView(dm); // 拓撲元件
gv.addToDOM(); // 將拓撲圖元件新增進body中

addToDOM 函式宣告如下:

addToDOM = function(){   
    var self = this,
        view = self.getView(),   
        style = view.style;
    document.body.appendChild(view); // 將元件底層div新增到body中           
    style.left = '0'; // 預設元件是絕對定位,所以要設定位置
    style.right = '0';
    style.top = '0';
    style.bottom = '0';      
    window.addEventListener('resize', function () { self.iv(); }, false); // 視窗變化事件           
}

現在我就可以在這個 div 上亂塗亂畫了~首先我獲取下載好的地鐵線路圖上的點,我將它們放在 subway.js 中,這個 js 檔案全部都是下載的內容,我沒有做其他的改動,主要是將這些點根據線路來分分配新增到陣列中,比如:

mark_Point13 = []; // 線路 陣列內包含線路的起點和終點座標以及這條線路的名稱
t_Point13 = []; // 換成站點 陣列內包含線路中的換乘站點座標以及換成站點名稱
n_Point13 = []; // 小站點 陣列內包含線路中的小站點座標以及小站點名稱
mark_Point13.push({ name: '十三號線', value: [113.4973,23.1095]}); 
mark_Point13.push({ name: '十三號線', value: [113.4155,23.1080]}); 
t_Point13.push({ name: '魚珠', value: [113.41548,23.10547]}); 
n_Point13.push({ name: '裕豐圍', value: [113.41548,23.10004]}); 

接下來來描繪地鐵線路,我宣告瞭一個陣列 lineNum,用來裝 js 中所有的地鐵線路的編號,以及一個 color 陣列,用來裝所有的地鐵線的顏色,這些顏色的 index 與 lineNum 中地鐵線編號的 index 是一一對應的:

var lineNum = ['1', '2', '3', '30', '4', '5', '6', '7', '8', '9', '13', '14', '32', '18', '21', '22', '60', '68'];
var color = ['#f1cd44', '#0060a1', '#ed9b4f', '#ed9b4f', '#007e3a', '#cb0447', '#7a1a57', '#18472c', '#008193', '#83c39e', '#8a8c29', '#82352b', '#82352b', '#09a1e0', '#8a8c29', '#82352b', '#b6d300', '#09a1e0'];

接著遍歷 lineNum,將 lineNum 中的元素和顏色傳到 createLine 函式中,根據這兩個引數來繪製地鐵線路以及配色,畢竟 js 檔案中的命名方式也是有規律的,哪一條線路,則命名後面一定會加上對應的數字,所以我們只需要將字串與這個編號結合即可獲得 js 中對應的陣列了:

let lineName = 'Line' + num;
let line = window[lineName];

createLine 的定義也非常簡單,我的程式碼設定了不少的樣式,所以看起來有點多。建立一個 ht.Polyline 管線,我們可以通過 polyline.addPoint() 函式向這個變數中新增具體的點,通過 setSegments 可以設定點的連線方式。

function createLine(num, color) { // 繪製地圖線
    var polyline = new ht.Polyline(); // 多邊形 管線
    polyline.setTag(num); // 設定節點tag標籤,作為唯一標示
    
    if(num === '68') polyline.setToolTip('A P M'); // 設定提示資訊 
    else if(num === '60') polyline.setToolTip('G F'); 
    else polyline.setToolTip('Line' + num);

    if(color) {
        polyline.s({ // s 為 setStyle 的簡寫,設定樣式
            'shape.border.width': 0.4, // 設定多邊形的邊框寬度
            'shape.border.color': color, // 設定多邊形的邊框顏色
            'select.width': 0.2, // 設定選中節點的邊框寬度
            'select.color': color // 設定選中節點的邊框顏色
        });
    }

    let lineName = 'Line' + num;
    let line = window[lineName];
    for(let i = 0; i < line.length; i++) {
        for(let j = 0; j < line[i].coords.length; j++) {
            polyline.addPoint({x: line[i].coords[j][0]*300, y: -line[i].coords[j][1]*300});
            if(num === '68'){ // APM 線(有兩條,但是點是在同一個陣列中的)
                if(i === 0 && j === 0) {
                    polyline.setSegments([1]);
                }
                else if(i === 1 && j === 0) {
                    polyline.getSegments().push(1);
                }
                else {
                    polyline.getSegments().push(2);
                }
            }    
        }
    }

    polyline.setLayer('0'); // 將線設定在下層,點設定在上層 “top”
    dm.add(polyline); // 將管線新增進資料容器中儲存,不然這個管線屬於“遊離”狀態,是不會顯示在拓撲圖上的
    return polyline;
}

上面程式碼中新增地鐵線上的點有分為幾種情況,是因為 js 中設定線的時候 Line68 有一個“跳躍”點的現象,所以我們必須“跳躍”過去,篇幅有限 Line68 陣列具體的宣告自行看 subway.js。

這裡說明一點,如果用的是 addPoint 函式,不設定 segments 時,預設將新增進的點用直線連線,segments 的定義如下:

  • moveTo,佔用 1 個點資訊,代表一個新路徑的起點
  • lineTo,佔用 1 個點資訊,代表從上次最後點連線到該點
  • quadraticCurveTo,佔用 2 個點資訊,第一個點作為曲線控制點,第二個點作為曲線結束點
  • bezierCurveTo,佔用 3 個點資訊,第一和第二個點作為曲線控制點,第三個點作為曲線結束點
  • closePath,不佔用點資訊,代表本次路徑繪製結束,並閉合到路徑的起始點

所以我們要做“跳躍”的行為設定 segments 為 1 即可。

最後繪製這些地鐵線上的點,這個部分 subway.js 中也分離出來了,命名以“mark_Point”、“t_Point”以及“n_Point”開頭,我在前面 js 的展示部分有對這些陣列進行解釋,大家動動中指劃上去看看。

我們在這些點的位置新增 ht.Node 節點,當節點一新增進 dm 資料容器中時,就會在拓撲圖上顯示,當然,前提是這個拓撲圖元件 gv 設定的資料容器是這個 dm。篇幅有限,新增地鐵線上的點的程式碼部分我只展示新增“換乘站點”的點:

var tName = 't_Point' + num;
var tP = window[tName]; // 大站點
if(tP) { // 有些線路沒有“換乘站點”
    for(let i = 0; i < tP.length; i++) {
        let node = createNode(tP[i].name, tP[i].value, color[index]); // 在獲取的線路上的點的座標位置新增節點
        node.s({ // 設定節點的樣式 style
            'label.scale': 0.05, // 文字縮放,可以避免瀏覽器限制的最小字號問題
            'label.font': 'bold 12px arial, sans-serif' // 設定文字的 font
        });
        node.setSize(0.6, 0.6); // 設定節點大小。由於 js 中每個點之間的偏移量太小,所以我不得不把節點設定小一些
        node.setImage('images/旋轉箭頭.json'); // 設定節點的圖片
        node.a('alarmColor1', 'rgb(150, 150, 150)'); // attr 屬性,可以在這裡面設定任何的東西,alarmColor1 是在上面設定的 image 的 json 中繫結的屬性,具體參看 HT for Web 向量手冊(http://www.hightopo.com/guide/guide/core/vector/ht-vector-guide.html#ref_binding)
        node.a('alarmColor2', 'rgb(150, 150, 150)'); // 同上
        node.a('tpNode', true); // 這個屬性設定只是為了用來區分“換乘站點”和“小站點”的,後面會用上
    }
}

所有的地鐵線路以及站點都新增完畢。但是,你可能會看不見自己繪製的圖,因為他們太小了,這個時候可以設定 graphView 拓撲元件上的 fitContent 函式,我們順便將拓撲圖上的所有東西不可移動也設定一下:

gv.fitContent(false, 0.00001); // 自適應大小,引數 1 為是否動畫,引數 2 為 gv 與邊框的 padding 值
gv.setMovableFunc(function(){
    return false; // 設定 gv 上的節點不可移動
});

這下你的地鐵線路圖就可以顯示啦~接下來看看互動。

互動

首先是滑鼠移動事件,滑鼠滑過具體線路時,線路會變粗,懸停一會兒還能看到這條線路的編號;當滑鼠移動到“換乘站點”或“小站點”,站點對應的圖示都會變大並且變色,字型也會變大,滑鼠移開圖示變回原來的顏色並且字型變小。不同點在於滑鼠移動到“換乘站點”時,“換乘站點”會旋轉。

圖片描述

滑鼠滑動事件,我直接基於 gv 的底層 div 進行的 mousemove 事件,通過 ht 封裝的 getDataAt 函式傳入事件 event 引數,獲取事件下對應的節點,然後就可以隨意操作節點了:

gv.getView().addEventListener('mousemove', function(e) {
    var data = gv.getDataAt(e); // 傳入邏輯座標點或者互動 event 事件引數,返回當前點下的圖元
    if(name) {
        originNode(name); // 不管什麼時候都要讓節點保持原來的大小
    }

    if (data instanceof ht.Polyline) { // 判斷事件節點的型別
        dm.sm().ss(data); // 選中“管道”
        name = '';
        clearInterval(interval);
    }
    else if (data instanceof ht.Node) {
        if(data.getTag() !== name && data.a('tpNode')) { // 若不是同一個節點,並且 mousemove 的事件物件為 ht.Node 型別,那麼設定節點的旋轉
            interval = setInterval(function() {
                data.setRotation(data.getRotation() - Math.PI/16); // 在自身旋轉的基礎上再旋轉
            }, 100);
        }
        if(data.a('npNode')) { // 如果滑鼠移到“小站點”也要停止動畫
            clearInterval(interval);
        }
        expandNode(data, name); // 自定義的放大節點函式,比較容易,我不粘程式碼了,可以去http://hightopo.com/   檢視
        dm.sm().ss(data); // 設定選中節點
        name = data.getTag(); // 作為“上一個節點”的儲存變數,可以通過這個值來獲取節點
    }
    else { // 其他任何情況則不選中任何內容並且清除“換乘站點”上的動畫
        dm.sm().ss(null);
        name = '';
        clearInterval(interval);
    }
});

滑鼠懸停在地鐵線路上時顯示“具體線路資訊”,我是通過設定 tooltip 來完成的(注意:要開啟 gv 的 tooltip 開關):

gv.enableToolTip(); // 開啟 tooltip 的開關
if(num === '68') polyline.setToolTip('A P M'); // 設定提示資訊 
else if(num === '60') polyline.setToolTip('G F'); 
else polyline.setToolTip('Line' + num);

然後我利用右下角的 form 表單,單擊表單上的具體線路,或者雙擊拓撲圖上任意一個“站點”或者線路,則拓撲圖會自適應到對應的部分,將被雙擊的部分展現到拓撲圖的中央。

圖片描述

form 表單的宣告部分我好像還沒有解釋。。。就是通過 new 一個 ht.widget.FomePane 類建立一個 form 表單元件,通過 form.getView() 獲取表單元件的底層 div,將這個 div 擺放在 body 右下角,然後通過 addRow 函式向 form 表單中新增一行的表單項,可以在這行中新增任意多個項,通過 addRow 函式的第二個引數(一個陣列),對新增進的表單項進行寬度的設定,通過第三個引數設定這行的高度:

function createForm() { // 建立右下角的form表單
    var form = new ht.widget.FormPane();
    form.setWidth(200); // 設定表單寬度
    form.setHeight(416); // 設定表單高度
    let view = form.getView();
    document.body.appendChild(view); // 將表單新增進 body 中
    view.style.zIndex = 1000;
    view.style.bottom = '10px'; // ht元件幾乎都設定絕對路徑
    view.style.right = '10px';
    view.style.background = 'rgba(211, 211, 211, 0.8)';

    names.forEach(function(nameString) {
        form.addRow([ // 向表單中新增行
            { // 這一行中的第一個表單項
                button: { // 向表單中新增 button 按鈕
                    icon: 'images/Line'+nameString.value+'.json', // 設定按鈕的圖示
                    background: '', // 設定按鈕的背景
                    borderColor: '', // 設定按鈕的邊框顏色
                    clickable: false // 設定按鈕不可點選
                }
            },
            { // 第二個表單項
                button: {
                    label: nameString.name,
                    labelFont: 'bold 14px arial, sans-serif',
                    labelColor: '#fff',
                    background: '',
                    borderColor: '',
                    onClicked: function() { // 按鈕點選回撥事件
                        gv.sm().ss(dm.getDataByTag(nameString.value)); // 設定選中按下的按鈕對應的線路
                        gv.fitData(gv.sm().ld(), true, 5); // 將選中的地鐵線路顯示在拓撲圖的中央
                    }
                }
            }
        ], [0.1, 0.2], 23); // 第二個引數是設定第一引數中的陣列的寬度,小於 1 是比例,大於 1 是實際寬度。第三個引數是該行的高度
    });
}

單擊“站點”顯示紅色標註,雙擊節點自適應放置到拓撲圖中央以及雙擊空白處將紅色標註隱藏的內容都是通過對拓撲元件 gv 的事件監聽來控制的,非常清晰易懂,程式碼如下:

var node = createRedLight(); // 建立一個新的節點,顯示為“紅燈”的樣式
gv.mi(function(e) { // ht 中拓撲元件中的事件監聽
    if(e.kind === 'clickData' && (e.data.a('tpNode') || e.data.a('npNode'))) { // e.kind 獲取當前事件型別,e.data 獲取當前事件下的節點
        node.s('2d.visible', true); // 設定 node 節點可見
        node.setPosition(e.data.getPosition().x, e.data.getPosition().y); // 設定 node 的座標為當前事件下節點的位置
    }
    else if(e.kind === 'doubleClickData') { // 雙擊節點
        gv.fitData(e.data, false, 10); // 將事件下的節點自適應到拓撲圖的中央,引數 1 為自適應的節點,引數 2 為是否動畫,引數 3 為 gv 與邊框的 padding
    }
    else if(e.kind === 'doubleClickBackground') { // 雙擊空白處
        node.s('2d.visible', false); // 設定 node 節點不可見 檢視 HT for Web 樣式手冊(http://www.hightopo.com/guide/guide/core/theme/ht-theme-guide.html#ref_style)
    }
});

注意 s(style) 和 a(attr) 定義是這樣的,s 是 ht 預定義的一些樣式屬性,而 a 是我們使用者來自定義的屬性,一般是通過呼叫字串來呼叫結果的,這個字串對應的可以是常量也可以是函式,還是很靈活的。

最後還做了一個小小的部分,選中“站點”,則該“站點”的上方會顯示一個紅色的會“呼吸”的用來註明當前選中的“站點”。

圖片描述

“呼吸”的部分是利用 ht 的 setAnimation 函式來完成的,在用這個函式之前要先開啟資料容器的動畫開關,然後設定動畫:

dm.enableAnimation(); // 開啟資料容器的動畫開關
function createRedLight() {
    var node = new ht.Node();
    node.setImage('images/紅燈.json'); // 設定節點的圖片
    node.setSize(1, 1); // 設定節點的大小
    node.setLayer('firstTop'); // 設定節點顯示在gv的最上層
    node.s('2d.visible', false); // 節點不可見
    node.s('select.width', 0); // 節點選中時的邊框為0,不可見
    node.s('2d.selectable', false); // 設定這個屬性,則節點不可選中

    node.setAnimation({ // 設定動畫 具體參見 HT for Web 動畫手冊(http://www.hightopo.com/guide/guide/plugin/animation/ht-animation-guide.html)
        expandWidth: {
            property: "width", // 設定這個屬性,並且未設定 accessType,則預設通過 setWidth/getWidth 來設定和獲取屬性。這裡的 width 和下面的 height 都是通過前面設定的 size 得到的
            from: 0.5, // 動畫開始時的屬性值
            to: 1, // 動畫結束時的屬性值
            next: "collapseWidth" // 字串型別,指定當前動畫完成之後,要執行的下個動畫,可將多個動畫融合
        },
        collapseWidth: {
            property: "width",
            from: 1, 
            to: 0.5,
            next: "expandWidth"
        },
        expandHeight: {
            property: "height",
            from: 0.5, 
            to: 1,
            next: "collapseHeight"
        },
        collapseHeight: {
            property: "height",
            from: 1, 
            to: 0.5,
            next: "expandHeight"
        },
        start: ["expandWidth", "expandHeight"] // 陣列,用於指定要啟動的一個或多個動畫
    });
    dm.add(node);
    return node;
}

全部程式碼結束!

總結

這個 Demo 花了我兩天時間完成,總覺得有點不甘心啊,但是有時候思維又轉不過彎來,花費了不少的時間,但是總的來說收穫還是很多的,我以前一直以為只要通過 getPoints().push 來向多邊形中新增點就可以了,求助了大神之後,發現原來這個方法不僅繞彎路而且還會出現各種各樣的問題,比如 getPoints 之前,一定要在多邊形中已經有 points 才可以,但是在很多情況下,初始化的 points 並不好設定,而且會造成程式碼很繁瑣,直接通過 addPoint 方法,直接將點新增進多邊形變數中,並且還會預設將點通過直線的方式連線,也不用設定 segments,多可愛的一個函式。

還有就是因為 ht 預設縮放大小是 20,而我這個 Demo 的間距又很小,導致縮放到最大地鐵線路圖顯示也很小,所以我在 htconfig 中更改了 ht 的預設 zoomMax 屬性,記住,更改這個值一定要在所有的 ht 呼叫之前,因為在 htconfig 中設定的值在後面定義都是不可更改的。

總而言之,這兩天我的腦細胞死了不少,也重新生長了不少,人都是在不斷進步的嘛~

相關文章