Files
lucia-frontend/src/module/cytoscapeMap.js
2026-03-06 18:57:58 +08:00

322 lines
13 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The Lucia project.
// Copyright 2023-2026 DSP, inc. All rights reserved.
// Authors:
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
// imacat.yang@dsp.im (imacat), 2023/9/23
// cindy.chang@dsp.im (Cindy Chang), 2024/5/30
/**
* @module cytoscapeMap Cytoscape.js process map rendering with
* interactive node/edge highlighting, tooltips, and position persistence.
*/
import cytoscape from 'cytoscape';
import spread from 'cytoscape-spread';
import dagre from 'cytoscape-dagre';
import fcose from 'cytoscape-fcose';
import cola from 'cytoscape-cola';
import tippy from 'tippy.js';
import 'tippy.js/dist/tippy.css';
import { useMapPathStore } from '@/stores/mapPathStore';
import { getTimeLabel } from '@/module/timeLabel.js';
import { useCytoscapeStore } from '@/stores/cytoscapeStore';
import { SAVE_KEY_NAME } from '@/constants/constants.js';
/**
* Composes display text for frequency-type data layer values.
*
* @param {string} baseText - The base label text prefix.
* @param {string} dataLayerOption - The data layer option key
* (e.g. "rel_freq" for relative frequency).
* @param {number} optionValue - The numeric value to format.
* @returns {string} The formatted text with the value appended.
*/
const composeFreqTypeText = (baseText, dataLayerOption, optionValue) => {
let text = baseText;
const textInt = dataLayerOption === 'rel_freq' ? baseText + optionValue * 100 + "%" : baseText + optionValue;
const textFloat = dataLayerOption === 'rel_freq' ? baseText + (optionValue * 100).toFixed(2) + "%" : baseText + optionValue.toFixed(2);
// 判斷是否為整數,若非整數要取小數點後面兩個值。
text = Math.trunc(optionValue) === optionValue ? textInt : textFloat;
return text;
};
// 註冊布局演算法
cytoscape.use(dagre);
cytoscape.use(spread);
cytoscape.use(fcose);
cytoscape.use(cola);
/**
* Creates and configures a Cytoscape.js process map instance with
* interactive features including node/edge highlighting, tooltips,
* and position persistence via localStorage.
*
* @param {Object} mapData - The map data containing nodes and edges.
* @param {number} mapData.startId - The start node ID.
* @param {number} mapData.endId - The end node ID.
* @param {Array} mapData.nodes - Array of node data objects.
* @param {Array} mapData.edges - Array of edge data objects.
* @param {string} dataLayerType - The data layer type ("freq" or "duration").
* @param {string} dataLayerOption - The data layer option key
* (e.g. "abs_freq", "rel_freq", "rel_duration").
* @param {string} curveStyle - The edge curve style
* ("unbundled-bezier" or "taxi").
* @param {string} rank - The layout direction ("TB" or "LR").
* @param {HTMLElement} graphId - The DOM container element for Cytoscape.
* @returns {cytoscape.Core} The configured Cytoscape instance.
*/
export default function cytoscapeMap(mapData, dataLayerType, dataLayerOption, curveStyle, rank, graphId) {
// 設定每個 node, edges 的顏色與樣式
let nodes = mapData.nodes;
let edges = mapData.edges;
// create Cytoscape
let cy = cytoscape({
container: graphId,
elements: {
nodes: nodes, //nodes, // 節點的資料
edges: edges, //edges, // 關係線的資料
},
layout: {
name: 'dagre',
rankDir: rank, // 直向 TB | 橫向 LR, 'cytoscape-dagre' 套件裡的變數
},
style: [
// 點擊 node 後改變的樣式
{
selector: 'node:selected',
style: {
'border-color': 'red',
'border-width': '3',
},
},
// node 節點的樣式
{
selector: 'node',
style: {
'label':
function (node) { // 節點要顯示的文字
// node.data(this.dataLayerType+"."+this.dataLayerOption) 為原先陣列 node.data.key.value
let optionValue = node.data(`${dataLayerType}.${dataLayerOption}`);
let text = '';
const STRING_LIMIT = 8;
if (node.data('label').length > STRING_LIMIT) {
// 若文字超過 STRING_LIMIT長度則字尾巴要加「...」style 要換兩行(\n 換行符號)
// 使用 data() 是因為在 cytoscape 中從陣列轉為 function
text = `${node.data('label').substr(0, STRING_LIMIT)}...\n\n`;
} else { // 補空白直到撐寬label的寬度這是為了統一所有label的寬度
text = `${node.data('label').padEnd(STRING_LIMIT, ' ')}\n\n`
}
// 在 element 中 activity 歸類在 default所以要先判斷 node 是否為 activity 才裝入文字。
// 可使用 parseInt(整數) parseFloat(浮點數) 將字串轉為數字
// Relative 要轉為百分比 %
if (node.data('type') === 'activity') {
let textDurRel;
let timeLabelInt;
let timeLabelFloat;
let textTimeLabel;
switch (dataLayerType) {
case 'freq': // Frequency
text = composeFreqTypeText(text, dataLayerOption, optionValue);
break;
case 'duration': // Duration 除了 Relative 為百分比 % ,其他要轉變時間單位。
// Relative %
textDurRel = text + (optionValue * 100).toFixed(2) + "%";
// Timelabel
timeLabelInt = text + getTimeLabel(optionValue);
timeLabelFloat = text + getTimeLabel(optionValue.toFixed(2));
// 判斷是否為整數,若非整數要取小數點後面兩個值。
textTimeLabel = Math.trunc(optionValue) === optionValue ? timeLabelInt : timeLabelFloat;
text = dataLayerOption === 'rel_duration' ? textDurRel : textTimeLabel;
break;
}
}
return text;
},
'text-opacity': 0.7,
'background-color': 'data(backgroundColor)',
'border-color': 'data(bordercolor)',
'border-width':
function (node) {
return node.data('type') === 'activity' ? '1' : '2';
},
'background-image': 'data(nodeImageUrl)',
'background-opacity': 'data(backgroundOpacity)', // 透明背景
'border-opacity': 'data(borderOpacity)', // 透明邊框
'shape': 'data(shape)',
'text-wrap': 'wrap',
'text-max-width': 'data(width)', // 在 div 內換行
'text-overflow-wrap': 'anywhere', // 在 div 內換行
'text-margin-x': function (node) {
return node.data('type') === 'activity' ? -5 : 0;
},
'text-margin-y': function (node) {
return node.data('type') === 'activity' ? 2 : 0;
},
'padding': function (node) {
return node.data('type') === 'activity' ? 0 : 0;
},
'text-justification': 'left',
'text-halign': 'center',
'text-valign': 'center',
'height': 'data(height)',
'width': 'data(width)',
'color': 'data(textColor)',
'line-height': '0.7rem',
'font-size':
function (node) {
return node.data('type') === 'activity' ? 14 : 14;
},
},
},
// edge 關係線的樣式
{
selector: 'edge',
style: {
'content': function (edge) { // 關係線顯示的文字
let optionValue = edge.data(`${dataLayerType}.${dataLayerOption}`);
let result = '';
let edgeInt;
let edgeFloat;
let edgeDurRel;
let timeLabelInt;
let timeLabelFloat;
let edgeTimeLabel;
if (optionValue === '') return optionValue;
switch (dataLayerType) {
case 'freq':
edgeInt = dataLayerOption === 'rel_freq' ? optionValue * 100 + "%" : optionValue;
edgeFloat = dataLayerOption === 'rel_freq' ? (optionValue * 100).toFixed(2) + "%" : optionValue.toFixed(2);
// 判斷是否為整數,若非整數要取小數點後面兩個值。
result = Math.trunc(optionValue) === optionValue ? edgeInt : edgeFloat;
break;
case 'duration': // Duration 除了 Relative 為百分比 % ,其他要轉變時間單位。
// Relative %
edgeDurRel = (optionValue * 100).toFixed(2) + "%";
// Timelabel
timeLabelInt = getTimeLabel(optionValue);
timeLabelFloat = getTimeLabel(optionValue.toFixed(2));
edgeTimeLabel = Math.trunc(optionValue) === optionValue ? timeLabelInt : timeLabelFloat;
result = dataLayerOption === 'rel_duration' ? edgeDurRel : edgeTimeLabel;
break;
};
return result;
},
'curve-style': curveStyle, // unbundled-bezier | taxi
'overlay-opacity': 0, // 將overlay-opacity設置為0移除灰色陰影
'target-arrow-shape': 'triangle', // 指向目標的箭頭形狀: 三角形
'color': 'gray', //#0066cc
//'control-point-step-size':100, // 從點到點的垂直線,指定貝茲取線邊緣間的距離
'width': 'data(lineWidth)',
'line-style': 'data(edgeStyle)',
"text-margin-y": "0.7rem",
//"text-rotation": "autorotate",
}
}, {
selector: '.highlight-edge',
style: {
'color': '#0099FF',
'line-color': '#0099FF',
'overlay-color': '#0099FF',
'overlay-opacity': 0.2,
'overlay-padding': '5px',
},
}, {
selector: '.highlight-node',
style: {
'overlay-color': '#0099FF',
'overlay-opacity': 0.01,
'overlay-padding': '5px',
},
}, {
selector: 'edge[source = target]', // 選擇 self-loop 的邊
style: {
'loop-direction': '0deg', // 控制 loop 的方向
'loop-sweep': '-60deg', // 控制 loop 的弧度,這裡可以調整弧度以改變大小
'control-point-step-size': 50 // 控制 loop 的半徑大小,增加這個值可以增大 loop
}
},
],
});
// 按下線條,線條及線條上數字有光暈效果
cy.on('tap', 'edge', function (event) {
cy.edges().removeClass('highlight-edge');
event.target.addClass('highlight-edge');
});
// 按下節點光暈效果與鄰邊光暈效果
cy.on('tap, mousedown', 'node', function (event) {
useMapPathStore().onNodeClickHighlightEdges(event.target);
});
// 按下線段光暈效果與兩端點光暈效果
cy.on('tap, mousedown', 'edge', function (event) {
useMapPathStore().onEdgeClickHighlightNodes(event.target);
});
// creat tippy.js
let tip;
cy.on('mouseover', 'node', function (event) {
const node = event.target;
let ref = node.popperRef()
let dummyDomEle = document.createElement('div');
let content = document.createElement('div');
content.innerHTML = node.data("label")
tip = new tippy(dummyDomEle, { // tippy props:
getReferenceClientRect: ref.getBoundingClientRect,
trigger: 'manual',
content: content
});
if (node.data("label").length > 10) tip.show();
});
cy.on('mouseout', 'node', function (event) {
tip.hide();
});
// here we remember and recall positions
const cytoscapeStore = useCytoscapeStore();
cy.ready(() => {
cytoscapeStore.loadPositionsFromStorage(rank);
// 判斷localStorage是否儲存過拜訪資訊
// 若曾經儲存過拜訪後的座標位置則restore位置來渲染出來
if (localStorage.getItem(SAVE_KEY_NAME) && JSON.parse(localStorage.getItem(SAVE_KEY_NAME))) {
const allGraphsRemembered = JSON.parse(localStorage.getItem(SAVE_KEY_NAME));
const currentGraphNodesRemembered =
allGraphsRemembered[cytoscapeStore.currentGraphId] ? allGraphsRemembered[cytoscapeStore.currentGraphId][rank] : null; // 可能是undefined
if (currentGraphNodesRemembered) {
currentGraphNodesRemembered.forEach(nodeRemembered => {
const nodeToDecide = cy.getElementById(nodeRemembered.id);
if (nodeToDecide) {
nodeToDecide.position(nodeRemembered.position);
}
});
}
}
//存下此刻剛進入畫面時當前所有節點的座標位置
const allNodes = cy.nodes();
allNodes.forEach(nodeFirstlySave => {
cytoscapeStore.saveNodePosition(nodeFirstlySave.id(), nodeFirstlySave.position(), rank);
});
// 在改變節點位置後,盡可能地記錄節點線條的位置情報
// rank 代表現在使用者切換的是水平方向還是垂直方向模式
cy.on('dragfree', 'node', (event) => {
const nodeToSave = event.target;
cytoscapeStore.saveNodePosition(nodeToSave.id(), nodeToSave.position(), rank);
});
});
return cy;
}