基於HTML5 Canvas繪製的支援手勢縮放的室內地圖
你是否有過這樣的經歷,在大型的商圈、商場中傻傻找不到路。嗯,室內地圖就這樣應運而生了。百度地圖、高德地圖等都提供了室內地圖的功能,高德地圖最近還把室內地圖的API開放了。室內地圖的導航、定位功能一定是未來幾年非常有前途的一件事。本文提供了一種基於HTML5 Canvas繪製室內地圖的方案,更重要的是可以支援手勢的縮放。先來看看室內地圖的效果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);
其中,最核心的兩個方法主要是animate
和gesturePinchZoom
,它們分別負責地圖的重新整理重繪以及縮放的實現。
跨平臺移植
程式碼的大致情況就是這樣,如果想要把我們這些前端的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上的專案原始碼。