在上一篇文章開創(chuàng)史無前例的在線學(xué)習(xí)方式——“集智知識(shí)星空”產(chǎn)品技術(shù)解剖(一)中我們講了產(chǎn)品新版本的特點(diǎn),簡單來說就是三點(diǎn):
- 使用二維展示方式,展示的信息更多維,更豐富。
- 使用層級(jí)化展示,每個(gè)層級(jí)有對應(yīng)的信息重點(diǎn),在展示更多信息的同時(shí),不產(chǎn)生視覺負(fù)擔(dān)。
- 高手可便捷地自行探索學(xué)習(xí)路徑,同時(shí)也為初學(xué)者提供了推薦的學(xué)習(xí)路徑。
那既然作為一個(gè)程序員,從本篇文章開始就要剖析產(chǎn)品中用到的技術(shù)了。整個(gè)產(chǎn)品前后端交互不多,核心在于后端算法生成數(shù)據(jù),和前端酷炫的交互實(shí)現(xiàn)兩部分。
算法過程還涉及到機(jī)密啊專利啊等等亂七八糟的事情,不能說的太詳細(xì),但前端部分本身就完全對外公開,所以也談不上技術(shù)保護(hù)。所以我們會(huì)著重對前端的實(shí)現(xiàn)部分進(jìn)行分享和分析。
還沒有體驗(yàn)過的同學(xué),可以前往集智學(xué)園官網(wǎng)體驗(yàn)后再繼續(xù)往下看。
模擬地圖功能
所有的課程以分布在二維坐標(biāo)系上的點(diǎn)的形式呈現(xiàn)。那就有對視圖在二維平面中上下左右移動(dòng)的需求。而且為了展示內(nèi)部細(xì)節(jié),還需要支持縮放。本質(zhì)上就是一個(gè)地圖。所以我們首先需要實(shí)現(xiàn)地圖的基本交互,移動(dòng) + 縮放
之所以不使用google或者百度地圖這類現(xiàn)有的地圖框架,一是因?yàn)槲覀兤鋵?shí)只需要地圖的部分交互,其實(shí)沒必要引入龐大的地圖庫;二是我們希望能更靈活地對這個(gè)"地圖"進(jìn)行自定義開發(fā),后續(xù)可能會(huì)在現(xiàn)有基礎(chǔ)上增加更多的交互或者元素。
另外地圖組件本質(zhì)是圖片的分片加載,所以難免在移動(dòng)和縮放的時(shí)候出現(xiàn)中間加載時(shí)刻。所以在經(jīng)過了一段時(shí)間的嘗試之后我們放棄了對地圖庫的引入。
1. 核心繪圖
整個(gè)視圖的組成主要元素是那些課程點(diǎn),這些點(diǎn)都是繪制在一個(gè)canvas上
核心繪圖函數(shù)很簡單
drawPoint (point) {
ctx.arc(point.x, point.y, point.r, 0, 2 * Math.PI);
}
點(diǎn)位的坐標(biāo)生成是另外的技術(shù)話題,大致流程是將課程信息(包括資料,文本,標(biāo)簽等)提取出來轉(zhuǎn)化為高維課程特征矩陣,再通過聚類和降維技術(shù)映射成二維坐標(biāo)。具體實(shí)現(xiàn)將另開篇幅。本文針對前端實(shí)現(xiàn)方式,不對此展開討論。
2. 引入監(jiān)聽事件
- 移動(dòng)功能用到了
-
mousedown, // 鼠標(biāo)移動(dòng) -
mousestart// 鼠標(biāo)點(diǎn)下 -
mouseup// 鼠標(biāo)抬起
- 縮放功能用到了
-
dblclick// 鼠標(biāo)雙擊 -
mousewheel// 鼠標(biāo)滾輪 -
DOMMouseScroll// firfox的鼠標(biāo)滾輪
設(shè)置事件函數(shù),將所有事件綁定在視圖的canvas上
//設(shè)置事件
setHandler(dom) {
//鼠標(biāo)雙擊
dom.addEventListener( 'dblclick',e => {
onDocumenDblClick(e, this, false);
}, { passive: true });
//鼠標(biāo)按下
dom.addEventListener('mousedown', e => {
moveDown(e, this, false);
}, { passive: true });
//鼠標(biāo)移動(dòng)
dom.addEventListener('mousemove', e => {
moveMouse(e, this, point);
});
//鼠標(biāo)抬起
dom.addEventListener( 'mouseup', e => {
moveUP(e, this);
}, { passive: true });
//鼠標(biāo)滾輪
dom.onmousewheel = e => { e.stopPropagation();
mouseScroll(e, this, false);
};
// 鼠標(biāo)滾輪事件firfox
dom.addEventListener('DOMMouseScroll', e => {
mouseScroll(e, this, false);
});
},
設(shè)置好事件后,就是地圖功能實(shí)現(xiàn)的核心了。移動(dòng) + 縮放
3. 拖拽移動(dòng)功能
移動(dòng)主要監(jiān)聽mousemove事件,這就需要對單純的“鼠標(biāo)移動(dòng)”,和按下后的“拖拽”做一個(gè)區(qū)分,所以需要mousedown和mouseup事件的配合,來判斷當(dāng)前是否為拖拽狀態(tài)。
let dragFlag = false; // 拖拽標(biāo)識(shí)
/*鼠標(biāo)點(diǎn)下事件 @param {*} e event */
moveDown (e) => {
dragFlag = true; // 鼠標(biāo)被按下,準(zhǔn)備拖拽
}
/*鼠標(biāo)抬起事件 @param {*} e event */
moveUP (e) => {
dragFlag = false; //結(jié)束拖拽標(biāo)識(shí)
},
/** 拖拽事件 @param {*} e event */
moveMouse (e) => {
if (dragFlag) {
...
transform(x, y); // x, y為地圖移動(dòng)的距離
}
},
至于拖拽的距離,則取決于上一時(shí)刻的位置,和當(dāng)前位置的差值。所以在移動(dòng)的過程中,需要去記錄上一時(shí)刻的位置。初始位置,為鼠標(biāo)按下的位置
let lastPointPos = [];
// 鼠標(biāo)按下
moveDown (e) => {
dragFlag = true; // 鼠標(biāo)被按下,準(zhǔn)備拖拽
lastPointPos = [e.clientX, e.clientY]
}
// 鼠標(biāo)拖拽
moveMouse (e) => {
if (dragFlag) {
let x = e.clientX - lastPoint[0];
let y = e.clientY - lastPoint[1];
lastPoint = [e.clientX, e.clientY];
transform(x, y);
}
}
這樣一來, transform函數(shù)就能專注實(shí)現(xiàn)移動(dòng)點(diǎn)位
// 移動(dòng)點(diǎn)位函數(shù)
transform (x, y) => {
this.x = this.x + x;
this.y = this.y + y
drawPoint();
})
}
到這里,拖拽移動(dòng)地圖的功能基本完成
接下去,我們來說一說稍微復(fù)雜的縮放操作。
4. 縮放功能
有很多操作會(huì)觸發(fā)縮放:
- 雙擊地圖
- 鼠標(biāo)滾動(dòng)
- 筆記本觸控板
雙擊觸發(fā)dbclick事件
鼠標(biāo)滾動(dòng)和觸控板的行為基本一致,都是觸發(fā)鼠標(biāo)滾輪mousewheel(firfox觸發(fā)的是DOMMouseScroll事件)
// 雙擊事件
onDocumenDblClick (e) => {
...
let flag = 'large';
scale(x, y, flag) // scale為縮放函數(shù),傳入縮放中心,和放大還是縮小標(biāo)志
}
// 滾動(dòng)事件
mouseScroll (e) => {
...
scale(x, y, flag) // scale為縮放函數(shù),傳入縮放中心,和放大還是縮小標(biāo)志
}
因?yàn)槊看坞p擊的縮放尺度,和每次滾輪的縮放尺度,顯然是不一樣的。所以兩個(gè)行為的縮放倍數(shù)??隙ú灰粯?。我們可以設(shè)置,每觸發(fā)一次雙擊事件,就相當(dāng)于觸發(fā)了n次的scale(n為一個(gè)自定義的參數(shù)), 即
onDocumenDblClick (e) => {
...
let flag = 'large';
let count = 0;
let time = setInterval(() => {
if (count <= n) {
scale(x, y, flag) // scale為縮放函數(shù),傳入縮放中心,和放大還是縮小標(biāo)志
} else {
clearInterval(time)
}
}, 100)
}
這么寫當(dāng)然可以實(shí)現(xiàn)功能,但是一點(diǎn)都不優(yōu)雅,而且使用setInterval做動(dòng)畫對瀏覽器來說并不是一個(gè)最佳的渲染方案,點(diǎn)位多的時(shí)候容易有失幀現(xiàn)象。這里鉆一下細(xì)節(jié),使用requestAnimationFrame改寫下。
let scaleStartTime = 0; // 開始放大的起始時(shí)間
// 雙擊事件
onDocumenDblClick (e) => {
...
let flag = 'large';
scaleStartTime = performance.now();
scaleOnceAnimation(e, time, flag); // time是自定義參數(shù),自行設(shè)置動(dòng)畫要運(yùn)行的時(shí)間。
}
// 循環(huán)動(dòng)畫
scaleOnceAnimation (e, time, flag) => {
// 使用當(dāng)前時(shí)間和起始時(shí)間做對比,每次循環(huán)都判斷是否已經(jīng)達(dá)到設(shè)置的動(dòng)畫運(yùn)行時(shí)間。
if (performance.now() - scaleStartTime > time) {
scaleStartTime = 0;
return;
}
scale(x, y, flag);
window.requestAnimationFrame(() => {
scaleOnceAnimation(e, time, flag);
});
}
最后就是scale函數(shù)的實(shí)現(xiàn)。在直接寫代碼之前,我們先來做個(gè)簡單的數(shù)學(xué)題。
以p(1, 1)為中心,把圓(2, 2, r = 1)放大為原來的兩倍,求圓放大后的坐標(biāo)和半徑

第一步,移動(dòng)整個(gè)坐標(biāo),直至p位于(0, 0)點(diǎn),此時(shí)圓坐標(biāo)為(1, 1, r = 1)

第二步,放大整個(gè)坐標(biāo)系至相應(yīng)倍數(shù),這里為2倍, 得到圓(2, 2, r = 2)

第三步,把坐標(biāo)系移回原來的位置,讓p回到初始點(diǎn),得到圓(3, 3, r = 2)

從這道題中可以看出,要把一個(gè)點(diǎn)以某一中心進(jìn)行縮放,還需要借助平移的方法,所以講了這么一堆,可以得出縮放函數(shù)應(yīng)該這么寫
// 縮放函數(shù)
scale (x, y, flag) => {
let scale = flag === 'large' ? 110 / 100 ? 100 / 110; // 縮放比例
transform(-x, -y);
this.x = this.x * scale;
this.y = this.y * scale;
transform(x, y);
this.drawPoint()
})
}
到此為止,縮放的功能就也已經(jīng)基本實(shí)現(xiàn)。一個(gè)模擬地圖行為的產(chǎn)品也已經(jīng)實(shí)現(xiàn)了最核心的功能。
在此基礎(chǔ)上,我們還可以模擬其他衍伸功能,比如:
-
viewPort (pointArray):把傳入的點(diǎn)放置于視圖中合適的位置; -
panTo (x, y):把視圖移動(dòng)到某個(gè)位置,并以傳入的坐標(biāo)為視圖中心(或任何一個(gè)你想要的位置點(diǎn)) -
openWindow (point):打開點(diǎn)位的信息窗口
除了模擬地圖API的基本功能以外,還能根據(jù)需求開發(fā)自己的地圖新功能 -
scaleToValue(point, value):對某個(gè)點(diǎn)移動(dòng)到視圖中心,并放大到指定大小 -
scaleToRange(range):縮放地圖,直到滿足傳入到視圖范圍內(nèi)
....
由于是完全canvas手?jǐn)]的地圖,所以完全可以根據(jù)需求開發(fā)想要的功能,雖然可能一開始如果選擇了地圖框架來實(shí)現(xiàn)功能,前期進(jìn)展肯定會(huì)比現(xiàn)在快,但到了后期開發(fā),我相信一定是我們自己的框架更加靈活,更有利于實(shí)現(xiàn)我們的想法,而不會(huì)被技術(shù)所局限。
本篇主要介紹了地圖的基礎(chǔ)操作移動(dòng)和縮放是如何實(shí)現(xiàn)的。
在下一篇,我們來介紹一下更加精彩的“窗口”和“路徑”實(shí)現(xiàn)。
敬請期待。
