使用 Cytoscape.js 實現廠房機台地圖繪製功能
前言
故事緣起是來自一個製造業客戶,目前既有功能是可以檢視廠房平面圖及機台的相對位置,可使用顏色標記出有狀況的機台,並且在點選機台時顯示該機台的即時資訊。
客戶的痛點在於該地圖及機台是使用 HTML Table 刻出,也就表示如果機台位置異動必須經由軟體工程師調整,無法讓工廠管理者自行維護處理;另外工程師在面對以 HTML Table 刻出的地圖及機台時,若要調整相對位置或平移數個機台是非常傷神的,所以筆者嘗試使用 Cytoscape.js 來解決這些痛點。
可行性驗證 (POC)
由於筆者以前也沒有使用過 Cytoscape.js 類別庫的經驗,只知道是一個視覺化類別庫,因此先針對以下幾點功能進行可行性驗證:
- 可否新增不同大小形狀的物件(機台)
- 可否拖拉移動物件(機台)擺放在所需的位置
- 可否刪除物件(機台)
- 是否可以儲存 / 取出目前顯示的所有物件(機台)
- 是否可以點對點的連線(牆壁)
- 是否可在點選時取得物件(機台)資料
- 是否可主動依照物件(機台)ID動態調整該物件(機台)背景色
首先產生畫板
從 npm 下載 cytoscape 套件。
npm install cytoscape
引入專案中
import cytoscape from 'cytoscape';
針對 id 為 cy 的 div 建立出畫板,並儲存在 this.cy;其中 style 定義了 node 樣式,而樣式皆可經由新增 node 時給予的 data 參數來決定,因此可產生不同風格的 node 物件。
this.cy = cytoscape({
container: document.getElementById('cy'),
elements: [
{
group: 'nodes',
data: { name: 'Test', weight: 75, type: 'circle', width: 30, height: 30 },
position: { x: 50, y: 100 }
}
],
layout: {
name: 'preset'
},
style: [
{
selector: 'node',
style: {
'label': 'data(name)',
"shape": "data(type)",
'width': "data(width)",
'height': "data(height)",
'font-size': 12,
}
}
]
})
顯示一下成果,畫布已經產生,並且擁有一個剛剛在初始時加入 elements 的 test node 物件。
POC - 新增不同大小形狀的物件
可以透過 cy.add() 加入 node 物件,可在 data 中自訂寬高 width / height 及形狀 type,若沒有賦予 id 值時會自動產生出 guid 作為該物件的 id 值。
handleAddMachine01 = () => {
this.cy.add({
group: 'nodes',
data: { name: 'M', weight: 75, type: 'rectangle', width: 30, height: 50 },
position: { x: 50, y: 100 }
})
}
handleAddMachine02 = () => {
this.cy.add({
group: 'nodes',
data: { name: 'P', weight: 75, type: 'rectangle', width: 80, height: 50 },
position: { x: 150, y: 100 }
})
}
從以下結果可以發現確實可以依照需求產生不同大小形狀的物件。
POC - 拖拉移動物件(機台)擺放在所需的位置
可以直接透過滑鼠點選物件後拖拉物件移動,也可以按 Ctrl 及滑鼠左鍵圈選多個需要移動的物件後,一次性地移動相對位置。
POC - 刪除物件(機台)
可以透 cy.$(':selected') 取得被選取的物件,然後透過 remove() 將這些物件移除。
handleRemove = () => {
const eles = this.cy.$(':selected');
eles.remove()
}
POC - 儲存 / 取出目前顯示的所有物件
可以透 cy.json() 取出資料後暫存 (此範例僅保存在 localStorage 中);當需要重現資料時,可以透過 cy.json(data) 將資料放入後顯示。
handleSave = () => {
// 儲存目前的畫面資料
const cyjsonStr = JSON.stringify(this.cy.json())
window.localStorage.setItem("elements", cyjsonStr);
message.success('This layout has been Saved.');
}
handleRestore = () => {
// 移除畫面上所有物件
this.cy.elements().remove();
// 取出畫面資料顯示在畫板上
const cyjson = JSON.parse(window.localStorage.getItem("elements"))
this.cy.json({ ...cyjson })
}
調整物件位置後按下儲存,關閉頁籤並再次開啟時,可經由 Restore 取出資料呈現剛才儲存的所有物件。
POC - 點對點的連線(牆壁)
目前 Cytoscape.js 已有許多成熟的 extension 可以輔助我們完成各項功能,其中 cytoscape-edgehandles 可以幫忙我們完成畫線功能,因此先透過 npm 安裝該套件。
npm install cytoscape-edgehandles
引入專案並向 cytoscape 註冊使用此插件。
import edgehandles from 'cytoscape-edgehandles'
cytoscape.use(edgehandles);
初始 edgehandles 插件並存入 this.eh 變數中,在 edgehandles(default) 方法中可以加入設定值來調整預設值(此範例先使用預設值無需傳入資料),細節請參考官方設定。
this.eh = this.cy.edgehandles();
由於畫線的過程中會有一些樣式的套用,因此需要加入一些 style 上去,所以在剛剛建立 cytoscape 畫板的 style 清單中補上以下樣式。
{
selector: '.eh-handle',
style: {
'background-color': 'red',
'width': 12,
'height': 12,
'shape': 'ellipse',
'overlay-opacity': 0,
'border-width': 12, // makes the handle easier to hit
'border-opacity': 0
}
},
{
selector: '.eh-hover',
style: {
'background-color': 'red'
}
},
{
selector: '.eh-source',
style: {
'border-width': 2,
'border-color': 'red'
}
},
{
selector: '.eh-target',
style: {
'border-width': 2,
'border-color': 'red'
}
},
{
selector: '.eh-preview, .eh-ghost-edge',
style: {
'background-color': 'red',
'line-color': 'red',
'target-arrow-color': 'red',
'source-arrow-color': 'red'
}
},
{
selector: '.eh-ghost-edge.eh-preview-active',
style: {
'opacity': 0
}
}
接著就可以測試看看了。當游標移上物件時,會出現紅色的 indicator 表示可連線狀態,接著點下滑鼠左鍵拖曳就可以完成連線,以此作為平面圖的牆面。
若一直保持可連線的狀況,對於想移動物件時是種阻礙,因此最好是設定一個開關當要用的時候在開啟即可;我們可以透過 eh.enable() 及 eh.disable() 切換,另外紅色的 indicator 有可能會殘留,因此在 disable 時可以呼叫 eh.hide() 先隱藏起來 (在儲存前也可以先執行此方法來避免 indicator 也被存入)。
handleSwitchConnectMode = () => {
// 可以開啟或關閉node連線模式,避免在拖曳的時候影響使用者體驗
const { isEnableEh } = this.state
if (isEnableEh) {
this.eh.hide()
this.eh.disable();
} else {
this.eh.enable();
}
this.setState({ isEnableEh: !isEnableEh })
}
POC - 點選時取得物件(機台)資料
要取得物件資料前,要先定義物件被點選時的事件,透過 cy.on() 訂定 node 被 click 的事件,從中取得該 node 的資料;以下代碼會將 node id 存放在 state 中顯示在畫面上。
const self = this
this.cy.on('click', 'node', function (evt) {
var node = evt.target;
const nodeData = node.json()
self.setState({ selectedNodeId: nodeData.data.id })
});
在點選物件時,右下方會顯示該物件 ID,在實務應用上可經由此 ID 延伸向後端取得該機台的即時資訊。
POC - 主動依照物件 ID 動態調整該物件背景色
在實際應用情境上,可能會定期從中台取得機台狀態,並要 highlight 有狀況的機台,因此需主動依據機台編號調整物件背景色,所以先定義一組高亮的 style 吧。
{
selector: '.highlight',
style: {
'background-color': '#ffd118',
'line-color': '#ffd118',
'target-arrow-color': '#ffd118',
'transition-property': 'background-color, line-color, target-arrow-color',
'transition-duration': '0.5s'
}
}
為了測試方便,我們從 cy.json() 把所有機台物件都列出。
handleFindMe = () => {
const source = this.cy.json()
if (source) {
const { elements: { nodes: nodes } } = source
if (nodes && nodes.length > 0) {
// 取出所有機台物件
const machines = nodes.filter(node => node.data.type === "rectangle").map(n => n.data)
this.setState({ machines: machines })
// 顯示清單視窗
this.showFindMeModal()
}
}
}
選定特定物件 ID 後透過 cy.animate() 動畫效果移動畫布讓將該物件置中顯示,再針對該物件套用特定樣式;由於此範例的作用僅在於吸引眼球的注意,因此透過 flashClass() 暫時切換樣式,等固定時間後自動復原。
handleFindMeModalOk = e => {
const eleId = this.state.selectedNodeId
if (eleId) {
// 將該物件至於畫布中顯示
this.cy.animate({ center: { eles: `#${eleId}` } })
// 套用 class 固定時間後復原
this.cy.$(`#${eleId}`).flashClass('highlight', 1500);
}
};
效果如下
後記
站在巨人的肩膀進行開發確實可以減少許多技術面上的問題,讓我們專心地聚焦在應用層面上,本文僅記錄筆者從發想到執行 POC 的過程,目前尚未實際將此 solution 應用於專案中,未來若有機會實際應用再來分享需要特別注意的地方吧!
有興趣的朋友可以至 DEMO 網站玩玩。
參考資訊
希望此篇文章可以幫助到需要的人
若內容有誤或有其他建議請不吝留言給筆者喔 !