1. 程式人生 > >基於HTML5 Canvas繪製的支援手勢縮放的室內地圖

基於HTML5 Canvas繪製的支援手勢縮放的室內地圖

你是否有過這樣的經歷,在大型的商圈、商場中傻傻找不到路。嗯,室內地圖就這樣應運而生了。百度地圖、高德地圖等都提供了室內地圖的功能,高德地圖最近還把室內地圖的API開放了。室內地圖的導航、定位功能一定是未來幾年非常有前途的一件事。本文提供了一種基於HTML5 Canvas繪製室內地圖的方案,更重要的是可以支援手勢的縮放。先來看看室內地圖的效果gif動圖吧:
demo1.gif           demo2.gif
怎麼樣?是不是感覺非常炫酷?下面就來分析一下這個開源專案:

地圖繪製

關於地圖的繪製,這裡採用的是HTML5的Canvas繪製的。這是HTML5非常重要的一個特性,這使得我們繪製一些圖形變得非常的方便。該專案是將地圖資料專門放到一個mapinfo.js

檔案裡面的,使用一個數組進行儲存,如下:

var ALLMAPINFO = [[
{
    title: 'Toilet',
    x: 0,
    y: 0,
    width: 171,
    height: 283,
    color: "rgba(76, 181, 216, 0.2)",
    textcolor: "black",
    bordercolor: "rgba(76, 181, 216, 1)",
    imageurl: 'images/Toilet.png',
},
......

最後遍歷這些資料,用一個方法將地圖繪製出來。

function DrawBlock(x
, y, width, height, text, backgroudcolor, textcolor, bordercolor, imageurl) { var canvas = document.getElementById("indoormap"); var context2D = canvas.getContext("2d"); var textsize = width * height / (1000 * zoomScale * zoomScale); context2D.fillStyle = backgroudcolor; context2D.fillRect
(x, y, width, height); context2D.strokeStyle = bordercolor; context2D.lineWidth = 0.8; context2D.strokeRect(x, y, width, height); context2D.fillStyle = textcolor; if (textsize > 15) { context2D.font = 20 * zoomScale + "pt Microsoft YaHei"; if (imageurl != "") { var image = new Image(); image.src = imageurl; context2D.drawImage(image, x + width / 7, y + height / 2 - 25 * zoomScale, 30 * zoomScale, 30 * zoomScale); } } else { context2D.font = 0 + "pt Microsoft YaHei"; } context2D.fillText(text, x + width / 7 + 40 * zoomScale, y + height / 2); }

手勢縮放

這個室內地圖的關鍵是如何進行縮放,該功能的實現主要依賴canvas-zoom.js,先把程式碼貼在這裡:

/*
=================================
canvas-zoom - v0.1
http://github.com/licaomeng/canvas-zoom

(c) 2015 Caomeng LI
This code may be freely distributed under the Apache License
=================================
 */

(function () {
    var root = this; // global object
    var CanvasZoom = function (options) {
        if (!options || !options.canvas) {
            throw 'CanvasZoom constructor: missing arguments canvas';
        }

        this.canvas = options.canvas;
        this.canvas.width = this.canvas.clientWidth;
        this.canvas.height = this.canvas.clientHeight;
        this.context = this.canvas.getContext('2d');

        this.desktop = options.desktop || false; // non touch events

        this.scaleAdaption = 1;

        var indoormap = document.getElementById("indoormap");
        var pageWidth = parseInt(indoormap.getAttribute("width"));
        var pageHeight = parseInt(indoormap.getAttribute("height"));
        currentWidth = document.documentElement.clientWidth;
        currentHeight = document.documentElement.clientHeight;

        var offsetX = 0;
        var offsetY = 0;
        if (pageWidth < pageHeight) {//canvas.width < canvas.height
            this.scaleAdaption = currentHeight / pageHeight;
            if (pageWidth * this.scaleAdaption > currentWidth) {
                this.scaleAdaption = this.scaleAdaption * (currentWidth / (this.scaleAdaption * pageWidth));
            }
        } else {//canvas.width >= canvas.height
            this.scaleAdaption = currentWidth / pageWidth;
            if (pageHeight * this.scaleAdaption > currentHeight) {
                this.scaleAdaption = this.scaleAdaption * (currentHeight / (this.scaleAdaption * pageHeight));
            }
        }

        indoormap.setAttribute("width", pageWidth * this.scaleAdaption);
        indoormap.setAttribute("height", pageHeight * this.scaleAdaption);

        this.positionAdaption = {
            x: (parseInt(currentWidth) - parseInt(indoormap.getAttribute("width"))) / 2,
            y: (parseInt(currentHeight) - parseInt(indoormap.getAttribute("height"))) / 2
        };

        indoormap.setAttribute("width", currentWidth);
        indoormap.setAttribute("height", currentHeight);

        this.position = {
            x: 0,
            y: 0
        };

        this.scale = {
            x: 1,
            y: 1
        };

        this.focusPointer = {
            x: 0,
            y: 0
        }

        this.lastZoomScale = null;
        this.lastX = null;
        this.lastY = null;

        this.mdown = false; // desktop drag

        this.init = false;
        this.checkRequestAnimationFrame();
        requestAnimationFrame(this.animate.bind(this));

        this.setEventListeners();
    };

    CanvasZoom.prototype = {
        animate: function () {
            // set scale such as image cover all the canvas
            if (!this.init) {
                var scaleRatio = null;
                if (this.canvas.clientWidth > this.canvas.clientHeight) {
                    scaleRatio = this.scale.x;
                } else {
                    scaleRatio = this.scale.y;
                }
                this.scale.x = scaleRatio;
                this.scale.y = scaleRatio;
                this.init = true;
            }
            // 清空Canvas
            this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
            // 繪製地圖方法
            DrawMapInfo(this.scale.x * this.scaleAdaption, this.scale.y * this.scaleAdaption, this.position.x + this.positionAdaption.x, this.position.y + this.positionAdaption.y);
            // 每隔一段時間進行一次重新整理
            requestAnimationFrame(this.animate.bind(this));
        },

        gesturePinchZoom: function (event) {
            var zoom = false;
            if (event.targetTouches.length >= 2) {
                // 獲得螢幕上的第一個點
                var p1 = event.targetTouches[0];
                // 獲得螢幕上的第二個點
                var p2 = event.targetTouches[1];
                // 螢幕上兩個點的中點座標,也就是我們縮放時的縮放中點
                this.focusPointer.x = (p1.pageX + p2.pageX) / 2;
                this.focusPointer.y = (p1.pageY + p2.pageY) / 2;
                // zoomScale是此時刻螢幕上兩點之間的距離
                var zoomScale = Math.sqrt(Math.pow(p2.pageX - p1.pageX, 2) + Math.pow(p2.pageY - p1.pageY, 2));
                if (this.lastZoomScale) {
                    // zoom是此時刻與上一時刻螢幕上兩點之間的距離的差
                    zoom = zoomScale - this.lastZoomScale;
                }
                // 此時刻螢幕上兩點之間距離成為下一時刻螢幕上兩點之間距離
                this.lastZoomScale = zoomScale;
            }
            // 最後返回的這個zoom將成為縮放比例的依據,這裡是除以400得到的值+1作為縮放的倍數
            return zoom;
        },

        doZoom: function (zoom) {
            if (!zoom)
                return;
            // new scale
            var currentScale = this.scale.x;
            var newScale = this.scale.x + zoom / 400;

            if (newScale > 1) {
                if (newScale > 2.5) {
                    newScale = 2.5;
                } else {
                    newScale = this.scale.x + zoom / 400;
                }
            } else {
                newScale = 1;
            }
            this.scale.x = newScale;
            this.scale.y = newScale;

            var deltaScale = newScale - currentScale;
            var currentWidth = (this.canvas.width * this.scale.x);
            var currentHeight = (this.canvas.height * this.scale.y);
            var deltaWidth = this.canvas.width * deltaScale;
            var deltaHeight = this.canvas.height * deltaScale;
            var canvasmiddleX = this.focusPointer.x;
            var canvasmiddleY = this.focusPointer.y;
            var xonmap = (-this.position.x) + canvasmiddleX;
            var yonmap = (-this.position.y) + canvasmiddleY;
            var coefX = -xonmap / (currentWidth);
            var coefY = -yonmap / (currentHeight);
            var newPosX = this.position.x + deltaWidth * coefX;
            var newPosY = this.position.y + deltaHeight * coefY;
            // edges cases
            var newWidth = currentWidth + deltaWidth;
            var newHeight = currentHeight + deltaHeight;
            if (newWidth < this.canvas.clientWidth)
                return;
            if (newPosX > 0) {
                newPosX = 0;
            }
            if (newPosX + newWidth < this.canvas.clientWidth) {
                newPosX = this.canvas.clientWidth - newWidth;
            }

            if (newHeight < this.canvas.clientHeight)
                return;
            if (newPosY > 0) {
                newPosY = 0;
            }
            if (newPosY + newHeight < this.canvas.clientHeight) {
                newPosY = this.canvas.clientHeight - newHeight;
            }

            // finally affectations
            this.scale.x = newScale;
            this.scale.y = newScale;
            this.position.x = newPosX;
            this.position.y = newPosY;
        },

        doMove: function (relativeX, relativeY) {
            if (this.lastX && this.lastY) {
                var deltaX = relativeX - this.lastX;
                var deltaY = relativeY - this.lastY;

                var currentWidth = (this.canvas.clientWidth * this.scale.x);
                var currentHeight = (this.canvas.clientHeight * this.scale.y);

                this.position.x += deltaX;
                this.position.y += deltaY;

                // edge cases
                if (this.position.x > 0) {
                    this.position.x = 0;
                } else if (this.position.x + currentWidth < this.canvas.clientWidth) {
                    this.position.x = this.canvas.width - currentWidth;
                }
                if (this.position.y > 0) {
                    this.position.y = 0;
                } else if (this.position.y + currentHeight < this.canvas.clientHeight) {
                    this.position.y = this.canvas.height - currentHeight;
                }
            }
            this.lastX = relativeX;
            this.lastY = relativeY;
        },

        setEventListeners: function () {
            // touch
            this.canvas.addEventListener('touchstart', function (e) {
                this.lastX = null;
                this.lastY = null;
                this.lastZoomScale = null;
            }.bind(this));

            this.canvas.addEventListener('touchmove', function (e) {
                e.preventDefault();

                if (e.targetTouches.length == 2) { // pinch
                    this.doZoom(this.gesturePinchZoom(e));
                } else if (e.targetTouches.length == 1) {// move
                    var relativeX = e.targetTouches[0].pageX - this.canvas.getBoundingClientRect().left;
                    var relativeY = e.targetTouches[0].pageY - this.canvas.getBoundingClientRect().top;

                    this.doMove(relativeX, relativeY);
                }
            }.bind(this));

            if (this.desktop) {
                // keyboard+mouse
                window.addEventListener('keyup', function (e) {
                    if (e.keyCode == 187 || e.keyCode == 61) { // +
                        this.doZoom(15);
                    } else if (e.keyCode == 54) {// -
                        this.doZoom(-15);
                    }
                }.bind(this));

                window.addEventListener('mousedown', function (e) {
                    this.mdown = true;
                    this.lastX = null;
                    this.lastY = null;
                }.bind(this));

                window.addEventListener('mouseup', function (e) {
                    this.mdown = false;
                }.bind(this));

                window.addEventListener('mousemove', function (e) {
                    var relativeX = e.pageX - this.canvas.getBoundingClientRect().left;
                    var relativeY = e.pageY - this.canvas.getBoundingClientRect().top;

                    if (e.target == this.canvas && this.mdown) {
                        this.doMove(relativeX, relativeY);
                    }

                    if (relativeX <= 0 || relativeX >= this.canvas.clientWidth || relativeY <= 0 || relativeY >= this.canvas.clientHeight) {
                        this.mdown = false;
                    }
                }.bind(this));
            }
        },

        checkRequestAnimationFrame: function () {
            var lastTime = 0;
            var vendors = ['ms', 'moz', 'webkit', 'o'];
            for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
                window.requestAnimationFrame = window[vendors[x] + 'RequestAnimationFrame'];
                window.cancelAnimationFrame = window[vendors[x] + 'CancelAnimationFrame']
                        || window[vendors[x] + 'CancelRequestAnimationFrame'];
            }

            if (!window.requestAnimationFrame) {
                window.requestAnimationFrame = function (callback, element) {
                    var currTime = new Date().getTime();
                    var timeToCall = Math.max(0, 16 - (currTime - lastTime));
                    var id = window.setTimeout(function () {
                        callback(currTime + timeToCall);
                    }, timeToCall);
                    lastTime = currTime + timeToCall;
                    return id;
                };
            }

            if (!window.cancelAnimationFrame) {
                window.cancelAnimationFrame = function (id) {
                    clearTimeout(id);
                };
            }
        }
    };

    root.CanvasZoom = CanvasZoom;
}).call(this);

其中,最核心的兩個方法主要是animategesturePinchZoom,它們分別負責地圖的重新整理重繪以及縮放的實現。

跨平臺移植

程式碼的大致情況就是這樣,如果想要把我們這些前端的HTML5, js移植到移動端就需要藉助於Cordova(前身是PhoneGap),在提供的github專案中,給出了Android版的,iOS版的可能還需要一段時間,不過都是藉助於Cordova進行打包而已。

螢幕自適應

移動端應用自適應不同手機螢幕尺寸、解析度,這是一個既老生常談又常論常新的話題。關於螢幕自適應,專案結合了兩種做法

第一階段:

之前的做法是利用HTML5 的viewport進行螢幕自適應的:

function setViewport() {
    if ((navigator.userAgent.toLowerCase().indexOf("android") != -1) || (navigator.userAgent.toLowerCase().indexOf("iphone") != -1)) {
        var scale = curwidth / pagewidth;
        var vPort = "width=" + pagewidth + ", maximum-scale=" + scale + ", minimum-scale=" + scale + ", initial-scale=" + scale + ", user-scalable=yes";
        document.getElementById("viewport_map").setAttribute("content", vPort);
    }
}

這樣做能夠很容易達到我們的目的,但是問題還是很明顯的,經過這種方法的自適應處理,相當於在我們的瀏覽器中直接將頁面放大縮小,這樣Canvas就會失真變得模糊不清。

第二階段:

之後改良的做法,有一部分和之前的做法是相似的,雖然沒有直接粗暴的往viewport裡面新增值。但是同樣都要獲取(當前裝置的寬度(高度)畫素/頁面本身的寬度(高度)畫素),這個就是我們縮放頁面的倍數,這個倍數可能大於1,也可能小於1。那麼在頁面初始載入的時候所有元素都要乘上這個倍數,這樣我們在不同的裝置尺寸、解析度上看到的樣式就是統一的。

控制元件特效

頁面上面三個控制元件都是使用CSS3繪製出來的,按下還有disabled狀態都是用CSS實現的。包括上面的圖示也都是通過CSS3 的@font-face實現的(可以參考我的上一篇博文:利用CSS3 @font-face使用圖示字型)。關於控制元件特效的整體實現,請參考github上的專案原始碼。