Files
lucia-frontend/src/module/cytoscapeMap.js
2026-03-07 20:03:19 +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.
// 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);
// Check if the value is an integer; if not, round to 2 decimal places.
text = Math.trunc(optionValue) === optionValue ? textInt : textFloat;
return text;
};
// Register layout algorithms
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) {
// Set the color and style for each node and edge
let nodes = mapData.nodes;
let edges = mapData.edges;
// create Cytoscape
let cy = cytoscape({
container: graphId,
elements: {
nodes: nodes, // Node data
edges: edges, // Edge data
},
layout: {
name: 'dagre',
rankDir: rank, // Vertical TB | Horizontal LR, variable from 'cytoscape-dagre' plugin
},
style: [
// Style changes when a node is selected
{
selector: 'node:selected',
style: {
'border-color': 'red',
'border-width': '3',
},
},
// Node styling
{
selector: 'node',
style: {
'label':
function (node) { // Text to display on the node
// node.data(this.dataLayerType+"."+this.dataLayerOption) accesses the original array value at node.data.key.value
let optionValue = node.data(`${dataLayerType}.${dataLayerOption}`);
let text = '';
const STRING_LIMIT = 8;
if (node.data('label').length > STRING_LIMIT) {
// If text exceeds STRING_LIMIT, append "..." and add line breaks (\n)
// Using data() because Cytoscape converts array data to function calls
text = `${node.data('label').substr(0, STRING_LIMIT)}...\n\n`;
} else { // Pad with spaces to match the label width for consistent sizing
text = `${node.data('label').padEnd(STRING_LIMIT, ' ')}\n\n`
}
// In elements, activity is categorized as default, so check if the node is an activity before adding text.
// Use parseInt (integer) or parseFloat (float) to convert strings to numbers.
// Relative values need to be converted to percentages (%)
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 is percentage %, others need time unit conversion.
// Relative %
textDurRel = text + (optionValue * 100).toFixed(2) + "%";
// Timelabel
timeLabelInt = text + getTimeLabel(optionValue);
timeLabelFloat = text + getTimeLabel(optionValue.toFixed(2));
// Check if the value is an integer; if not, round to 2 decimal places.
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)', // Transparent background
'border-opacity': 'data(borderOpacity)', // Transparent border
'shape': 'data(shape)',
'text-wrap': 'wrap',
'text-max-width': 'data(width)', // Wrap text within the node
'text-overflow-wrap': 'anywhere', // Allow wrapping at any position
'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 styling
{
selector: 'edge',
style: {
'content': function (edge) { // Text displayed on the 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);
// Check if the value is an integer; if not, round to 2 decimal places.
result = Math.trunc(optionValue) === optionValue ? edgeInt : edgeFloat;
break;
case 'duration': // Duration: Relative is percentage %, others need time unit conversion.
// 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, // Set overlay-opacity to 0 to remove the gray shadow
'target-arrow-shape': 'triangle', // Arrow shape pointing to target: triangle
'color': 'gray', //#0066cc
//'control-point-step-size':100, // Distance between Bezier curve control points
'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]', // Select self-loop edges
style: {
'loop-direction': '0deg', // Control the loop direction
'loop-sweep': '-60deg', // Control the loop arc; adjust to change size
'control-point-step-size': 50 // Control the loop radius; increase to enlarge the loop
}
},
],
});
// When an edge is clicked, apply glow effect to the edge and its label
cy.on('tap', 'edge', function (event) {
cy.edges().removeClass('highlight-edge');
event.target.addClass('highlight-edge');
});
// When a node is clicked, apply glow effect to the node and adjacent edges
cy.on('tap, mousedown', 'node', function (event) {
useMapPathStore().onNodeClickHighlightEdges(event.target);
});
// When an edge is clicked, apply glow effect to the edge and both endpoint nodes
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);
// Check if localStorage has previously saved visit data.
// If saved node positions exist, restore them for rendering.
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; // May be undefined
if (currentGraphNodesRemembered) {
currentGraphNodesRemembered.forEach(nodeRemembered => {
const nodeToDecide = cy.getElementById(nodeRemembered.id);
if (nodeToDecide) {
nodeToDecide.position(nodeRemembered.position);
}
});
}
}
// Save the current positions of all nodes when the view is first entered
const allNodes = cy.nodes();
allNodes.forEach(nodeFirstlySave => {
cytoscapeStore.saveNodePosition(nodeFirstlySave.id(), nodeFirstlySave.position(), rank);
});
// After node positions change, save the updated positions.
// rank represents whether the user is in horizontal or vertical layout mode.
cy.on('dragfree', 'node', (event) => {
const nodeToSave = event.target;
cytoscapeStore.saveNodePosition(nodeToSave.id(), nodeToSave.position(), rank);
});
});
return cy;
}