// 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; }