322 lines
13 KiB
JavaScript
322 lines
13 KiB
JavaScript
// 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;
|
||
}
|