:動態(tài)熱點增刪改查與坐標(biāo)轉(zhuǎn)換全解析)
上一篇文章聊完Vue工程接入krpano的基礎(chǔ)整合這篇是實戰(zhàn)系列的第二篇專門講全景項目里最核心的交互——熱點。熱點就是全景畫面里那些可以點擊的小圖標(biāo)、標(biāo)簽或者按鈕它是全景看房、園區(qū)導(dǎo)覽、巡檢系統(tǒng)里所有業(yè)務(wù)入口的載體。這篇文章會從一個可運行的角度出發(fā)把在Vue組件里動態(tài)添加、更新、刪除熱點的完整鏈路拆開講清楚包括krpano的熱點屬性模型、事件綁定的坑、坐標(biāo)換算問題以及打包上線后的路徑陷阱。適合正在用Vue做全景項目、或者準(zhǔn)備把手頭寫死的XML熱點改為接口動態(tài)渲染的同學(xué)參考。1. 為什么繞不開Vue和krpano整合后的數(shù)據(jù)流界線1.1 兩種嵌入方案的取舍krpano和Vue整合本質(zhì)上就兩條路。一條是iframe把krpano產(chǎn)物當(dāng)作一個黑盒頁面嵌進來另一條是不用iframe直接在Vue頁面里引入embedpano.js用原生API操作全景。我先把兩者的對比放在這里因為很多項目到最后都是從iframe踩坑之后切到原生嵌入的。對比項iframe嵌入原生嵌入embedpano.js接入成本低一個src就完事中等需要處理初始化生命周期Vue和全景通信必須postMessage鏈路長直接調(diào)用krpano接口同步返回?zé)狳c點擊回傳消息序列化、易丟幀直接通過js()回調(diào)到window函數(shù)組件切換控制iframe重建成本高可精確銷毀實例調(diào)試體驗跨域/同域都要切context打斷點、看變量都順手我的結(jié)論很直接只要你的熱點數(shù)量多、交互復(fù)雜或者熱點數(shù)據(jù)需要跟著接口變就別用iframe。iframe那種方式只適合全景就是個展示頁的輕場景一旦涉及熱點點擊聯(lián)動Vue彈窗、列表和熱點雙向高亮這種需求用iframe做消息樞紐會非常痛苦而且主頁面和全景之間的時序問題很難定位。1.2 自己封裝Service而不依賴npm包的原因GitHub和npm上其實有一些現(xiàn)成的Vue組件封裝比如vue-krpano之類的我早期也確實用過。但后來項目里krpano版本升級到1.21之后這些組件的維護大多停了對新的接口和事件體系支持不完整出了問題還得自己去讀源碼找版本差異。更關(guān)鍵的是krpano自身API并不復(fù)雜封裝的成本遠低于踩別人封裝出來的坑。所以我最后在項目里維護了一個獨立的krpanoService單例把初始化、加載完成回調(diào)隊列、熱點增刪改查、事件注冊全部收口到這一個模塊里。好處是Vue組件里不需要關(guān)心krpano實例存不存在、是否readyService內(nèi)部統(tǒng)一等待組件只管調(diào)方法。2. 工程準(zhǔn)備krpano文件在Vue項目里的布局與加載時機2.1 目錄擺放與打包路徑krpano輸出到web端的產(chǎn)物一般是一個一個的文件夾里面有tour.js或者swf老版本、xml配置文件、皮膚素材和各種瓦片切片圖片。千萬不要把這些文件直接拷進src/assets里因為webpack會重新改寫資源路徑、文件名加hashkrpano內(nèi)部的相對路徑加載機制會被徹底打亂。我的做法是直接放在public/vtour目錄下整個krpano產(chǎn)物原封不動地作為一個靜態(tài)子目錄交付。引入embedpano.js的方式有兩種。我推薦在vue.config.js里把embedpano聲明成external然后在index.html里用普通script標(biāo)簽引入。這樣Vue打包時不會再去處理這個文件避免webpack對全局腳本做任何干擾。如果你懶省事用import的方式引入很可能會遇到krpano內(nèi)部依賴window、document在模塊化作用下偶發(fā)未定義的問題。2.2 組件內(nèi)初始化與onready時序krpano的embedpano初始化是一個異步過程雖然函數(shù)調(diào)用是同步發(fā)出但viewer實例真正準(zhǔn)備好是在它內(nèi)部的onready回調(diào)之后。這里要特別指出初始化代碼最好放在組件的mounted里并且包一層nextTick確保承載全景的容器div已經(jīng)渲染到DOM上。// krpanoService.js 核心片段 export const krpanoService { viewer: null, readyCallbacks: [], init(containerId, config {}) { return new Promise((resolve) { const defaultConfig { swf: /vtour/tour.js, target: containerId, html5: only, passQueryParameters: true, consolelog: false, ...config } embedpano(defaultConfig, (viewer) { this.viewer viewer this.readyCallbacks.forEach(fn fn(viewer)) this.readyCallbacks [] resolve(viewer) }) }) }, onReady(callback) { if (this.viewer) { callback(this.viewer) } else { this.readyCallbacks.push(callback) } } }這里有一個很多人初學(xué)時忽略的點embedpano里面那個swf參數(shù)在現(xiàn)代版本實際傳的是HTML5版的tour.js文件路徑不是真正的flash文件。很多教材還停留在十年前讓人去找swf現(xiàn)在只要你在krpano的打包工具里選擇HTML5輸出生成物就是一個js文件和一套xml配置。如果不懂這一點光看報錯信息會繞很大的彎路。2.3 路由切換與組件銷毀Vue項目里最常見的隱患是路由切換之后全景實例沒有被銷毀。krpano實例本質(zhì)上是一個持續(xù)運行的渲染引擎它掛在DOM上之后只要沒有主動銷毀即使路由跳到別處它依然在后臺渲染、監(jiān)聽事件。切回來看似正常但如果你又初始化了一個新實例兩個實例同時存在輕則事件重復(fù)觸發(fā)重則頁面卡死。我習(xí)慣在組件的beforeUnmountVue3或beforeDestroyVue2里做兩件事beforeUnmount() { if (this.viewer) { try { this.viewer.call(removemenu()) this.viewer.destroy() } catch (e) { // 忽略銷毀時的異常 } } }如果全景所在容器是唯一的我還會手動把容器innerHTML清空確保資源完全釋放。3. 認識熱點XML定義、坐標(biāo)體系和事件模型3.1 一個熱點到底由哪些屬性決定在krpano里熱點在XML層面的定義長這樣hotspot namespot_gate ath35.2 atv8.4 urlicons/gate.png scale1.0 zorder2 onclickjs(onGateClick(gate)); /這一坨屬性里最關(guān)鍵的是name、ath、atv、url、onclick剩下的屬性按實際需求補。你不需要把XML全背下來但一定要理解這些字段的含義因為在JS里動態(tài)添加熱點時你操作的就是這些字段。屬性作用取值范圍/說明name熱點唯一標(biāo)識重復(fù)設(shè)置會覆蓋或報錯ath水平角/經(jīng)度-180到1800表示視野正前方atv垂直角/緯度-90到900表示水平線url圖標(biāo)素材路徑相對krpano根目錄或絕對路徑scale縮放0.5為半倍大小可動態(tài)修改zorder層級數(shù)值越大越靠近視角visible顯隱false隱藏不會觸發(fā)點擊onclick點擊動作支持krpano動作或js()調(diào)用3.2 為什么不能完全靠寫死XML如果你手上的項目只有幾個固定點位寫死在XML里沒問題。但我的場景是熱點的來源是后端接口不同樓棟、不同視角下熱點集合完全不一樣而且還需要根據(jù)設(shè)備狀態(tài)刷新顏色這時候?qū)懰繶ML等于自斷活路。還有一個很容易被忽視的問題Vue數(shù)據(jù)和krpano熱點狀態(tài)需要雙向同步。比如你左側(cè)有熱點列表用戶點了列表項右側(cè)熱點要閃爍反過來用戶點了全景里的熱點列表要滾動到對應(yīng)項。這種聯(lián)動如果靠來回生成XML再reload體驗非常粗糙而直接用krpano的運行時API改屬性是流暢的。krpano提供了很樸素的動態(tài)接口核心就三個動作// 添加一個熱點先創(chuàng)建空對象 viewer.call(addhotspot( id )) // 設(shè)置熱點屬性 viewer.set(hotspot[ id ].ath, 35.2) viewer.set(hotspot[ id ].atv, 8.4) // 刪除熱點 viewer.call(removehotspot( id ))這套API的思路是先把熱點對象創(chuàng)建出來再逐項set屬性。所有屬性都支持運行時修改所以你完全可以在創(chuàng)建之后隨時更新它的url、scale、visible實現(xiàn)狀態(tài)切換。3.3 事件模型onclick的兩種姿勢給熱點綁點擊事件常見的有兩種。第一種是在onclick屬性里直接寫js()調(diào)用viewer.set(hotspot[ id ].onclick, js(bridgeOnHotspotClick( id )))這個js()動作的意思是調(diào)用全局window對象下的bridgeOnHotspotClick函數(shù)把熱點id傳過去。這也是我推薦的方式因為熱點id是我們自己控制的業(yè)務(wù)標(biāo)識在Vue側(cè)拿到這個id之后就可以查數(shù)據(jù)、彈窗、做更新鏈路很清晰。另一種是給krpano viewer注冊全局事件監(jiān)聽比如用addEventListener監(jiān)聽熱點相關(guān)事件。這種方式適合處理觸摸手勢、設(shè)備旋轉(zhuǎn)等底層交互但對于業(yè)務(wù)熱點過度依賴監(jiān)聽方式會讓代碼耦合度變高排查問題時還要理清哪一層事件冒泡出了問題。我試過在兩個項目里用監(jiān)聽方式后來全部改回js()內(nèi)聯(lián)不是因為不能做而是團隊協(xié)作時js()方式更直觀新人看一眼就知道熱點點擊之后會走到哪個函數(shù)。4. 在Vue里動態(tài)添加熱點從業(yè)務(wù)數(shù)據(jù)到全景對象的完整鏈路4.1 先設(shè)計好熱點數(shù)據(jù)結(jié)構(gòu)在動手寫代碼前先約定一份熱點數(shù)據(jù)格式我踩過數(shù)據(jù)結(jié)構(gòu)不統(tǒng)一的坑后端給的字段一會兒是longitude一會兒是lng前端這邊非常被動。這里我建議Vue組件里維護統(tǒng)一結(jié)構(gòu)hotspots: [ { id: gate-001, name: 小區(qū)大門, type: gate, ath: 35.2, atv: 8.4, icon: /hotspots/gate.png, status: normal, bizData: { deviceId: D-1001 } } ]id是krpano里熱點的唯一標(biāo)識同時還是我們業(yè)務(wù)匹配的鑰匙。icon路徑在開發(fā)時就是public下的絕對路徑避免打包后相對路徑錯亂。status字段用來控制不同狀態(tài)下的圖標(biāo)切換這塊在第5章會展開。4.2 addHotspot、updateHotspot、removeHotspot的封裝直接在組件里散落viewer.set也不是不行但項目一大就會失控。我封裝了三個方法組件里只需要調(diào)用不需要關(guān)心viewer是否ready// krpanoService.js 內(nèi)繼續(xù)補充 addHotspot(item) { this.onReady((viewer) { if (!item.id || viewer.get(hotspot[ item.id ])) { return } viewer.call(addhotspot( item.id )) this.updateHotspot(item) }) }, updateHotspot(item) { this.onReady((viewer) { const prefix hotspot[ item.id ] viewer.set(prefix .ath, item.ath) viewer.set(prefix .atv, item.atv) viewer.set(prefix .url, item.icon) viewer.set(prefix .scale, item.scale || 1) viewer.set(prefix .zorder, item.zorder || 2) viewer.set(prefix .onclick, js(bridgeOnHotspotClick( item.id ))) }) }, removeHotspot(id) { this.onReady((viewer) { viewer.call(removehotspot( id )) }) }有一個細節(jié)需要提醒調(diào)用addhotspot之前最好先判斷同名熱點是否已存在。因為krpano的addhotspot不會自動去重同名重復(fù)添加會導(dǎo)致熱點被覆蓋或者拋出警告。用viewer.get(hotspot[ id ])可以探測是否存在如果已經(jīng)存在就直接走update邏輯更新屬性。4.3 批量同步全量替換還是主動Diff航拍巡檢項目里后端接口每秒都在推送設(shè)備位置熱點的增刪很頻繁。最初我圖省事每次接口返回就remove掉所有舊熱點再add所有新熱點。結(jié)果就是全景畫面頻繁閃動而且當(dāng)熱點數(shù)量到200個以上時瀏覽器渲染性能顯著下降。后來改成主動Diff之后性能改善明顯。思路很簡單用Set算出三種數(shù)據(jù)應(yīng)該刪除的、應(yīng)該新增的、應(yīng)該更新的然后分別處理syncHotspots(newList) { const oldIds new Set(this.hotspotIds) const newIds new Set(newList.map(h h.id)) // 刪除消失的 oldIds.forEach(id { if (!newIds.has(id)) { this.removeHotspot(id) } }) // 新增或更新 newList.forEach(item { if (oldIds.has(item.id)) { this.updateHotspot(item) } else { this.addHotspot(item) } }) this.hotspotIds Array.from(newIds) }這個diff邏輯在這類全景聯(lián)動場景里是通用的你可以直接抄。真正常踩的坑是update的時候也會set onclick而set onclick會導(dǎo)致重復(fù)綁定嗎不會onclick屬性是覆蓋式的不會疊加執(zhí)行這點krpano做得還算干凈。4.4 坐標(biāo)換算后端給的不是krpano坐標(biāo)怎么辦做全景項目的后端同學(xué)經(jīng)常直接把GPS經(jīng)緯度當(dāng)作ath/atv傳過來結(jié)果熱點全飛到天上去或者堆在地面以下。krpano的ath/atv是球形坐標(biāo)和GPS經(jīng)緯度沒有直接對應(yīng)關(guān)系不能混用。實際項目里有兩種處理方案。第一種是建全景時就在krpano編輯器里手工標(biāo)注點位導(dǎo)出坐標(biāo)存庫前端直接用這是最穩(wěn)的方式。第二種是后端給GPS前端通過計算偏移量來轉(zhuǎn)換。如果一定要換算至少需要一個基準(zhǔn)點以全景中心的GPS坐標(biāo)為原點后續(xù)GPS坐標(biāo)和它求差值乘以一個比例系數(shù)映射到ath/atv。但注意這個系數(shù)跟拍攝設(shè)備、鏡頭焦距有關(guān)需要實拍校準(zhǔn)不是固定的0.00001這種經(jīng)驗值能解決的。所以我的建議是熱點坐標(biāo)盡量在制作全景時就確定這是投入產(chǎn)出比最高的做法。5. 雙向聯(lián)動熱點點擊、Vue狀態(tài)和外部控制的常見場景5.1 點擊熱點彈出業(yè)務(wù)彈窗的處理這是幾乎每個項目都會遇到的需求。核心難在js()調(diào)用只能找到window下的函數(shù)而Vue組件里的方法不在window上。解決辦法是在組件mounted時主動把處理函數(shù)掛在window上組件銷毀時再刪除。mounted() { window.bridgeOnHotspotClick this.handleHotspotClick }, beforeUnmount() { delete window.bridgeOnHotspotClick // 其他銷毀邏輯 }, methods: { handleHotspotClick(id) { const current this.hotspots.find(h h.id id) if (!current) return this.hotspotDialogVisible true this.currentHotspot current } }這里有一個非?,F(xiàn)實的坑不要在onclick的js()調(diào)用里傳對象只傳id。因為js()接收的參數(shù)本質(zhì)是字符串你傳一個JSON對象過去最終得到的是一段[object Object]。我甚至見過有人把整個熱點的業(yè)務(wù)屬性拼成字符串再在Vue側(cè)解析這不是不行但完全沒有必要用id去數(shù)據(jù)源里查一遍就是最可靠的。5.2 外部列表驅(qū)動視角移動熱點聯(lián)動還有另一個方向用戶點擊Vue側(cè)的列表項全景視角平滑移動到對應(yīng)熱點位置。krpano的lookat動作就是干這個的moveToHotspot(id) { const item this.hotspots.find(h h.id id) if (!item || !this.viewer) return this.viewer.call(lookat( item.ath , item.atv ,90)) }如果希望視角是平滑移動過去而不是瞬間跳變lookat支持在動作后面補上過渡時間參數(shù)。實際寫起來大致是這樣this.viewer.call( lookat( item.ath , item.atv ,90,0,0,0,1200) )參數(shù)含義是目標(biāo)視角的h、v、fov然后是當(dāng)前視角的h、v、fov最后1200是過渡時長毫秒。這個順手記一下就行真正用的時候查API也能看到注釋。注意這里的0是當(dāng)前視角參數(shù)當(dāng)你傳入非零值時會從那個位置開始過渡實際項目里通常傳0即可但如果你想實現(xiàn)從上一個熱點位置飛過來的效果就可以把當(dāng)前視角參數(shù)填成當(dāng)前視角的真實值。5.3 熱點狀態(tài)聯(lián)動的實現(xiàn)思路巡檢類項目里熱點狀態(tài)經(jīng)常要在未處理、處理中、已完成之間切換。我建議用url替換實現(xiàn)狀態(tài)切換因為單純改visible或者scale并不能很好地表達語義差異。做法是預(yù)置三套圖標(biāo)路徑狀態(tài)變了就updateHotspotchangeHotspotStatus(id, status) { const item this.hotspots.find(h h.id id) if (!item) return const iconMap { normal: /hotspots/dot-gray.png, processing: /hotspots/dot-yellow.png, done: /hotspots/dot-green.png } this.updateHotspot({ ...item, status, icon: iconMap[status] }) }這個方案簡單直接Vue列表側(cè)也能同時響應(yīng)狀態(tài)變化因為item是響應(yīng)式對象列表樹會自動更新。有一點要留意對于已經(jīng)存在且被用戶視線注視的熱點突然替換url會有一個極短的閃爍這是正?,F(xiàn)象但如果在同一幀內(nèi)對幾十個熱點同時改url會有肉眼可見的卡頓建議分批處理。6. 實測排坑坐標(biāo)偏移、渲染層級、打包路徑與實例殘留6.1 熱點點擊不到先查命中區(qū)域和zorder熱點點擊失效大概率不是事件代碼問題而是命中區(qū)域被遮擋。krpano的熱點雖然有層級概念但層級排序規(guī)則是zorder數(shù)值越大顯示越靠上。如果你有一個透明的吸附熱區(qū)遮在真正可點擊的熱點上面底下的熱點就永遠點不到。我遇到過一次真實情況為了在某個區(qū)域做鼠標(biāo)懸停變色我加了一個覆蓋整個墻面的透明熱點結(jié)果墻面上所有小熱點全部點不了。排查方式很簡單在XML或動態(tài)初始化時把透明熱點的zorder調(diào)低比如透明吸附層用1業(yè)務(wù)熱點用2問題立刻解決。另外熱點在屏幕上的實際可點擊區(qū)域不完全是圖標(biāo)圖片的顯示區(qū)域。krpano會根據(jù)熱點的scale和圖片尺寸計算screen區(qū)域如果你的圖標(biāo)本身很透明、四周留白過大視覺上看著不小實際的可點擊命中區(qū)域卻很小。解決辦法是給熱點再加一個純色的透明底圖或者直接用帶padding的png資源。6.2 ath/atv坐標(biāo)偏移的兩種典型情況坐標(biāo)偏移最典型的一種情況是換了一臺設(shè)備拍攝全景圖的球面零點發(fā)生了變化之前標(biāo)定的點全部偏移了十幾度。這不是前端代碼的問題而是素材源的問題。我的處理辦法是在開發(fā)環(huán)境保存一份基準(zhǔn)點位json發(fā)現(xiàn)偏移時對照檢查確定是源素材變了再做批量坐標(biāo)平移別在前端代碼里單個點去試錯補正。另一種偏移是進入場景的初始視角帶了rotate()或者通過lookat設(shè)置了初始鏡頭角度導(dǎo)致開發(fā)者以為自己看到的正前方就是0度而krpano的ath零點是在未旋轉(zhuǎn)的原始方向。記住一句話ath是相對全景文件本身的世界坐標(biāo)不是相對你當(dāng)前視野的坐標(biāo)。熱點不會因為視角旋轉(zhuǎn)而歪這也是全景交互的基礎(chǔ)。6.3 打包后熱點圖片404的路徑邏輯Vue項目打包之后熱點圖片404是非常高頻的問題。根源在于krpano的熱點url屬性是它自己解析路徑不是webpack解析。很多人在開發(fā)環(huán)境正常是因為dev server的路徑和public目錄是一致的一旦打包部署到子目錄或者CDN相對路徑就全亂了。我的處理方案是熱點圖片全部放public/hotspots前端拼絕對路徑const publicPath process.env.BASE_URL || / icon: ${publicPath}hotspots/dot-green.png這樣即使部署到子路徑只要BASE_URL配置正確熱點資源也能正常加載。這一點同樣適用于krpano的vtour靜態(tài)文件它們放public里是安全的但前提是index.html里embedpano.js的src也要用相對路徑或者集成BASE_URL不然整個全景都加載不出來。6.4 時序問題onReady回調(diào)注冊太晚最后說一個最隱蔽的問題。如果你在主入口直接初始化krpano全局實例然后在某個深度組件里才去添加熱點此時如果viewer已經(jīng)readyonReady回調(diào)會立即執(zhí)行但如果初始化還沒完成你的回調(diào)就被排隊。這個設(shè)計本身沒問題真正的坑是組件卸載時如果回調(diào)還留在隊列里之后會被意外執(zhí)行。我建議在Service里維護回調(diào)隊列時給每個回調(diào)帶一個標(biāo)記或者組件卸載時主動從隊列中移除。不然一個已經(jīng)被銷毀的組件它的初始化還是會在幾毫秒后執(zhí)行導(dǎo)致在全局狀態(tài)里寫入臟數(shù)據(jù)非常難查。removeReadyCallback(token) { this.readyCallbacks this.readyCallbacks.filter( cb cb.token ! token ) }用的地方就是組件mounted時 onReady(cb) 并保存tokenbeforeUnmount時 removeReadyCallback(token)。這套機制看起來簡單但實際排查一次就知道值不值。把熱點做成活的核心是把生命周期交給Vue我在實際項目中最大的體會是krpano本身并不難難點在于它的運行生命周期和Vue組件的生命周期之間要建立一套清晰的映射關(guān)系。熱點不只是加在XML里的一次性配置它應(yīng)該是由Vue數(shù)據(jù)驅(qū)動的、可以被創(chuàng)建、更新、銷毀的業(yè)務(wù)對象。做到這一點之后全景里的熱點就活了它能跟著接口數(shù)據(jù)實時變化能和列表、彈窗、狀態(tài)流轉(zhuǎn)緊密聯(lián)動。如果你正卡在某個具體問題上先回頭看看熱點的命名是否沖突、圖片路徑是否絕對、onReady是否安全、組件銷毀是否干凈這四個點能解決八成以上的詭異問題。希望這篇文章能幫你把Vue嵌入krpano的熱點功能穩(wěn)定落地少走我走過的這些彎路。